안드로이드/공부

블루투스(BLE) 통신 앱 만들기 -1

바코더 2020. 11. 8. 23:53

블루투스 방식을 이용해 비 접촉 체온계와 통신을 주고 받는 앱을 만들 일이 생겼다.

평소에 평소 블루투스는 무선 이어폰과 연결할 때만 써봤지 다뤄볼 일이 없었기에 이번 기회를 통해 BLE 통신과 코틀린을 공부해보자는 마음을 차근차근 앱을 만들어 보았다.

코틀린과 BLE 통신 모두 처음 사용해보는 거라 생각보다 쉽지 않았다.

참고 문서

안드로이드 공식 BLE 가이드

https://developer.android.com/guide/topics/connectivity/bluetooth-le?hl=ko

공식 문서와 여러가지 인터넷에 올라와 있는 설명들을 기반으로 만들었다.

 

1.블루투스 Permission 확인하기

앱을 구동할 디바이스가 단일 핸드폰이어서 굳이 확인하지 않아도 될 코드였지만 일단 구현해두었다.

먼저 앱이 블루투스 기능을 지원하는지 확인해야 한다.

MainActivity.kt

private fun PackageManager.missingSystemFeature(name: String): Boolean = !hasSystemFeature(name)

packageManager.takeIf { it.missingSystemFeature(PackageManager.FEATURE_BLUETOOTH_LE) }?.also {
    Toast.makeText(this, "이 앱은 블루투스를 지원하지 않습니다", Toast.LENGTH_SHORT).show()
    finish()
}

이 작업은 PackageManager 를 통해 해결하면 된다. 만일 PackManager에 BLE를 지원하지 않는다면 Toast를 띄우고 앱을 종료시키자.

takeIf 로 PackageManager.FEATURE_BLUETOOTH_LE 가 PackageManager 에서 지원하는지 확인하고 만일 그렇다면 만일 지원하지 않는다면 (!hasSystemFeature(name)) 이 true라면 뒤의 내용을 실행시킨다.

다음으로는 Permission을 체크하자

 

Manifest.xml

<uses-permission android:name="android.permission.BLUETOOTH"/>
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN"/>
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />

 

잊지 말아야 할건 블루투스는 위치 서비스의 권한을 얻어야 작동한다는 것이다.

일단 동적 퍼미션은 넘어가도록 하자

 

2.블루투스 기기 스캔

BluetoothAdapter 가져오기

private val BluetoothAdapter.isDisabled:Boolean get() = !isEnabled

//BluetoothAdapter 변수를 사용하려고 할때까지 by lazy로 객체 초기화를 미룬다
private val bluetoothAdapter:BluetoothAdapter? by lazy(LazyThreadSafetyMode.NONE){
        val bluetoothManager = getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
        bluetoothManager.adapter
    }

//현재 블루투스 Adapter 의 상태를 가져온다
//만일 현재 블루투스가 꺼져있는 상태라면 블루투스 세팅창을 띄운다.
bluetoothAdapter?.takeIf { it.isDisabled }?.apply {
    val enableBtIntent = Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE)
    startActivityForResult(enableBtIntent, REQUEST_ENABLE_BT)
}

override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
        super.onActivityResult(requestCode, resultCode, data)
        if(requestCode == REQUEST_ENABLE_BT && resultCode == RESULT_OK){
            scanBluetooth()
        }
    }

bluetoothAdapter 가 만일 disabled 상태라면 블루투스 허가를 요청하고 만일 허가를 받았다면 scanBluetooth() 함수를 통해 블루투스 기기 스캔을 요청하자. 스캔결과를 띄우고 연결하는 화면을 별도로 만들어도 돼지만 버튼 아래에 RecyclerView를 통해 보이도록 했다.

 

private fun scanBluetooth(enable:Boolean){
        Log.d(TAG,"scanLeDevice Enable = ${enable}")
        when(enable){
            true->{
                handler.postDelayed({
                    mScanning = false
										//Bluetooth Classic 이 아닌 LE만 스캔된다.
                    bluetoothAdapter?.bluetoothLeScanner?.stopScan(scanCallback)
                },SCAN_PERIOD.toLong())
                mScanning = true
                recyclerView?.let{ it ->
                    devices.clear()
                    it.recycledViewPool.clear()
                    recyclerAdapter?.notifyDataSetChanged()}
                bluetoothAdapter?.bluetoothLeScanner?.startScan(scanCallback)
            }
            else->{
                mScanning = false
                bluetoothAdapter!!.bluetoothLeScanner.stopScan(scanCallback)
            }
        }
    }

scanBluetooth 라는 함수로 scan 항목을 정리했는데, handler.postDelayed 를 통해 블루투스 기기를 스캔할 시간을 정하고, 그 뒤에는 스캔을 중단하도록 했다. 그리고 스캔을 시작하는 동시에 recyclerApdater 로 표시할 디바이스 목록들을 clear 하고, recyclerView의 ViewPool 도 초기화 했다.

 

이 recycledViewPool.clear()는 이미 스캔되어 리사이클러 뷰에 나타난 목록들을 둔채 새롭게 스캔을 시작했을 때 devices 가 초기화 되면서 생기는 오류를 해결하고자 추가한 코드다. 아마 리사이클러뷰의 아이템 갱신 타이밍을 조절하거나, devices를 스캔된 디바이스를 추가하는 ArrayList와 리사이클러 어댑터에 그리는 ArrayList로 복제해서 나누면 될거 같은데, 일단 새롭게 스캔될 때 recyclerViewPool 비우는 걸로 해결했다.

 

이제 주변에 있는 BluetoothLe 디바이스가 스캔되고, 스캔의 결과는 scanCallback으로 구현할 수 있다.

 

private val scanCallback = object:ScanCallback(){
        override fun onScanFailed(errorCode: Int) {
            super.onScanFailed(errorCode)
            Toast.makeText(this@MainActivity, "스캔에 실패했습니다.", Toast.LENGTH_SHORT).show()
        }

        override fun onScanResult(callbackType: Int, result: ScanResult?) {
            result?.let{
                Log.d(TAG,"onScanResult = ${result.toString()}")
                if(!devices.contains(it.device)){
                    Log.d(TAG,"device Inserted ${result.device}")
                    devices.add(it.device)
                    recyclerAdapter!!.notifyDataSetChanged()
                }
            }
        }
        override fun onBatchScanResults(results: MutableList<ScanResult>?) {
            results?.let{
                Log.d(TAG,"onBatchScanResults = ${results}")
                for(result in it){
                    if(!devices.contains(result.device))devices.add(result.device)
                }
            }
        }
    }

scanCallback 은 만일 스캔에 성공한 디바이스가 있을때마다 onScanResult 가 호출되고 만일 result.device 즉 검색된 기기가 devices ArrayList 에 존재하지 않으면 add를 해주고, recyclerAdapter.norifyDataChange를 통해 변형된 데이터를 반영해 주었다. 그리고 이 리스트를 RecyclerAdapter에 붙여주면 스캔된 디바이스의 목록이 노출된다.

디바이스의 이름이 없으면 noName을 붙였고, 있으면 이름과 디바이스의 주소를 표시했다.

원하는 기기의 이름을 찾기 쉽게 만들었다.