はじめに

AndroidOS”12″(以降、Android12)からBluetooth周りが大きく変わりました。そこでAndroid12未満もサポートするBluetoothを扱うアプリの作り方ガイドをご紹介します。

大前提:権限周りの話

Android12以上では、”BLUETOOTH_CONNECT, BLUETOOTH_SCAN”の2つの権限を取得する必要があります。取得できていない状態でBluetooth接続依頼ダイアログを表示しようとするとアプリがクラッシュするようになっています。また、BLEでは下記権限が必要になります。

OS権限
Android12未満ACCESS_FINE_LOCATION
Android12以上ACCESS_FINE_LOCATION, BLUETOOTH_CONNECT, BLUETOOTH_SCAN

アルゴリズム処理

Android12ではアプリがクラッシュするため、アルゴリズムを下記のようにすれば単純化されるでしょう。

権限の取得(BLEで必要なのもこの段階で取ってしまう) -> Bluetooth接続依頼ダイアログの表示 -> BLEスキャン

Manifest設定

//Android12未満を対応させるなら
<uses-permission
    android:name="android.permission.BLUETOOTH" 
    android:maxSdkVersion="30" />
<uses-permission
    android:name="android.permission.BLUETOOTH_ADMIN" 
    android:maxSdkVersion="30" />

// Android12から求められるpermission
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />

// Android12以上, Android12未満両方必要
<uses-permission
    android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission
    android:name="android.permission.ACCESS_COARSE_LOCATION" />

// ここtrueにしたりするとBLEが備わってない端末をそもそも起動させなくさせることもできる
<uses-feature
    android:name="android.hardware.bluetooth" 
    android:required="false" />
<uses-feature
    android:name="android.hardware.bluetooth_le" 
    android:required="false" />

権限周りの取得

コールバック用の定数宣言。指定する整数はなんでも構いません。

private val requestBluetoothScanPermissionCode = 600
private val requestAccessFineLocationPermissionCode = 601

権限を取得しているかのチェックコード

// Android12未満
private fun enableAccessFineLocation() =
    ActivityCompat.checkSelfPermission(
        this,
        Manifest.permission.ACCESS_FINE_LOCATION
    ) == PackageManager.PERMISSION_GRANTED
            && ActivityCompat.checkSelfPermission(
        this,
        Manifest.permission.ACCESS_COARSE_LOCATION
    ) == PackageManager.PERMISSION_GRANTED

// Android12以上
@RequiresApi(Build.VERSION_CODES.S)
private fun enableBluetoothScanPermission() =
    enableAccessFineLocation() &&
            ActivityCompat.checkSelfPermission(
                this,
                Manifest.permission.BLUETOOTH_CONNECT
            ) == PackageManager.PERMISSION_GRANTED
            && ActivityCompat.checkSelfPermission(
        this,
        Manifest.permission.BLUETOOTH_SCAN
    ) == PackageManager.PERMISSION_GRANTED

権限の取得コード

// Android12未満
private fun checkAccessFineLocation() {
    var permissions = arrayOf(
        Manifest.permission.ACCESS_FINE_LOCATION
    )
    requestPermissions(
        permissions, requestAccessFineLocationPermissionCode
    )
}

// Android12以上
// checkAccessFineLocationに"BLUETOOTH_CONNECT, BLUETOOTH_SCAN"
// が増えただけなのでpermissionsをmergeして1つのメソッドで対応するのもOKです
private fun checkBluetoothScanPermission() {
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
        val permissions = arrayOf(
            Manifest.permission.ACCESS_FINE_LOCATION,
            Manifest.permission.BLUETOOTH_CONNECT,
            Manifest.permission.BLUETOOTH_SCAN
        )
        requestPermissions(
            permissions, requestBluetoothScanPermissionCode
        )
    }
}

コールバック処理

override fun onRequestPermissionsResult(
    requestCode: Int,
    permissions: Array<String>,
    grantResults: IntArray
) {
    when (requestCode) {
        requestBluetoothScanPermissionCode -> {
            if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
                //権限取得時
            }
            return
        }

        requestAccessFineLocationPermissionCode -> {
            if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
                //権限取得時
            }
            return
        }

    }
    super.onRequestPermissionsResult(requestCode, permissions, grantResults)
}

上記コードはActivity内ではDeprecatedにはなりませんが、最近の書き方に置き換えると下記コードのようになります。

private fun checkBluetoothScanPermission() {
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
        if (enableBluetoothScanPermission())
            return
        val permissions = arrayOf(
            Manifest.permission.ACCESS_FINE_LOCATION,
            Manifest.permission.BLUETOOTH_CONNECT,
            Manifest.permission.BLUETOOTH_SCAN
        )
        requestPermissionsResultLauncher.launch(permissions)
    }
}

private val requestPermissionsResultLauncher =
    registerForActivityResult(
        ActivityResultContracts.RequestMultiplePermissions()
    ) { granted ->
        if (granted.map { (_, v) -> v }.reduce { res, v -> res && v}) {
            // 権限取得時処理
        }
    }

Bluetooth接続依頼

// adapter周り
private val bluetoothAdapter: BluetoothAdapter? by lazy { // API level18から使える
    (getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager).adapter
}

private val bleScanner: BluetoothLeScanner? by lazy {
    bluetoothAdapter?.bluetoothLeScanner
}

// Bluetooth接続要求
private fun turnOnBluetooth() {
    bluetoothAdapter?.takeIf { !it.isEnabled }?.apply {
        val intent = Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE)
        activityResultLauncher.launch(intent)
    }
}

// 後処理
private val activityResultLauncher =
    registerForActivityResult(
        ActivityResultContracts.StartActivityForResult()
    ) { result: ActivityResult ->
        if (result.resultCode == RESULT_OK) {
            // Bluetooth ON時の後処理
        }
    }

BLE

BLEスキャンのコード

// フラグで状態を管理した方が良いです(理由は後述)
private var runScaning: Boolean = false

private fun scanLeDevice(enable: Boolean) {
    // bleScanner?.startScanで求められるため必要
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
        if (ActivityCompat.checkSelfPermission(
                this,
                Manifest.permission.BLUETOOTH_SCAN
            ) != PackageManager.PERMISSION_GRANTED
        ) {
            return
        }
    }

    // すでに走っている時は一度stopしてから再度走らせる
    if (enable && runScaning) {
        runScaning = false
        scanLeDevice(false)
    }
    when (enable) {
        true -> {
            runScaning = true
            bleScanner?.startScan(leScanCallback)
        }
        else -> {
            runScaning = false
            bleScanner?.stopScan(leScanCallback)
        }
    }
}

状態を管理すべき理由は長期に渡りBLEスキャンを走らせているとコールバックが届かなくなる問題があり、対策としてhandlerなどでスキャン処理のループ状態を作り、10分置きなどにBLEスキャンをやり直すなどがあります。従って、状態を管理しておけば、2重で走らせてしまう問題などがケアできます。

コールバックは下記のようになります。

private val leScanCallback: ScanCallback = object : ScanCallback() {
    override fun onScanResult(callbackType: Int, result: ScanResult?) {
        if (result == null)
            return
        val rssi = result.rssi
        result.scanRecord?.bytes?.let {
            // 9~24byteがuuids
            // 25,26byteがmajor
            // 27,28byteがminor
            // ※major, minorは16進数なので注意
            val tmp = it.map { String.format("%02X", it) }
            val uuid = tmp.slice(9..24).joinToString("")
            val major = tmp.slice(25..26).joinToString("")
            val minor = tmp.slice(27..28).joinToString("")
        }
        super.onScanResult(callbackType, result)
    }

    override fun onBatchScanResults(results: List<ScanResult?>?) {
        super.onBatchScanResults(results)
    }

    override fun onScanFailed(errorCode: Int) {
        super.onScanFailed(errorCode)
    }

さいごに

Bluetooth周りはAndroid12で大きく変更されたので今後も大きく変更される可能性があります。一度公式の情報に目を通してからの実装をオススメします。



ギャップロを運営しているアップフロンティア株式会社では、一緒に働いてくれる仲間を随時、募集しています。 興味がある!一緒に働いてみたい!という方は下記よりご応募お待ちしております。
採用情報をみる