技術本部 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の互換性プログラムのドキュメントに記載があります。
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 // スキャン結果を受け取るコールバック )
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サーバの開始に失敗")
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) } })
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)
まとめ
以上がBLEを用いて通信相手を検知し、L2CAPを用いて端末間で全二重通信を行う方法とそこで得た知見です。
今回BLEを用いた機能開発を行って得たもっとも重要な知見は、BLEに「正しい動作」と言うものは期待せず、実機での動作を受け入れ、想定通りの動作をしない場合はそれに対して柔軟に設計や実装を変える必要があると言う点です。
また、特にAndroid端末では機種毎の動作に大きな違いがあることからエラー時のログ収集を行い、想定通りの動作をしていない場合に検知できる体制も必要です。
最後に
Sansanの技術本部では、一緒にSansan / Eightのモバイルアプリを開発する仲間を募集中です。
選考評価無しで現場のエンジニアのリアルな声が聞けるカジュアル面談もあるので、ご興味ありましたらぜひ面談だけでもお越しいただけたら幸いです。