출처
실험환경
•
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를 호출하면, 기기 연결을 수행 콜백을 호출