///
Search
🦺

03.(220514)Kotlin으로 BLE(Bluetooth Low Energy)기반 블루투스 통신 기기 스캔(검색)하기

출처

시작하기 앞서

1.
블루투스 기기가 다 같은 블루투스 기기가 아닌 것 같다.
2.
HC-06, HM-10 등등 다양한 블루투스 모듈이 있어 그것에 따라 다르게 코드를 짜줘야 한다.
3.
프로젝트 명은 “keti_ble”이다.
4.
프로젝트는 kotlin으로 작성했다.
5.
그럼 시작
아래의 경로에 파일 하나 생성하기
해당 작업 이유는 어플을 제작할때 안에 들어가는 문자열을 별도로 따로 처리해줘야함
당연히 “keti_ble”, “scan” 문구는 다른걸로 바꾸어도 됨
scan_button이라는 문구를 사용해서 어플내 어플 버튼 이름을 선언하려면 이렇게 해야함

/app/src/main/res/values/strings.xml

<resources> <string name="app_name">keti_ble</string> <string name="scan_button">button2</string> </resources>
XML
복사
아래의 경로에 파일 하나 생성하기

/app/src/main/res/layout/recyclerview_item.xml

<?xml version="1.0" encoding="utf-8"?> <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="match_parent"> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" tools:context=".MainActivity"> <TextView android:id="@+id/item_name" android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="item_name" android:textAppearance="@style/TextAppearance.AppCompat.Medium" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" /> <TextView android:id="@+id/item_address" android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="item_address" android:layout_marginLeft="20dp" android:textAppearance="@style/TextAppearance.AppCompat.Medium" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" /> </LinearLayout> </androidx.constraintlayout.widget.ConstraintLayout>
XML
복사
마지막 소스코드는 아래의 경로에 있는 파일에 ctrl+CV 해주자

/app/src/main/java/com/example/a220428_1114_bluetooth/MainActivity.kt

package com.example.keti_ble import android.Manifest import android.annotation.TargetApi import android.bluetooth.BluetoothAdapter import android.bluetooth.BluetoothDevice import android.bluetooth.le.ScanCallback import android.bluetooth.le.ScanResult import android.content.Context import android.content.Intent import android.content.pm.PackageManager import android.os.Build import androidx.appcompat.app.AppCompatActivity import android.os.Bundle import android.os.Handler import android.util.Log import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.* import androidx.annotation.RequiresApi import androidx.core.app.ActivityCompat import androidx.core.view.isVisible import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView class MainActivity : AppCompatActivity() { private val REQUEST_ENABLE_BT=1 private val REQUEST_ALL_PERMISSION= 2 private val PERMISSIONS = arrayOf( Manifest.permission.ACCESS_FINE_LOCATION ) private var bluetoothAdapter: BluetoothAdapter? = null private var scanning: Boolean = false private var devicesArr = ArrayList<BluetoothDevice>() private val SCAN_PERIOD = 1000 private val handler = Handler() private lateinit var viewManager: RecyclerView.LayoutManager private lateinit var recyclerViewAdapter : RecyclerViewAdapter private val mLeScanCallback = @RequiresApi(Build.VERSION_CODES.LOLLIPOP) object : ScanCallback() { override fun onScanFailed(errorCode: Int) { super.onScanFailed(errorCode) Log.d("scanCallback", "BLE Scan Failed : " + errorCode) } override fun onBatchScanResults(results: MutableList<ScanResult>?) { super.onBatchScanResults(results) results?.let{ // results is not null for (result in it){ if (!devicesArr.contains(result.device) && result.device.name!=null) devicesArr.add(result.device) } } } override fun onScanResult(callbackType: Int, result: ScanResult?) { super.onScanResult(callbackType, result) result?.let { // result is not null if (!devicesArr.contains(it.device) && it.device.name!=null) devicesArr.add(it.device) recyclerViewAdapter.notifyDataSetChanged() } } } @RequiresApi(Build.VERSION_CODES.LOLLIPOP) private fun scanDevice(state:Boolean) = if(state){ handler.postDelayed({ scanning = false bluetoothAdapter?.bluetoothLeScanner?.stopScan(mLeScanCallback) }, SCAN_PERIOD) scanning = true devicesArr.clear() bluetoothAdapter?.bluetoothLeScanner?.startScan(mLeScanCallback) }else{ scanning = false bluetoothAdapter?.bluetoothLeScanner?.stopScan(mLeScanCallback) } private fun hasPermissions(context: Context?, permissions: Array<String>): Boolean { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && context != null && permissions != null) { for (permission in permissions) { if (ActivityCompat.checkSelfPermission(context, permission) != PackageManager.PERMISSION_GRANTED) { return false } } } return true } // Permission check @RequiresApi(Build.VERSION_CODES.M) override fun onRequestPermissionsResult( requestCode: Int, permissions: Array<String?>, grantResults: IntArray ) { when (requestCode) { REQUEST_ALL_PERMISSION -> { // If request is cancelled, the result arrays are empty. if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) { Toast.makeText(this, "Permissions granted!", Toast.LENGTH_SHORT).show() } else { requestPermissions(permissions, REQUEST_ALL_PERMISSION) Toast.makeText(this, "Permissions must be granted", Toast.LENGTH_SHORT).show() } } } } @TargetApi(Build.VERSION_CODES.M) @RequiresApi(Build.VERSION_CODES.LOLLIPOP) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) val bleOnOffBtn:ToggleButton = findViewById(R.id.ble_on_off_btn) val scanBtn: Button = findViewById(R.id.scanBtn) bluetoothAdapter = BluetoothAdapter.getDefaultAdapter() viewManager = LinearLayoutManager(this) recyclerViewAdapter = RecyclerViewAdapter(devicesArr) val recyclerView = findViewById<RecyclerView>(R.id.recyclerView).apply { layoutManager = viewManager adapter = recyclerViewAdapter } if(bluetoothAdapter!=null){ if(bluetoothAdapter?.isEnabled==false){ bleOnOffBtn.isChecked = true scanBtn.isVisible = false } else{ bleOnOffBtn.isChecked = false scanBtn.isVisible = true } } bleOnOffBtn.setOnCheckedChangeListener { _, isChecked -> bluetoothOnOff() scanBtn.visibility = if (scanBtn.visibility == View.VISIBLE){ View.INVISIBLE } else{ View.VISIBLE } } scanBtn.setOnClickListener { v:View? -> // Scan Button Onclick if (!hasPermissions(this, PERMISSIONS)) { requestPermissions(PERMISSIONS, REQUEST_ALL_PERMISSION) } scanDevice(true) } } fun bluetoothOnOff(){ if (bluetoothAdapter == null) { // Device doesn't support Bluetooth Log.d("bluetoothAdapter","Device doesn't support Bluetooth") }else{ if (bluetoothAdapter?.isEnabled == false) { // 블루투스 꺼져 있으면 블루투스 활성화 val enableBtIntent = Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE) startActivityForResult(enableBtIntent, REQUEST_ENABLE_BT) } else{ // 블루투스 켜져있으면 블루투스 비활성화 bluetoothAdapter?.disable() } } } class RecyclerViewAdapter(private val myDataset: ArrayList<BluetoothDevice>) : RecyclerView.Adapter<RecyclerViewAdapter.MyViewHolder>() { class MyViewHolder(val linearView: LinearLayout) : RecyclerView.ViewHolder(linearView) override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerViewAdapter.MyViewHolder { // create a new view val linearView = LayoutInflater.from(parent.context) .inflate(R.layout.recyclerview_item, parent, false) as LinearLayout return MyViewHolder(linearView) } // Replace the contents of a view (invoked by the layout manager) override fun onBindViewHolder(holder: MyViewHolder, position: Int) { val itemName:TextView = holder.linearView.findViewById(R.id.item_name) val itemAddress:TextView = holder.linearView.findViewById(R.id.item_address) itemName.text = myDataset[position].name itemAddress.text = myDataset[position].address } override fun getItemCount() = myDataset.size } } private fun Handler.postDelayed(function: () -> Unit?, scanPeriod: Int) { }
Kotlin
복사

코드리뷰

layout > new > Layout resource file 을 통해 아래와 같은 xml을 하나 추가 했는데, 추가한 xml은 Bluetooth Device Scan 시 Device Name과 Address를 표기할 TextView 두 개를 담는 셀의 구조이다.
/recyclerview_item.xml
<?xml version="1.0" encoding="utf-8"?> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" tools:context=".MainActivity"> <TextView android:id="@+id/item_name" android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="item_name" android:textAppearance="@style/TextAppearance.AppCompat.Medium" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" /> <TextView android:id="@+id/item_address" android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="item_address" android:layout_marginLeft="20dp" android:textAppearance="@style/TextAppearance.AppCompat.Medium" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" /> </LinearLayout>
XML
복사

Scan Button Visiblity 설정

Scan Button은 Bluetooth가 활성화 되었을 때만 보인다. 설정을 위해 onCreate 함수 안의 코드를 아래와 같이 수정한다. scanBtn.visibility = if (scanBtn.visibility == View.VISIBLE){ View.INVISIBLE } else{ View.VISIBLE } 는 VISIBLE인 경우 INVISIBLE로 INVISIBLE의 경우 VISIBLE로 Toggle 시키는 코드이다.
val scanBtn: Button = findViewById(R.id.scanBtn) if (bluetoothAdapter != null) { if (bluetoothAdapter ?. isEnabled == false) { bleOnOffBtn.isChecked = true scanBtn.isVisible = false } else { bleOnOffBtn.isChecked = false scanBtn.isVisible = true } } bleOnOffBtn.setOnCheckedChangeListener { _, isChecked - > bluetoothOnOff() scanBtn.visibility = if(scanBtn.visibility == View.VISIBLE) { View.INVISIBLE } else { View.VISIBLE } }
Kotlin
복사

Bluetooth Device Scan을 위한 Permission 확인하기

요청할 Permission을 PERMISSIONS라는 이름의 배열로 저장 후, 해당 배열에 저장된 Permission을 모두 요청한다.
Bluetooth Scan 기능을 사용하려면 ACCESS_FINE_LOCATION이라는 위치 접근 Permission을 허용해줘야한다.
private val REQUEST_PERMISSIONS= 2 private val PERMISSIONS = arrayOf( Manifest.permission.ACCESS_FINE_LOCATION ) ... private fun hasPermissions(context: Context?, permissions: Array<String>): Boolean { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && context != null && permissions != null) { for (permission in permissions) { if (ActivityCompat.checkSelfPermission(context, permission) != PackageManager.PERMISSION_GRANTED) { return false } } } return true } // Permission 확인 @RequiresApi(Build.VERSION_CODES.M) override fun onRequestPermissionsResult( requestCode: Int, permissions: Array<String?>, grantResults: IntArray ) { when (requestCode) { REQUEST_PERMISSIONS -> { // If request is cancelled, the result arrays are empty.if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) { Toast.makeText(this, "Permissions granted!", Toast.LENGTH_SHORT).show() } else { requestPermissions(permissions, REQUEST_PERMISSIONS) Toast.makeText(this, "Permissions must be granted", Toast.LENGTH_SHORT).show() } } } }
Kotlin
복사

Bluetooth Device Scan하기

Class 내부 전역변수로 아래 4줄을 추가한다.
scanning : scan중인지 나타내는 state 변수
devicesArr : scan한 Device를 담는 배열
SCAN_PERIOD
handler
private var scanning: Boolean = false private var devicesArr = ArrayList<BluetoothDevice>() private val SCAN_PERIOD = 1000 private val handler = Handler()
Kotlin
복사
mLeScanCallback이라는 이름의 ScanCallback 변수를 만든다.
ScanCallback은 scan에 실패하였을 때 실행되는 OnScanFailed()와 Batch Scan Result가 전달될 때 콜백하는 onBatchScanResults(), BLE advertisement가 발견되었을 때 실행되는 onScanResult()를 override 한다.
아래와 같이 onBatchScanResults()와 onScanResult()의 경우, Scan된 Device의 Name이 null이 아니면 deviceArr 배열에 추가하는 코드를 추가한다. recyclerViewAdapter.notifyDataSetChanged()는 다음 과정에서 설명하도록 한다.
private val mLeScanCallback = @RequiresApi(Build.VERSION_CODES.LOLLIPOP) object:ScanCallback() { override fun onScanFailed(errorCode: Int) { super.onScanFailed(errorCode) Log.d("scanCallback", "BLE Scan Failed : " + errorCode) } override fun onBatchScanResults(results: MutableList<ScanResult > ?) { super.onBatchScanResults(results) results?.let { // results is not nullfor(result in it) { if(!devicesArr.contains(result.device) && result.device.name!=null) devicesArr.add(result.device) } } } override fun onScanResult(callbackType: Int, result: ScanResult?) { super.onScanResult(callbackType, result) result?.let { // result is not nullif(!devicesArr.contains(it.device) && it.device.name!=null) devicesArr.add(it.device) recyclerViewAdapter.notifyDataSetChanged() } } }
Kotlin
복사
scanDevice라는 함수를 통해서 매개변수 state가 true이면 handler를 이용해 Bluetooth Scan을 SCAN_PERIOD 동안 실행하고 false이면, Scanning을 멈춘다.
@RequiresApi(Build.VERSION_CODES.LOLLIPOP) private fun scanDevice(state:Boolean) = if(state) { handler.postDelayed({ scanning = false bluetoothAdapter?.bluetoothLeScanner?.stopScan(mLeScanCallback) }, SCAN_PERIOD) scanning = true devicesArr.clear() bluetoothAdapter?.bluetoothLeScanner?.startScan(mLeScanCallback) } else { scanning = false bluetoothAdapter?.bluetoothLeScanner?.stopScan(mLeScanCallback) }
Kotlin
복사
scanBtn을 누르면, 위의 과정에서 추가했던 Permission 검사 함수를 통해 필요 Permission을 요청한 후, ScanDevice(true)를 통해 Bluetooth Device Scan을 실행한다.
scanBtn.setOnClickListener { v:View? ->// Scan Button Onclickif(!hasPermissions(this, PERMISSIONS)) { requestPermissions(PERMISSIONS, REQUEST_ALL_PERMISSION) } scanDevice(true) }
Kotlin
복사

RecyclerView를 통해 스캔한 기기 리스트 보여주기

Class 내부 전역변수로 아래 2줄을 추가한다.
private lateinit var viewManager: RecyclerView.LayoutManager private lateinit var recyclerViewAdapter : RecyclerViewAdapter
Kotlin
복사
onCreate 내부에 아래의 코드를 통해 recyclerView를 초기화한다.
viewManager = LinearLayoutManager(this) recyclerViewAdapter = RecyclerViewAdapter(devicesArr) val recyclerView = findViewById<RecyclerView > (R.id.recyclerView).apply { layoutManager = viewManager adapter = recyclerViewAdapter }
Kotlin
복사
RecyclerViewAdapter는 다음과 같이 devicesArr 배열의 Name, Address 정보를 recyclerview_item.xml의 두 TextView의 data로 동적 Cell을 생성한다.
class RecyclerViewAdapter(private val myDataset: ArrayList<BluetoothDevice>): RecyclerView.Adapter<RecyclerViewAdapter.MyViewHolder > () { class MyViewHolder(val linearView: LinearLayout):RecyclerView.ViewHolder(linearView) override fun onCreateViewHolder(parent: ViewGroup, viewType: Int):RecyclerViewAdapter.MyViewHolder { // create a new viewval linearView = LayoutInflater.from(parent.context) .inflate(R.layout.recyclerview_item, parent, false) as LinearLayout return MyViewHolder(linearView) } override fun onBindViewHolder(holder: MyViewHolder, position: Int) { val itemName: TextView = holder.linearView.findViewById(R.id.item_name) val itemAddress: TextView = holder.linearView.findViewById(R.id.item_address) itemName.text = myDataset[position].name itemAddress.text = myDataset[position].address } override fun getItemCount() = myDataset.size } }
Kotlin
복사

문의 : yklovejesus@gmail.com