Android BLEでチャットアプリを作ってみよう
はじめに
この記事は株式会社ビットキー 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
- BluetoothManager.openGattServer() // PeripheralがCentralからのconnectを受け付けるようにする
- BluetoothLeAdvertiser.startAdvertising() // スキャンして見つけられるようにする
Central
- BluetoothLeScanner.startScan() // デバイスを見つける
- BluetoothDevice.connectGatt() // 見つけたデバイスと接続する
ソースコード
ここからは、実際に作成したアプリのコードと合わせて紹介していきます。
名前を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は、エンジニアリングマネジメント室のしゅういちろが担当します。お楽しみに!
参考
Discussion