このトランスポートを利用する広範なチャットアーキテクチャについては Chat Architecture を参照してください。 各 BLE ペイロードの暗号化に使われる共通 ChaCha20 鍵を 2 台のデバイスがどう取得するかについては Pairing Flow を参照してください。
目次
- なぜ BLE トランスポートなのか
- GATT サービス構成
- ピア識別: shortId であり MAC ではない
- 2 層チャンク設計
- RPC プリミティブ:
BleDeviceApi.requestAsync - ワイヤーエンベロープフォーマット
- メッセージ送信パス (エンドツーエンド)
- ファイルダウンロードパス (エンドツーエンド)
- 優先付け: 実運用でチャットがファイルに勝つ方法
- 並行制御と静的 GATT キュー
- 接続ライフサイクルと MTU ネゴシエーション
- 通知のフロー制御
- エラー処理: TransportUnavailable と本当の失敗
- 主要定数リファレンス
- 設計トレードオフのまとめ
なぜ BLE トランスポートなのか {#why-a-ble-transport-at-all}
PlainApp は サーバーレスでオフラインファースト です。トランスポート層は順序付きフォールバックチェーンです: LAN → Wi-Fi Aware → BLE。LAN はハッピーパスです (Wi-Fi 上の HTTPS、~10 ms ラウンドトリップ)。Wi-Fi Aware は
サブネットをまたぐピア (異なる SSID、ゲスト vs IoT VLAN) をカバーします。どちらも何らかの IP 接続性 を必要とします。BLE は唯一、次のような状況で動作するトランスポートです:
- デバイスが同じ IP ネットワークに全くいない場合。
- Wi-Fi がオフ、または機内モードの場合 (BLE 無線は別系統です)。
- Wi-Fi Aware が非対応の場合 (Android 13 未満、PlainApp のすべての iOS バリアント)。
BLE は遅いです — 数十 KB/s、リクエストごとに数秒のレイテンシ — しかし、ペアリング済みの任意のピアに対して 保証済み です。必要なのはピアの clientId だけで、それは常に BLE スキャンレスポンスでブロードキャストされているからです。
GATT サービス構成 {#gatt-service-layout}
PlainApp は 単一のカスタム GATT サービス を 2 つのキャラクタリスティックとともにアドバタイズします。登録された 16-bit UUID はなく、サービスは末尾バイトが ASCII で plpai\x01 にデコードされる 128-bit UUID を使います:
なぜ 2 つのキャラクタリスティックなのか?
2 つのプロトコルは 全く異なる信頼モデルとペイロード形状 を持ちます:
- NEARBY はペアリングメッセージを運びます。これらはピアがペアリングされる 前に 到着し (まだ共通鍵がない)、独自の Ed25519 署名付き JSON ペイロードと独自のプレフィックスルーティングを用います。ボディはプレーンな文字列です。
- HTTP はペアリング後の全トラフィック (チャット、ファイル、プレゼンス) を運びます。常に共通鍵で ChaCha20 暗号化され、LAN の Ktor サーバーと同じ
HttpRouteRegistryを使うため、ルートハンドラー (/peer_graphql、/fs、/peer_status) は一度書かれ、両トランスポートで再利用されます。
なぜ read ではなく notifications なのか?
BLE ATT プロトコルは単一属性の read を 512 バイト に制限します。GraphQL レスポンスや 16 KB ファイルチャンクはもっと大きくなり得ます。PlainApp は 実データに readCharacteristic を使わない ことでこれを回避します — サーバーの onCharacteristicReadRequest は GATT_SUCCESS とともに空ペイロードを返します。代わりにクライアントはリクエストをキャラクタリスティックに書き込み、サーバーはクライアントが再構成する チャンク化された notifications のシーケンスを送信することで応答します。これは BleDeviceApi.requestAsync、BleServerProtocol.handleWrite、AndroidBleGattServer.sendChunkedResponse でドキュメント化されています。
ピア識別: shortId であり MAC ではない
BLE アドバタイジングパケットは小さく (31 バイト)、BLE MAC アドレスは Android によって約 15 分ごとにランダム化 されるため、安定した識別子として使えません。PlainApp は代わりに 9 バイトの serviceData ペイロードをスキャンレスポンスでブロードキャストします:
なぜ完全な clientId ではなく切り詰めたハッシュなのか?
13 文字の clientId は 13 バイトに収まりますが、PlainApp は 2 つの理由から 8 バイトの 切り詰めた SHA-256 を選びます:
- 安定したバイト予算。 9 バイト合計は、サービス UUID (16 バイト)、長さ、タイプフィールドと並んで 31 バイトのアドバタイジングペイロードに収まります (約 27 バイト使用、4 バイトの余裕)。
- プライバシー。 BLE をスキャンする受動的観測者は shortId (SHA-256 ハッシュの 8 バイトプレフィックス) から clientId を復元できません (実用上、不可逆です)。同じ shortId をアドバタイズしたピアを 認識 することしかできず、PlainApp ユーザーを列挙することはできません。
完全な clientId は、実際に GATT 経由で接続し DDiscoverReply を交換したピア — つまりユーザーが既にインタラクションを選んだピア — にのみ開示されます。
2 層チャンク設計
これは BLE トランスポートで最も繊細な部分です。2 つの層はサイズと目的が全く異なるため、両方を理解することが不可欠です:
なぜ 380 文字なのか?
ネゴシエーション後の ATT MTU は Android では 517 バイト (requestMtu(517) — BLE 仕様の最大値)、iOS では ~185+ (CoreBluetooth が自動ネゴシエーション) です。ATT ヘッダー (~3 バイト) と BleSegmentData の JSON ラッパーオーバーヘッド ({"d":"...","s":N} で ~12 バイト追加) を引くと、380 文字のペイロードが両プラットフォームで単一の ATT MTU に余裕を持って収まります。この値は 対称的 です (クライアントのリクエストフラグメントもサーバーの notification フラグメントも 380 を使う) で、コードをシンプルに保ちます。
なぜファイルチャンクは 16 KiB なのか?
16 KiB のファイルチャンクは base64 で ~22 KiB の JSON にエンコードされ、~58 個の GATT notification セグメントにフラグメント化されます。各 requestAsync ラウンドトリップは BLE 上で数秒かかるため、大きく少ないチャンクほどチャンクごとのオーバーヘッドを減らせます。あまり大きくすると BLE RPC のタイムアウトにぶつかるリスクがあり、プログレスフィードバックも粗くなります (ユーザーが見る進捗更新はチャンクごとに 1 回)。16 KiB は経験的にチューニングされたスイートスポットです — スループットに十分な大きさ、レスポンシブなプログレス UI に十分な小ささです。
RPC プリミティブ: BleDeviceApi.requestAsync
すべての BLE チャットメッセージとすべてのファイルチャンクは 1 回の BleDeviceApi.requestAsync(service, requestData) 呼び出しです — BleResult を返す suspend 関数です。呼び出し側からは同期的に見えます: 1 リクエスト → 1 つの完全に再構成されたレスポンス、パイプライン化はありません。
主要な不変条件
- 1 リクエスト → 1 レスポンス。
requestAsyncは呼び出し側からは同期的です — 完全なレスポンスが再構成された後にのみ返ります。パイプライン化はありません。 - 呼び出しごとに notifications を有効化。 クライアントは各
requestAsyncの開始時に CCCD を書き込み、終了時に無効化します。無駄 (呼び出しごとに 2 回の余分な GATT write) ですが、プロトコルをステートレスに保ちます — サーバーはどのクライアントが「リスニング中」かを追跡する必要がありません。 - RPC 内でリトライなし。 単一の
writeCharacteristicがタイムアウト (5 秒) すると、RPC 全体が中断されます。ensureConnectedのみがリトライします (接続失敗時に 3 試行)。粗いトランスポートレベルのバックオフは RPC 層ではなくPeerCircuitBreakerが提供します。
ワイヤーエンベロープフォーマット {#wire-envelope-format}
Layer A セグメント内のペイロードはネストされた JSON エンベロープです。フラグメンテーションを剥がすと、論理構造は次のようになります:
レスポンス形状
レスポンスは同じ Layer A フラグメンテーションを逆方向に流れますが、内側の JSON は 3 つのフィールドを持つ BleHttpResponse です: s (HTTP ステータスコード)、h (レスポンスヘッダーマップ)、b (ボディ)。ボディは BleHttpCall.encodeResponse() によって 常に base64 エンコード されます — 空の場合でも同様です。レスポンスはバイナリの可能性があり (暗号化された GraphQL バイト、raw /fs ファイルバイト)、BLE トランスポートは文字列専用のため、同じ JSON エンベロープがテキストとバイナリの両方のペイロードを運びます。
メッセージ送信パス (エンドツーエンド) {#message-send-path-end-to-end}
すべてを組み合わせると — チャットメッセージが BLE 経由で送信されるとき何が起きるか:
注目すべき設計選択
- LAN と同じ鍵。 ペアリングの ChaCha20 共通鍵は BLE でも再利用されます — 別の BLE 鍵はありません。
LanTransportが使う OkHttp 暗号インターセプターとBleTransportのchaCha20Encrypt/chaCha20Decryptは同じプリミティブで、起動方法が違うだけです。 - LAN と同じルートハンドラー。
BleHttpRequestはHttpRouteRegistry.matchRoute(path)経由でディスパッチされます。これは Ktor LAN サーバーが使うのと同じレジストリです。そのため/peer_graphql、/fs、/peer_statusなどは一度だけ実装され、両トランスポートで同一に動作します。 - 接続再利用なし。
finally { scanner.teardownConnection(client) }ブロックは常に実行されます。各メッセージは接続→サービスディスカバリ→MTU のフルコスト (数秒) を支払います。これは意図的なトレードオフです — 設計トレードオフ を参照してください。
ファイルダウンロードパス (エンドツーエンド) {#file-download-path-end-to-end}
BLE 経由のダウンロードは ストリーミング です — ファイルは 16 KiB チャンクで読まれ、到着したままテンポラリファイルに書き込まれるため、10 MB のファイルが 10 MB の RAM を必要としません。コツは、各チャンクの RPC が 別の requestAsync 呼び出しで、チャンクがコンシューマが並行で読む ByteChannel にプッシュされることです。
なぜ 1 つの大きな RPC ではなくストリーミングなのか?
10 MB のファイルを単一の RPC で送ると、~280 000 の notification セグメントがすべて両側のメモリに保持され、レスポンスすら開始できないことになります — さらに転送全体が成功しないとプログレスが報告されません。さらに悪いことに、途中で 1 つの notification がドロップされると全体が破損します。
チャンク設計には 3 つの利点があります:
- 一定メモリ。 一度に 1 つの 16 KiB チャンクだけがインフライトです。
- リアルタイムプログレス。
DownloadQueue.notifyProgressUpdate()が毎秒発火し、UI がダウンロードバーを表示します。 - レジリエンス。 失敗したチャンクは独立してリトライできます (
DownloadQueueはタスクレベルで一時停止/再開/リトライをサポートします。途中での失敗は不完全なテンポラリファイルを残しますが、現在はダウンローダーが失敗時にそれを削除します — トレードオフを参照)。
なぜ onClose がダウンロードジョブをキャンセルするのか
DownloadedResponse.onClose コールバックは downloadJob.cancel() を呼び出します。これは不可欠です。なぜならダウンロードループは子コルーチンで実行され、コンシューマがチャネルを早めに放棄した場合 (例: ユーザーが Pause をタップ)、さもないと永遠に実行され続けるからです。DownloadedResponse の AutoCloseable 契約により、コンシューマの use { ... } ブロックは退出時に onClose を自動的に呼び出し、BLE ダウンロードコルーチンをキャンセルし、コルーチンの finally ブロックで GATT 接続を破棄します。
優先付け: 実運用でチャットがファイルに勝つ方法 {#prioritization-how-chat-beats-files-in-practice}
これはチャットアプリケーションにとって最も重要な問いです: 遅い BLE ファイルダウンロードが進行中のとき、新しいチャットメッセージはそれを追い越せるか?
正直な答え: 明示的な優先スキームは存在しない
BLE コードやダウンロードキューのどこにも 優先フィールド、優先キュー、preemption はありません。網羅的な grep で確認しました — shared/src の priority の一致はログ優先レベルと EXIF メタデータだけで、メッセージ vs ダウンロードの順序付けに関するものはありません。
代わりに存在するのは、望ましい挙動を創発的特性として生み出す アーキテクチャ上の分離 のセットです:
なぜ実運用で機能するのか
チャットを「優先されているように感じさせる」分離は構造的です:
- チャット送信は
DownloadQueueを経由しない。 これらはPeerGraphQLClient→PeerTransportRouter→BleTransport.sendから直接発行されます。そのためチャットメッセージがファイルダウンロードのキューの後ろに座ることはありません。 - 各
BleTransport呼び出しは独自の GATT 接続を開く。 長時間のダウンロードが 1 つの接続を保持していても、チャット送信が同じピアへの 2 つ目の接続を開くことを妨げません。Android は複数の同時 GATT 接続をサポートします。 - チャット RPC は短い。 単一のチャットメッセージは 1 回の
requestAsyncラウンドトリップ (接続後 ~1 秒) です。無線がダウンロードでビジーでも、チャット送信は数秒以内に完了します。
設計が及ばないところ
「明示的優先なし」のトレードオフ:
- 接続レイテンシ。 チャットもダウンロードも接続→ディスカバリ→MTU コスト (数秒) を毎回支払います。接続が再利用されないためです。ダウンロード中に到着したチャットメッセージはダウンロードの既存接続に便乗できず — 新しい接続を開きます。
- Android での静的キュー。
AndroidBleGattClientのプロセス全体のoperationQueueは、すべてのピア・すべての接続にわたって GATT 操作を直列化します。そのため 2 つの GATT 接続は共存できても、それらの write/read/notify 操作 はキューレベルでインターリーブされます。実運用では (各操作は ~ms なので) 問題ありませんが、高並行下では微妙なグローバルなボトルネックです。 - preemption なし。 進行中のダウンロードを一時停止してチャットメッセージを通すことはできません。チャット送信は単に並行実行され、無線時間を巡って競合します。
将来の改善として、BleTransport.send と downloadFile を per-peer の Mutex で囲み、キューに優先フィールドを追加することが考えられます — が、現在の設計はチャット RPC が十分に短く、競合がユーザーに見えることは稀であるという事実に依存しています。
並行制御と静的 GATT キュー
これは Android BLE 実装で最も繊細な側面なので独自のセクションを設けます。
なぜ静的 (プロセス全体) なのか?
Android BLE スタックは 単一の BluetoothGatt インスタンス上での並行 GATT 操作を許可しません — 別の write がインフライト中に writeCharacteristic を呼ぶと false を返し、2 番目の write を暗黙にドロップします。標準的な回避策は BluetoothGatt ごとのキューです。PlainApp は一歩進んで プロセス全体 のキュー (companion object 内) を使い、過度に保守的ですが正確です: アプリ内のどの 2 つの GATT 操作も同時に実行されないことを保証します。
代償は、長い BLE ファイルダウンロードの write/read/notify 操作が他のピアの GATT 操作の後ろに (そしてその逆に) キューイングされることです。各操作は ~ms なので、ユーザーに見えるボトルネックになることは稀ですが — 重い並行 BLE トラフィックを複数ピアに送る場合はボトルネックになり得ます。
トランスポート層に per-peer ロックはない
BleDeviceApi.requestAsync はプレーンな suspend fun で、ミューテックスも、キューも、per-peer の直列化もありません。同じピアに対する 2 つの並行 BleTransport.send 呼び出しはそれぞれ独自の GATT 接続を開き、独立して進行します。直列化は GATT 操作レベルで暗黙に行われます (Android では静的キュー経由、iOS では逐次 await 経由)。
接続ライフサイクルと MTU ネゴシエーション {#connection-lifecycle--mtu-negotiation}
なぜ requestMtu(517) なのか?
デフォルトの ATT MTU は 23 バイト (3 バイトの ATT ヘッダーを引いた後のペイロードは 20 バイト のみ) です。デフォルト MTU では、すべての 380 文字セグメントが 1 回ではなく ~19 回の GATT write を必要とします — 19 倍の低速化です。BLE 仕様が許す最大 MTU (517 バイト) を要求することで、380 文字セグメントが単一の ATT 操作に収まり、スループットが劇的に向上します。
iOS は明示的な MTU 要求 API を公開していません — CoreBluetooth が接続中にペリフェラルと自動的にネゴシエーションします。最近の iOS デバイスは通常 ~185 バイトをネゴシエーションし、380 文字セグメントに (ATT ヘッダー + JSON ラッパーオーバーヘッドを引いても) 余裕で収まります。
通知のフロー制御 {#flow-control-for-notifications}
サーバーはレスポンスフラグメントを notifications として送信しますが、BLE notifications には 組み込みのフロー制御がありません — サーバーがコントローラが送信できる速度より速く notifications を送信すると、それらは暗黙にドロップされます。PlainApp は明示的な ack ベースのフロー制御を実装しています:
このフロー制御がないと、バックツーバックの notifications は BLE コントローラの内部送信キューが一杯になると暗黙にドロップされます — BleGattServer インターフェースのコメントにドキュメント化されたよく知られた Android BLE の問題です。per-device の single-in-flight ルールにより、すべての notification は送信されるか、タイムアウト (その後トランスポート失敗として扱われる) をトリガーするかのいずれかであることが保証されます。
エラー処理: TransportUnavailable と本当の失敗 {#error-handling-transportunavailable-vs-real-failure}
TransportUnavailable は PeerTransportRouter に 次のトランスポートにフォールスルー させる合図です。それ以外は呼び出し側に返される本当の失敗です。
ダウンロード失敗の繊細さ
BleTransport.downloadFile は DownloadedResponse(200, channel, onClose) を 即座に 返します — チャンク化されたダウンロードループはチャネルに書き込むバックグラウンドコルーチンで実行されます。チャンク RPC が途中で失敗すると、ループは channel.close(TransportUnavailable(...)) を呼び出し、コンシューマ (PeerFileDownloader.downloadAsync) は channel.readAvailable(buf) からスローされた例外としてエラーを見ます。
つまり PeerTransportRouter.downloadFile 呼び出し自体は成功 (=DownloadedResponse を返した) しているため、サーキットブレーカーは途中のダウンロードエラーを失敗として記録しません。接続時・スキャン時の失敗のみがルーターに捕捉されます。これは意図的な設計選択です — 途中の失敗がそのピアの BLE を恒久的に無効にすべきではありません (ピアが一時的に範囲外に出ただけかもしれません)。
主要定数リファレンス {#key-constants-reference}
| 定数 | 値 | 場所 | 目的 |
|---|---|---|---|
BleDeviceApi.CHUNK_SIZE | 380 | GATT セグメントフラグメンテーション | 各 BleSegmentData.data のサイズ (JSON オーバーヘッド後も ATT MTU に収まる) |
BleTransport.CHUNK_SIZE | 16 384 (16 KiB) | ファイルダウンロード byte-range | 各 /fs チャンクリクエストのサイズ |
BleTransport.SCAN_TIMEOUT_MS | 10 000 | BLE スキャン | scanner.findOne のタイムアウト |
BleDeviceApi.NOTIFY_TIMEOUT_MS | 15 000 | RPC レスポンス | requestAsync の notification ごとの待機 |
AndroidBleGattClient MTU | 517 | 接続セットアップ | requestMtu(517) — BLE 仕様の最大値 |
AndroidBleGattClient 接続タイムアウト | 10 000 | 接続セットアップ | STATE_CONNECTED の待機 |
AndroidBleGattClient MTU タイムアウト | 5 000 | 接続セットアップ | onMtuChanged の待機 |
AndroidBleGattClient write タイムアウト | 5 000 | GATT write | onCharacteristicWrite の待機 |
AndroidBleGattClient read タイムアウト | 10 000 | GATT read | onCharacteristicRead の待機 (実データには不使用) |
AndroidBleGattClient notify-state タイムアウト | 5 000 | CCCD write | CCCD ディスクリプタ write の待機 |
ensureConnected リトライ | 3 | 接続セットアップ | 最大 4 試行 (0..3) |
AndroidBleGattServer.NOTIFY_ACK_TIMEOUT_MS | 10 000 | Notification フロー制御 | onNotificationSent の待機 |
AndroidBleGattServer notifyChunkSize | 380 | レスポンスフラグメンテーション | BleDeviceApi.CHUNK_SIZE に同じ |
IosBleGattServer リトライ上限 | 10 | Notification フロー制御 | 諦める前の最大 updateValue リトライ |
PeerCircuitBreaker.WINDOW_MS | 30 000 | トランスポートサーキットブレーカー | しきい値後のオープン期間 |
PeerCircuitBreaker.MAX_FAILURES | 2 | トランスポートサーキットブレーカー | オープンするまでの窓内失敗数 |
DownloadQueue.MAX_CONCURRENT | 3 | ダウンロードワーカープール | 同時ダウンロードコルーチン |
BleServiceData.SHORT_ID_BYTES | 8 | ピア識別 | 切り詰め SHA256 プレフィックスバイト |
BleServiceData.PAYLOAD_BYTES | 9 | ピア識別 | 1 フラグバイト + 8 shortId バイト |
BleSegmentData.STATE_START_BIT | 1 | Layer A EOF シグナリング | 複数セグメントメッセージの最初のセグメント |
BleSegmentData.STATE_END_BIT | 2 | Layer A EOF シグナリング | 最後のセグメント (または単一セグメント) |
設計トレードオフのまとめ {#design-trade-offs-recap}
関連記事
- Chat Architecture —
BleTransportがLAN → Aware → BLEフォールバックチェーンや広範なチャット送受信パイプラインにどうフィットするか。 - Pairing Flow — 各 BLE ペイロードが使う共通 ChaCha20 鍵がどう確立されるか、ペアリングハンドシェイクに NEARBY キャラクタリスティックがどう使われるか。