Sansan Tech Blog

Sansanのものづくりを支えるメンバーの技術やデザイン、プロダクトマネジメントの情報を発信

AndroidでBluetooth Low EnergyのL2CAP通信を行う方法と開発で得た知見

技術本部 Mobile Applicationグループに所属する北村です。SansanとEightの両プロダクトをまたぐプロダクト横断チームの一員として、モバイル領域の中長期的な技術的課題の解決や、PoCの開発を担当しています。

今回は昨年9月にリリースした、Eightのタッチ名刺交換機能をテーマに、そこで得た知見をこの記事で共有します。
jp.corp-sansan.com


タッチ名刺交換とは、Eightのアプリを開いた状態で、同じくEightのアプリを開いた他のAndroid端末やiOS端末と自端末をタッチすることで、デジタル名刺が交換できる機能です。
その際にBluetooth Low Energy(以降BLE)を用いてタッチの検出やデジタル名刺の交換に必要な通信のコネクションを張るタッチ検出&通信ライブラリの開発を担当しました。
なお、今回開発したタッチ検出&通信ライブラリのBLEのアドバタイズ時電波強度を用いた距離推定とタッチ検出についてはこちらの記事に詳しく書かれています。また、L2CAPの技術的な部分に関してはiOSでの記事となりますが
こちらの記事をぜひ参照ください。
目次

BLEで端末間通信を行ういくつかの方法

BLEを用いて2端末間で通信する場合、いくつかの選択肢があります。それぞれ特徴があり、今回開発したタッチ検出&通信ライブラリではすべて使っていますが、アプリがメインで使う2端末間の通信には最後のL2CAPを用いています。

1. BLEのアドバタイズパケットにデータを設定しアドバタイズ

BLEのアドバタイズはBLEデバイスが周囲のデバイスに対して自身の存在を知らせたり、提供する機能を公開できたりする仕組みです。
Androidはそのアドバタイズのパケットに任意のデータを設定できます。しかし、iOSでは任意のデータを設定する事はできません。
そのため、タッチ検出&通信ライブラリでは通信相手の検出とアドバタイズの電波強度を用いた距離推定によるタッチ検出を用いています。

2. BLEのGATT接続を用いたデータの送受信

2端末間でデータの読み書きができますが、送受信のデータ長に制約があり、全二重通信ではありません。Android 13、Pixel 7 Pro環境ではMTUを拡張しないと21byteまでしか設定できませんでした。
タッチ検出&通信ライブラリではL2CAP通信に必要な各種パラメータの送受信に用いています。

3. BLEのL2CAP接続を用いたデータの送受信

TCP/IPのような全二重通信が可能です。アプリからはInputStream、OutputStreamとして読み書きが可能で扱いやすいです。
タッチ検出&通信ライブラリでは2端末間でアプリが通信を行うメインの通信手段として用いています。

BLEのL2CAPを用いて端末間で全二重通信をする方法とそこで得た知見

今回開発したタッチ検出&通信ライブラリでは、L2CAPを用いた端末間通信を行いました。Androidでの具体的なL2CAPでの接続方法を紹介します。
以降はL2CAPの技術的な知識がある前提で紹介しますので、L2CAPは先ほど紹介したCore BluetoothにおけるL2CAP実装 - 基礎編を参照してください。

1. 通信相手に自分の存在を通知するためのアドバタイズ

L2CAP通信をするためには、アドバタイズによる通信相手の発見、GATT接続によるPSMの交換が必要です。PSMはTCP/IPにおけるポート番号のようなものです。まずはアドバタイズで自分の存在をアドバタイズの受け取り側であるセントラルに知らせましょう。

val bluetoothAdapter = bluetoothManager.adapter
val advertiser = adapter.bluetoothLeAdvertiser
try {
    advertiser.startAdvertisingSet(
        AdvertisingSetParameters
            .Builder()
            .setLegacyMode(true)
            .setScannable(true)
            .setConnectable(true)
            .setTxPowerLevel(AdvertisingSetParameters.TX_POWER_HIGH)
            .build(),
        AdvertiseData
            .Builder()
            .addServiceUuid(serviceUUID))
            .build(),
        null, null, null,
        advertisingSetCallback
    )
} catch (e: IllegalArgumentException) {
    // 指定したパラメータでアドバタイズがサポートされていない場合に例外がthrowされる
}

AndroidではBLEのアドバタイズ機能は必須ではないので、対応していない機種があります。対応しているかどうかを取得する方法はAndroidの互換性プログラムのドキュメントに記載があります。

得た知見中国メーカーのAndroid端末で少し古めの端末(Android 10)はアドバタイズができない事がありました。
EightのAndroidアプリに搭載しているタッチ名刺交換機能はAndroid 10以降のデバイスに提供しており、国産メーカのAndroid 10以降の端末は概ねアドバタイズをサポートしているようです。
また、アドバタイズの電波強度はメーカーや端末によって異なり、iPhoneはAndroid端末の平均よりも電波強度が強く、Android端末の中でもメーカーや端末によってAndroidの平均よりも強かったり、さらに弱かったりする端末が存在します。送信電波強度ではなく受信アンテナの感度が低い端末も中には存在しました。

Androidでアドバタイズの開始と終了を短い間隔で繰り返すと、アドバタイズの開始処理が例外なく開始できてもlogcatにエラーが表示され実際にはアドバタイズが開始できないと言う問題がありました。プログラムからは検知できないエラーなので注意が必要です。

2. アドバタイズを検知し通信相手を発見する

次に、アドバタイズをスキャンして通信相手を検出しましょう。BluetoothLeScannerのstartScanメソッドでスキャンを開始できます。
ScanModeで、スキャン結果の通知間隔を指定することができます。アドバタイズのスキャン結果をタッチ検出に用いるため、最短の通知間隔を指定できるSCAN_MODE_LOW_LATENCYを設定しタッチ検出精度を高めています。

val scanner = adapter.bluetoothLeScanner
scanner.startScan(
    listOf(
        ScanFilter.Builder()
            .setServiceUuid(serviceUUID))
            .build()
    ),
    ScanSettings.Builder()
        .setScanMode(SCAN_MODE_LOW_LATENCY)
        .build(),
    scanCallback // スキャン結果を受け取るコールバック
)

得た知見最短の通知間隔を設定した場合、Pixelなど多くのAndroid端末は1秒間に3,4回の通知頻度ですが、一部メーカーでは1秒に1回程度の通知間隔だったり、通知間隔が不安定で間が空いたり、連続で通知されたり、通知間隔が安定しない端末もありました。
また、Pixel系でもスキャン開始後30分経過すると通知間隔が勝手に1秒に1回程度に広がる挙動となり、通知間隔は安定しない傾向があります。
アドバタイズしている端末を一意に特定するにはBluetoothDeviceのAddressが使えそうに思えますが、この情報はプライバシー保護のため一定時間毎に変わります。そのためアドバタイズしている端末を一意に特定するには別の方法が必要となります。

3. GATTサーバを開始しL2CAPの通信に必要なパラメータを送る準備をする

アドバタイズで自分の存在を知らせた際に、相手へGATT接続で自分の情報を伝える必要があります。まずはGATTサーバを開始し、セントラル機からの接続を待ち受けます。
そして、接続があれば接続元に対してsendResponseで応答を送信します。
MTU(最大転送単位)を拡張しないと21byteまでしか送れず、MTUの拡張もどれだけの端末がどれだけのサイズまで拡張できるかわからないため送信データ長には気をつける必要があります。なお、MTUの拡張はBluetoothGatt#requestMtu()で行う事ができます。

// GATTサーバへの接続があったときのコールバック
val callback = object : BluetoothGattServerCallback() {
    override fun onCharacteristicReadRequest(
        device: BluetoothDevice?,
        requestId: Int,
        offset: Int,
        characteristic: BluetoothGattCharacteristic?,
    ) {
        super.onCharacteristicReadRequest(device, requestId, offset, characteristic)

        // GATTサーバへ接続してきたクライアントに対してレスポンスを書き込む
        val sendResponseSucceeded = server?.sendResponse(
            device,
            requestId,
            GATT_SUCCESS,
            offset,
            payloadByteArray // ペイロードを指定する。MTUを拡張しないと21byteまでしか送れない。
        )
    }

val server = bluetoothManager.openGattServer(
    context,
    callback
) ?: throw Exception("GATTサーバの開始に失敗")

得た知見ネット上ではキャラクタリスティックに設定できるデータ長としていくつか情報がありますが、今回AndroidのPixelで試したところ21byteが最長でした。それを超えるとsendResponseは送信に成功した結果が返ってきますがレスポンスの受信側では受信できません。

4. 通信相手を検知した際に相手のGATTサーバに接続する

セントラル側でアドバタイズを検知した場合に、アドバタイズ側のGATTサーバに接続しに行きます。
このGATT接続は不安定で失敗するのが日常です。リトライ処理、リトライし続けてもダメな場合に最初からやり直すリカバリー処理が必要です。

bluetoothGatt = bluetoothDevice.connectGatt(context, false, object : BluetoothGattCallback() {
    // GATTの接続状態更新コールバック
    override fun onConnectionStateChange(
         gatt: BluetoothGatt?,
         status: Int,
         newState: Int,
    ) {
        super.onConnectionStateChange(gatt, status, newState)
        // 接続が成功したらサービスを取得する
        if (newState == BluetoothProfile.STATE_CONNECTED) {
            gatt.discoverServices()
        }

        // エラーだったら再接続
    }

    // サービス取得結果通知コールバック
    override fun onServicesDiscovered(gatt: BluetoothGatt?, status: Int) {
        super.onServicesDiscovered(gatt, status)

        // サービスを取得
        val service = gatt?.getService(settings.serviceUUID)

        // サービスのキャラクタリスティックを取得
        val characteristic = service.getCharacteristic(characteristicUUID)

        // キャラクタリスティックの読み込み開始
        gatt.readCharacteristic(characteristic) 
    }

    // キャラクタリスティックの読み込み完了コールバック
    override fun onCharacteristicRead(
        gatt: BluetoothGatt,
        characteristic: BluetoothGattCharacteristic,
        byteArray: ByteArray,
        status: Int
    ) {
        // L2CAPの接続に必要な情報の取り出し
        val psm = parsePayload(byteArray)
    }
})

得た知見このGATT接続がAndroidのBLE関連で一番の闇かもしれません。
GATT接続が安定せず接続に失敗するのは当たり前で、API上定義されていないエラーが返ってくるのも当たり前です。
よくあるのがstatusが133でエラーになる問題ですが、接続できるまでリトライすることで回避しています。
また、特定メーカーの特定機種でstatusCodeが62のエラーになることもあり、その場合もリトライすることで回避しています。
無限にリトライはせず、接続のリトライはタイムアウトを設定し、一定秒数までリトライし続けてダメだったら諦めてアドバタイズのスキャンからやり直す対応をしています。

5. 通信相手とL2CAP接続を確立する

L2CAPの接続には、接続先のBluetoothDeviceと、TCP/IPにおけるポート番号にあたるPSMが必要です。GATT接続でPSMが取得することができればL2CAPで接続します。
今回はBluetoothのペアリングはしないため、セキュアではないL2CAP接続を用いています。
そのため、L2CAPを用いた端末間通信では機微な情報はやりとりせず、Eightのサーバ経由で行います。

val l2capPsm = // GATT接続で取得したPSM
val bluetoothSocket = gatt.device?.createInsecureL2capChannel(l2capPsm)
bluetoothSocket.connect()

// あとはInputStream、OutputStreamで読み書きする
bluetoothSocket.outputStream.write(byteArray)

得た知見Androidでは自端末(セントラル)から同じ端末と複数のL2CAP接続が可能ですが、iOSは同じ端末とは1つのL2CAP接続しかできないようです。

まとめ

以上がBLEを用いて通信相手を検知し、L2CAPを用いて端末間で全二重通信を行う方法とそこで得た知見です。
今回BLEを用いた機能開発を行って得たもっとも重要な知見は、BLEに「正しい動作」と言うものは期待せず、実機での動作を受け入れ、想定通りの動作をしない場合はそれに対して柔軟に設計や実装を変える必要があると言う点です。
また、特にAndroid端末では機種毎の動作に大きな違いがあることからエラー時のログ収集を行い、想定通りの動作をしていない場合に検知できる体制も必要です。

最後に

Sansanの技術本部では、一緒にSansan / Eightのモバイルアプリを開発する仲間を募集中です。
選考評価無しで現場のエンジニアのリアルな声が聞けるカジュアル面談もあるので、ご興味ありましたらぜひ面談だけでもお越しいただけたら幸いです。

open.talentio.com




20240312182329

© Sansan, Inc.