BLE 기기 스캔을 마쳤으면, 스캔한 Devoce 중 원하는 기기와 연결을 할 수 있다.
성공적으로 연결을 마치면 Toast 메세지를 통해 'Connected [Device_name]'를 보여준다.
스마트폰 블루투스 기능 On/Off, BLE 기기를 Scan하는 코드는 이전 게시물을 참고한다.
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="#000000"
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="#000000"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</LinearLayout>
MainAcitivity에 OnClickListener를 이용한 DeviceControl Class 호출
itemClickListerRecyclerViewAdapter의 내부에 아래 코드를 추가한 후,
var mListener : OnItemClickListener? = null
interface OnItemClickListener{
fun onClick(view: View, position: Int)
}
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)
}
}
}
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)
}
}
}
DeviceControlActivity로 BLE Deivce 연결
DeviceControlActivity는 생성자의 파라메타로 context와 BluetoothGatt를 전달 받는다.
onConnectionsStateChange()의 경우 BLE Connection 상태가 변경될 때 호출되는 함수로,
newSate가 BluetoothProfile.STATE_CONNECTED일 경우 bluetoothGatt?.discoverServices()를 호출해주고,
BluetoothProfile.STATE_DISCONNECTED인 경우 disconnectGattServer()함수를 이용해 bluetoothGatt 연결을 해제하고 초기화한다.
하지만, onConnectionStateChange의 state log를 찍어보니, "133"이 찍혔다.
BluetoothGatt의 소스코드를 확인해봐도 133 State에 대한 정보는 얻을 수 없어서 구글링 중에 잘 정리된 게시물을 발견했다.
여기서 제안하는 해결방법은 아래와 같다.
1. Scanning 종료 후 100ms 이후에 연결을 시도하라.
2. connectGatt 함수에 BluetoothDevice.TRANSPORT_LE 인자를 추가하라.(SDK 23 이상 지원)
나는 1번 방법으로 해결되지 않아, 아래와 같이 소스코드를 변경하였다.
테스트로 이용하는 공기계의 SDK가 23보다 높아 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)
}
onServicesDiscovered()는 원격 장치에 대한 원격 서비스, 특성 및 설명자 목록이 업데이트되었을 때 호출되는 콜백이다.
broadcastUpdate()는 Toast로 보여줄 메세지를 파라메타로 받아, Handler의 handleMessage로 나타낸다.
Toast도 하나의 UI 작업이기 때문에 Thread 안에서 그냥 호출해주면 에러가 발생한다.
connectGatt()는 MainActivity에서 호출할 메서드로 위에서 설명한 내용과 같이 SDK의 버전에 따라 두 가지로 나누어 처리해준다. BluetoothDevice의 connectGatt를 호출하면, 기기 연결을 수행 콜백을 호출한다.
전체 소스코드는 아래와 같다.
DeviceControlActivity.kt
package com.example.bluetoothapp
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
}
}
MainActivity.kt
package com.example.bluetoothapp
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) {
}
'Mobile Programming > Android' 카테고리의 다른 글
[Google Play Console] 어플리케이션 버전 업데이트하기 (0) | 2021.05.24 |
---|---|
[Kotlin] 음악 재생 어플리케이션 만들기(프로그래머스 과제) (2) | 2021.04.16 |
[Kotlin] BLE 기기 검색하기 (45) | 2020.12.28 |
[Kotlin] 블루투스 On/Off 제어하기 (0) | 2020.12.21 |
[Kotlin] 페이스북 로그인 이용하기 (2020.08) (0) | 2020.08.26 |
댓글