///
Search
🛕

04.(220514)Kotlin으로 BLE(Bluetooth Low Energy)기반 블루투스 통신 기기 연결하기

출처

실험환경

bluetooth : ble (qualcomm qca402x ble)
moblie : 갤럭시 s10e

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

RecyclerView의 Cell에 해당하는 recyclerview_item.xml은 좀 더 가시성 있게 아래와 같이 코드를 변경
글씨가 안 보일걸 대비하여 붉은색으로 표시
(TextView Margin 조정 밑, 클릭 시 Select Animation 추가)
<?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" android:background="?attr/selectableItemBackground"> <!-- android:background="?attr/selectableItemBackground" : select animation 추가 --> <TextView android:id="@+id/item_name" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginLeft="20dp" android:layout_marginTop="20dp" android:layout_marginBottom="20dp" android:text="item_name" android:textAppearance="@style/TextAppearance.AppCompat.Medium" android:textColor="#a50000" 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:layout_marginLeft="20dp" android:layout_marginTop="20dp" android:layout_marginBottom="20dp" android:text="item_address" android:textAppearance="@style/TextAppearance.AppCompat.Medium" android:textColor="#a50000" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" /> </LinearLayout>
Kotlin
복사

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

package com.example.keti_ble import android.Manifest import android.annotation.TargetApi import android.bluetooth.* import android.bluetooth.le.ScanCallback import android.bluetooth.le.ScanResult import android.content.BroadcastReceiver 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 // BLE Gatt private var bleGatt: BluetoothGatt? = null private var mContext:Context? = null 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) mContext = this 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) recyclerViewAdapter.mListener = object : RecyclerViewAdapter.OnItemClickListener{ override fun onClick(view: View, position: Int) { scanDevice(false) // scan 중지 val device = devicesArr.get(position) bleGatt = DeviceControlActivity(mContext, bleGatt).connectGatt(device) } } 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 } if (scanBtn.visibility == View.INVISIBLE){ scanDevice(false) devicesArr.clear() recyclerViewAdapter.notifyDataSetChanged() } } 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>() { var mListener : OnItemClickListener? = null interface OnItemClickListener{ fun onClick(view: View, position: Int) } 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 if(mListener!=null){ holder?.itemView?.setOnClickListener{v-> mListener?.onClick(v, position) } } } override fun getItemCount() = myDataset.size } } private fun Handler.postDelayed(function: () -> Unit?, scanPeriod: Int) { }
Kotlin
복사

상세 코드리뷰

MainAcitivity에 OnClickListener를 이용한 DeviceControl Class 호출
itemClickListerRecyclerViewAdapter의 내부에 아래 코드를 추가한 후,
var mListener : OnItemClickListener? = null interface OnItemClickListener{ fun onClick(view: View, position: Int) }kotlin
Kotlin
복사
onBindViewHolder에 onclickListener를 등록한다.
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 // 아래부터 추가코드if(mListener!=null){ holder?.itemView?.setOnClickListener{v-> mListener?.onClick(v, position) } } }kotlin
Kotlin
복사
MainActivity.kt 내부에 bleGatt(BluetoothGatt 변수), mContext(Context 변수)를 추가하고,
onCreate 내부에 mContext와 mListener를 정의해준다.
mContext : Toast 알림을 위한 Context 전달
mListener : 위에서 추상화한 setOnclickListener에 대한 내용을 구현. scan을 중지하고, Device 정보를 DeviceControlActivity.connectGatt로 넘긴다.
// BLE Gatt 추가하기private var bleGatt: BluetoothGatt? = null private var mContext:Context? = null override fun onCreate(savedInstanceState: Bundle?) { // ... recyclerViewAdapter = RecyclerViewAdapter(devicesArr) // 여기부터 추가 mContext = this recyclerViewAdapter.mListener = object : RecyclerViewAdapter.OnItemClickListener{ override fun onClick(view: View, position: Int) { scanDevice(false)// scan 중지val device = devicesArr.get(position) bleGatt = DeviceControlActivity(mContext, bleGatt).connectGatt(device) } } }kotlin
Kotlin
복사

/app/src/main/java/com/example/keti_ble/DeviceControlActivity.kt

package com.example.keti_ble import android.bluetooth.BluetoothDevice import android.bluetooth.BluetoothGatt import android.bluetooth.BluetoothGattCallback import android.bluetooth.BluetoothProfile import android.content.* import android.os.Build import android.os.Handler import android.os.Looper import android.os.Message import android.util.Log import android.widget.Toast private val TAG = "gattClienCallback" class DeviceControlActivity(private val context: Context?, private var bluetoothGatt: BluetoothGatt?) { private var device : BluetoothDevice? = null private val gattCallback : BluetoothGattCallback = object : BluetoothGattCallback(){ override fun onConnectionStateChange(gatt: BluetoothGatt?, status: Int, newState: Int) { super.onConnectionStateChange(gatt, status, newState) when (newState) { BluetoothProfile.STATE_CONNECTED -> { Log.i(TAG, "Connected to GATT server.") Log.i(TAG, "Attempting to start service discovery: " + bluetoothGatt?.discoverServices()) } BluetoothProfile.STATE_DISCONNECTED -> { Log.i(TAG, "Disconnected from GATT server.") disconnectGattServer() } } } override fun onServicesDiscovered(gatt: BluetoothGatt?, status: Int) { super.onServicesDiscovered(gatt, status) when (status) { BluetoothGatt.GATT_SUCCESS -> { Log.i(TAG, "Connected to GATT_SUCCESS.") broadcastUpdate("Connected "+ device?.name) } else -> { Log.w(TAG, "Device service discovery failed, status: $status") broadcastUpdate("Fail Connect "+device?.name) } } } private fun broadcastUpdate(str: String) { val mHandler : Handler = object : Handler(Looper.getMainLooper()){ override fun handleMessage(msg: Message) { super.handleMessage(msg) Toast.makeText(context,str,Toast.LENGTH_SHORT).show() } } mHandler.obtainMessage().sendToTarget() } private fun disconnectGattServer() { Log.d(TAG, "Closing Gatt connection") // disconnect and close the gatt if (bluetoothGatt != null) { bluetoothGatt?.disconnect() bluetoothGatt?.close() bluetoothGatt = null } } } fun connectGatt(device:BluetoothDevice):BluetoothGatt?{ this.device = device if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { bluetoothGatt = device.connectGatt(context, false, gattCallback, BluetoothDevice.TRANSPORT_LE) } else { bluetoothGatt = device.connectGatt(context, false, gattCallback) } return bluetoothGatt } }
Kotlin
복사

상세코드 리뷰

DeviceControlActivity로 BLE Deivce 연결
DeviceControlActivity는 생성자의 파라메타로 context와 BluetoothGatt를 전달 받음
onConnectionsStateChange()의 경우 BLE Connection 상태가 변경될 때 호출되는 함수
newSate가 BluetoothProfile.STATE_CONNECTED일 경우  bluetoothGatt?.discoverServices()를 호출
BluetoothProfile.STATE_DISCONNECTED인 경우 disconnectGattServer()함수를 이용해 bluetoothGatt 연결을 해제하고 초기화
하지만, onConnectionStateChange의 state log를 찍어보니, "133"이 찍힘(오류)
해결방법
1. Scanning 종료 후 100ms 이후에 연결을 시도하라.
2. connectGatt 함수에 BluetoothDevice.TRANSPORT_LE 인자를 추가하라.(SDK 23 이상 지원)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { device?.connectGatt(view.context, false, gattClienCallback,BluetoothDevice.TRANSPORT_LE) } else { device?.connectGatt(view.context, false, gattClienCallback) }kotlin
Kotlin
복사
onServicesDiscovered()는 원격 장치에 대한 원격 서비스, 특성 및 설명자 목록이 업데이트되었을 때 호출되는 콜백.
broadcastUpdate()는 Toast로 보여줄 메세지를 파라메타로 받아, Handler의 handleMessage로 나타 냄
Toast도 하나의 UI 작업이기 때문에 Thread 안에서 그냥 호출해주면 에러가 발생한다.
connectGatt()는 MainActivity에서 호출할 메서드로 위에서 설명한 내용과 같이 SDK의 버전에 따라 두 가지로 나누어 처리해준다. BluetoothDevice의 connectGatt를 호출하면, 기기 연결을 수행 콜백을 호출