😊

Android BLEでチャットアプリを作ってみよう

2024/12/10に公開

はじめに

この記事は株式会社ビットキー Advent Calendar 2024 10日目の記事です。
ビットキーで主にAndroidアプリ開発をしているしらかしが担当します

弊社のスマートデバイスたちとアプリの間ではBluetooth Low Energy(以下BLE)を使って通信を行っています。
今回の記事では、2台のAndroid端末を使ってメッセージをやり取りするアプリを作ってBLEの雰囲気を感じ取れるようにします。

BLEについて

BLEには、Central、Peripheralという2つの役割があります。

役割 責任
Peripheral Advertise で電波を出して Read/Write を受け付ける bitlock
Central 電波を Scan して Read/Write をリクエストする スマートフォン

日常的なユースケースでは各種BLE機器がPeripheralの役割を、スマートフォンがCentralとしての役割を担います。

AndroidはCentralだけでなくPeripheralとしても動作させることができます。
なので、BLEデバイスがなくてもAndroidスマートフォンが2台あれば自由に遊ぶことができます。
また、エミュレータ間でBLE通信ができるのでエミュレータを2台同時に動かして動作を確認することもできます。

AndroidBLEの主な登場人物

これらを使うことでAndroid端末間でBLE通信が行えます。

2台のAndroidでBLE通信を開始するには以下の流れになります。

Peripheral

  1. BluetoothManager.openGattServer() // PeripheralがCentralからのconnectを受け付けるようにする
  2. BluetoothLeAdvertiser.startAdvertising() // スキャンして見つけられるようにする

Central

  1. BluetoothLeScanner.startScan() // デバイスを見つける
  2. BluetoothDevice.connectGatt() // 見つけたデバイスと接続する

ソースコード

ここからは、実際に作成したアプリのコードと合わせて紹介していきます。
https://github.com/crakaC/Emob

名前をEncrypted Messaging over BLE、略してEmobとしています。
本当はアプリケーションレイヤーで暗号化も施す予定だったのですが、力尽きてしまったので名前に反して平文で通信します。

免責

本文内で紹介するコードはAndroidでBLE通信を行うときの雰囲気を感じ取るためのものです。
実際のアプリケーションでコードを使用した際に発生した不具合、損害に対してビットキーおよびしらかしは一切の責任を負いません。

下準備

AndroidManifest.xmlに権限を記述

アプリでBLEを使用するためには、マニフェストファイルに利用する権限を記述しておく必要があります。

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

    <!-- BLEが使えないことには始まらないのでrequired="true"としておく -->
    <uses-feature
        android:name="android.hardware.bluetooth_le"
        android:required="true" />

    <!-- Android R以前で動かす場合は以下2つが必要 -->
    <uses-permission
        android:name="android.permission.BLUETOOTH"
        android:maxSdkVersion="30" />
    <uses-permission
        android:name="android.permission.BLUETOOTH_ADMIN"
        android:maxSdkVersion="30" />

    <uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />
    <uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
    <uses-permission android:name="android.permission.BLUETOOTH_SCAN" />

    <!-- 以下略 -->

</manifest>

ServiceとCharacteristic(とDescriptor)

BLEで通信するためには、任意のServiceUUIDを事前に定義しておく必要があります。

internal object BleResource {
    val EmobServiceUUID: UUID = UUID.fromString("eb5ac374-b364-4b90-bf05-0000000000")
    val EmobCharacteristicUUID: UUID = UUID.fromString("eb5ac374-b364-4b90-bf05-0000000001")
    val EmobDescriptorUUID: UUID = UUID.fromString("eb5ac374-b364-4b90-bf05-0000000002")

    private val EmobCharacteristic: BluetoothGattCharacteristic
        get() = BluetoothGattCharacteristic(
            EmobCharacteristicUUID,
            PROPERTY_READ or PROPERTY_WRITE or PROPERTY_NOTIFY,
            PERMISSION_READ or PERMISSION_WRITE
        ).apply {
            addDescriptor(
                BluetoothGattDescriptor(
                    EmobDescriptorUUID,
                    PERMISSION_READ or PERMISSION_WRITE
                )
            )
        }
    val EmobService = BluetoothGattService(EmobServiceUUID, SERVICE_TYPE_PRIMARY).apply {
        addCharacteristic(EmobCharacteristic)
    }
}

internal val UUID.parcel: ParcelUuid get() = ParcelUuid(this)

アプリ間で送受信するデータの定義

ByteArrayに変換できれば何でも送受信できるので、今回は独自のデータを用意します。
ByteBufferを使うことでByteArrayとの相互変換を楽に書くことができます。

internal sealed interface EmobFrame {
    val header: EmobHeader
    val payload: ByteArray
    val size: Int get() = header.size + payload.size

    companion object
}

internal fun EmobFrame.toByteArray(): ByteArray {
    return ByteBuffer.allocate(size)
        .order(ByteOrder.LITTLE_ENDIAN)
        .put(header.value)
        .put(payload)
        .array()
}

internal fun EmobFrame.Companion.parse(data: ByteArray): EmobFrame {
    val buf = ByteBuffer.wrap(data)
        .order(ByteOrder.LITTLE_ENDIAN)
    val header = EmobHeader(buf.get())
    val payload = buf.array().copyOfRange(buf.position(), buf.limit())
    return when (header) {
        EmobHeader.PlainText -> EmobPlainText(payload)
        else -> TODO("not implemented")
    }
}

@JvmInline
internal value class EmobHeader(val value: Byte) {
    companion object {
        val PlainText = EmobHeader(0x00)
    }

    val size: Int get() = Byte.SIZE_BYTES
}

internal data class EmobPlainText(
    val text: String
) : EmobFrame {
    override val header: EmobHeader = EmobHeader.PlainText
    override val payload: ByteArray = text.toByteArray()

    constructor(payload: ByteArray) : this(String(payload))
}

Peripheral / Server

Peripheralは周囲にアドバタイズパケットを送信し、他の端末からスキャン可能な状態にします。
また、他端末からのWrite/Readを受け付け、アプリからのメッセージを受信出来るようにします。

アドバタイズ

  • BluetoothLeAdvertiser
  • AdvertiseCallback

GattServer

  • BluetoothGattServer
  • BluetoothGattServerCallback

上記4つをガガーっとまとめてSe


// EmobServer.kt (抜粋)

class EmobServer @Inject constructor(
    @ApplicationContext private val context: Context,
    @BleScope val bleScope: CoroutineScope
) {
    private lateinit var gattServer: BluetoothGattServer

    private val bleAdvertiser: BluetoothLeAdvertiser =
        bluetoothManager.adapter.bluetoothLeAdvertiser

    private val mutableMessagesFlow = MutableSharedFlow<Message>()
    val messagesFlow = mutableMessagesFlow.asSharedFlow()

    private val advertiseCallback = object : AdvertiseCallback() {
        /* 省略 */
    }

    @SuppressLint("MissingPermission")
    private val gattCallback = object : BluetoothGattServerCallback() {
        override fun onCharacteristicWriteRequest(
            device: BluetoothDevice,
            requestId: Int,
            characteristic: BluetoothGattCharacteristic,
            preparedWrite: Boolean,
            responseNeeded: Boolean,
            offset: Int,
            value: ByteArray?
        ) {
            if (value == null) {
                return
            }
            if (responseNeeded) {
                gattServer.sendResponse(device, requestId, GATT_SUCCESS, offset, value)
            }
            val emobFrame = EmobFrame.parse(value)

            when (emobFrame) {
                is EmobPlainText -> {
                    bleScope.launch {
                        mutableMessagesFlow.emit(
                            RemoteMessage(
                                EmobDevice(device),
                                emobFrame.text,
                                Instant.now()
                            )
                        )
                    }
                }

                else -> TODO("not implemented")
            }
        }
        /* 省略 */
    }

    @RequiresPermission(allOf = [BLUETOOTH_ADVERTISE, BLUETOOTH_CONNECT])
    fun open() {
        gattServer = bluetoothManager.openGattServer(context, gattCallback)
        gattServer.addService(EmobService)
        startAdvertising()
    }

    @RequiresPermission(BLUETOOTH_ADVERTISE)
    private fun startAdvertising() {
        val setting = AdvertiseSettings.Builder()
            .setAdvertiseMode(AdvertiseSettings.ADVERTISE_MODE_BALANCED)
            .setConnectable(true)
            .build()

        val data = AdvertiseData.Builder()
            .setIncludeTxPowerLevel(true)
            .addServiceUuid(EmobServiceUUID.parcel)
            .build()

        val scanResponse = AdvertiseData.Builder()
            .setIncludeDeviceName(true)
            .build()

        bleAdvertiser.startAdvertising(setting, data, scanResponse, advertiseCallback)
    }

    fun close() {
        if (!::gattServer.isInitialized) return
        if (context.checkPermission(BLUETOOTH_ADVERTISE)) {
            bleAdvertiser.stopAdvertising(advertiseCallback)
        }
        if (context.checkPermission(BLUETOOTH_CONNECT)) {
            gattServer.close()
        }
    }
}

Central / Client

CentralはPeripheralのスキャンと、スキャンしたPeripheralへの接続を行います。
Peripheralに対してwriteすることでメッセージの送信を行います。

Scan

BluetoothLeScannerでPeripheralのアドバタイズパケットをスキャンして、BluetoothDeviceを取得することができます。

// EmobScanner.kt (抜粋)
class EmobScanner @Inject constructor(
    @ApplicationContext private val context: Context,
) {
    private val bluetoothManager = context.getSystemService<BluetoothManager>()!!
    private val bleScanner: BluetoothLeScanner = bluetoothManager.adapter.bluetoothLeScanner

    @RequiresPermission(allOf = [BLUETOOTH_SCAN, BLUETOOTH_CONNECT])
    @CheckResult
    fun scan(): Flow<EmobDevice> = callbackFlow {
        val scanCallback = object : ScanCallback() {
            override fun onScanResult(callbackType: Int, result: ScanResult) {
                val device = result.emobDevice
                Timber.tag(TAG).d("Scan result: $device")
                trySend(device)
            }
        }
        val filter = ScanFilter.Builder()
            .setServiceUuid(EmobServiceUUID.parcel)
            .build()
        val settings = ScanSettings.Builder()
            .setScanMode(ScanSettings.SCAN_MODE_LOW_POWER)
            .build()
        bleScanner.startScan(listOf(filter), settings, scanCallback)
        awaitClose {
            bleScanner.stopScan(scanCallback)
        }
    }
}

GATT

callbackFlowを使い、GattCallbackをFlowに変換します。
メッセージを送信するためのメソッドも作っておきます。

// EmobClient.kt(抜粋)

class EmobClient @Inject constructor(
    @ApplicationContext private val context: Context,
    @BleScope private val bleScope: CoroutineScope
) {
    private var clientJob: Job? = null
    private lateinit var gattClient: GattClient

    private val mutableClientState = MutableStateFlow<EmobClientState>(EmobClientState.Idle)
    val clientState = mutableClientState.asStateFlow()

    fun connect(device: EmobDevice) {
        clientJob = callbackFlow {
            gattClient = GattClient(context, device, this)
            awaitClose {
                gattClient.close()
            }
        }.onEach {
            when (it) {
                is GattState.Connected -> {
                    // GATTで接続完了していてもServiceが見つかるまではデータの送受信は行えないため
                    // Connectingとする
                    mutableClientState.update { EmobClientState.Connecting(device) }
                }

                is GattState.ServiceDiscovered -> {
                    gattClient.enableNotification()
                    gattClient.keyExchange()
                    mutableClientState.update { EmobClientState.Connected(device) }
                }

                is GattState.Disconnected -> {
                    clientJob?.cancel()
                    mutableClientState.update { EmobClientState.Disconnected(device) }
                }

                is GattState.Error -> {
                    val error = it
                    mutableClientState.update { EmobClientState.Error(device, error.cause) }
                }
            }
        }.launchIn(bleScope)
    }

    @RequiresPermission(BLUETOOTH_CONNECT)
    suspend fun sendMessage(message: String) {
        val req = EmobPlainText(message)
        gattClient.write(req.toByteArray())
    }

    fun disconnect() {
        clientJob?.cancel()
        clientJob = null
    }

    @Suppress("MissingPermission")
    private class GattClient(
        context: Context,
        device: EmobDevice,
        private val channel: SendChannel<GattState>,
    ) : BluetoothGattCallback() {
        val gatt: BluetoothGatt = device.bluetoothDevice.connectGatt(context, false, this)

        override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) {
            if (status == BluetoothGatt.GATT_SUCCESS) {
                // 接続が成功してからdiscoverServices()を呼び出す
                gatt.discoverServices()
            } else {
                channel.trySend(GattState.Disconnected)
            }
        }

        override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) {
            if (status == BluetoothGatt.GATT_SUCCESS && gatt.getService(EmobServiceUUID) != null) {
                channel.trySend(GattState.Connected)
            } else {
                channel.trySend(GattState.Error(EmobServiceNotFoundException(status)))
            }
        }

        fun close() {
            gatt.disconnect()
            gatt.close()
        }

        suspend fun write(data: ByteArray) {
            gatt.write(data)
        }
    }
}

private sealed interface GattState {
    class Error(val cause: Throwable) : GattState
    data object Connected : GattState
    data object Disconnected : GattState
}

sealed interface EmobClientState {
    data object Idle : EmobClientState
    data class Error(val device: EmobDevice, val cause: Throwable?) : EmobClientState
    data class Connected(val device: EmobDevice) : EmobClientState
    data class Disconnected(val device: EmobDevice) : EmobClientState
}

GATT関連のAPIはDeprecatedになったりしているので、APIレベルによる分岐を行っています。

// Gatt.kt(抜粋)

internal val BluetoothGatt.emobCharacteristic: BluetoothGattCharacteristic
    get() {
        val service = getService(EmobServiceUUID) ?: error("EmobService not found")
        return service.getCharacteristic(EmobCharacteristicUUID)
            ?: error("Characteristic not found")
    }

@RequiresPermission(BLUETOOTH_CONNECT)
internal suspend fun BluetoothGatt.write(
    value: ByteArray,
    timeout: Duration = 10.seconds,
) = withTimeout(timeout) {
    val characteristic = emobCharacteristic
    val writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
    val status = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
        retryUntilBusy {
            writeCharacteristic(characteristic, value, writeType)
        }
    } else {
        characteristic.writeType = writeType
        @Suppress("DEPRECATION")
        characteristic.value = value
        @Suppress("DEPRECATION")
        writeCharacteristic(characteristic)
    }
    if (status != BluetoothStatusCodes.SUCCESS) {
        Timber.e("write failed: $status")
    }
}

@RequiresApi(Build.VERSION_CODES.TIRAMISU)
private suspend fun retryUntilBusy(block: () -> Int): Int {
    var result: Int
    while (block().also { result = it } == ERROR_GATT_WRITE_REQUEST_BUSY) {
        Timber.d("waiting for gatt")
        delay(100.milliseconds)
    }
    return result
}

動かす

エミュレータを2台用意する

Android端末を2台以上持っていなくても、エミュレータ同士でBluetooth接続をして動作を確認することができます。

Android Studio画面右側にある、DeviceManagerで、Emulatorを追加していきます。

端末サイズは好きなものを選びましょう。

システムイメージは最新の安定版が安定していて良いと思います。

適当に名前をつけて

複製すればエミュレータ2台の準備完了です。

Select Multiple Deviceで両方にチェックを入れると2台同時に実行できてハッピーです。

動作例

さいごに

11日目の株式会社ビットキー Advent Calendar 2024は、エンジニアリングマネジメント室のしゅういちろが担当します。お楽しみに!

参考

https://developer.android.com/develop/connectivity/bluetooth/ble/ble-overview?hl=ja

https://github.com/android/platform-samples/tree/20c7a4e5016fcfefbea6c598f95c51477b073a1f/samples/connectivity/bluetooth/ble

Bitkey Developers

Discussion