目次
- ハイレベルアーキテクチャ
- データモデル
- GraphQL API サーフェス
- ピアチャット: メッセージの送信
- ピアチャット: メッセージの受信
- チャネルチャット: リーダー選出と fan-out
- チャネルシステムメッセージ
- チャネルライフサイクル
- ピアトランスポート層 (LAN → Wi-Fi Aware → BLE)
- ピアステータスとプレゼンス
- キャッシュ層
- ファイルダウンロード
- デザインパターンのまとめ
ハイレベルアーキテクチャ {#high-level-architecture}
PlainApp のチャットは サーバーレス です。各デバイスは組み込みの Ktor HTTP サーバーを動かし、デバイス同士がローカルネットワーク、Wi-Fi Aware (NAN)、または Bluetooth Low Energy 経由で直接通信します。リレーサーバーもクラウドの受信箱も電話番号ベースのアイデンティティもありません。デバイスは自己生成した clientId で識別され、ペアリング中に行われる Ed25519 + ECDH ハンドシェイクで認証されます。
会話の種類は 2 つあります:
| 型 | 定数 | 説明 |
|---|---|---|
PEER | ChatTargetType.PEER | 2 台のペアリング済みデバイス間の 1 対 1 ダイレクトチャット。 |
CHANNEL | ChatTargetType.CHANNEL | 1 台のデバイスが所有するマルチパーティグループチャット。メンバーがお互いにメッセージを fan-out する。 |
特別な "local" ターゲットはデバイス自身のスクラッチパッド (自分宛てのメモ) で、これへの送信はワイヤ上では no-op です。
コンポーネントマップ
アーキテクチャは意図的に レイヤー化 されています:
- UI / GraphQL エントリポイント はトランスポートや DB に直接触れません。
ChatManagerはファサードです — すべての呼び出し側 (UI、GraphQL リゾルバー、ピアレシーバー) がこれを経由します。ChatSenderはChatTargetTypeで分岐し、ピアまたはチャネルセンダーに委譲するディスパッチャーです。- トランスポート層 はサーキットブレーキングを備えたプラグイン可能な戦略チェーンで、不安定な Wi-Fi Aware リンクが BLE で到達できるはずのメッセージをブロックすることはありません。
データモデル {#data-model}
ChatTarget
ルーティングの最小単位は ChatTarget です — (toId, type) のペアで、type は PEER または CHANNEL のいずれかです。これは encodedToId (peer:<id> または channel:<id>) を公開し、UI はこれを安定したルーティングキーとして使います (例: TempData.activeToId。レシーバーが通知を発するかどうかを知るため)。また、isLocal() チェック (toId == "local") と、保存された文字列からターゲットを再構築する parseId コンパニオンを持ちます。
データベーステーブル
すべての永続化に Room を使います。チャットに関係する 3 つのテーブル:
| テーブル | エンティティ | 目的 |
|---|---|---|
chats | DChat | メッセージ (テキスト/画像/ファイル) ごとに 1 行。 |
chat_channels | DChatChannel | グループチャネルごとに 1 行。 |
peers | DPeer | 既知のデバイス (ペアリング済みまたはチャネルのみ) ごとに 1 行。 |
いくつか注目すべき点:
- アイデンティティは
clientIdであり、MAC ではない。 Android は BLE MAC を接続ごとにランダム化するため、データベースは安定した 13 文字の自己生成 id を使います。BLE でブロードキャストされるのは 8 バイトの SHA-256 プレフィックス (shortId) のみで、ディスカバリを許可します。 status="channel"のピア は、このデバイスが直接ペアリングしたことのないチャネルのメンバーです。これらのkeyは空で — ペアワイズ共通鍵の代わりに チャネル鍵 で認証します。owner="me"は、clientIdが安定する前に新規インストールデバイスがオーナーとして振る舞えるようにするセンチネルです。isOwnedByMe()は"me"とTempData.clientIdの両方を受け入れます。
GraphQL API サーフェス {#graphql-api-surface}
PlainApp は 2 つ の GraphQL スキーマを公開します:
- Web GraphQL (
addChatChannelSchema+addChatMessageSchema) — ローカル Ktor サーバーがブラウザー UI とapitest/ハーネスに提供。ChaCha20 暗号化トークンで認証。 - Peer GraphQL (
PeerGraphQLService.applyPeerSchema) — 暗号化されたピアトランスポート経由で他デバイス向けに/peer_graphqlで公開。Ed25519 署名 + ChaCha20 ボディ暗号化で認証。
2 つのスキーマは同じビジネスロジックシングルトン (ChannelManager、ChatMessageReceiver、…) を共有しますが、異なるサーフェスを公開します。なぜなら 信頼モデルが異なる からです: Web GraphQL はローカル UI を信頼し、Peer GraphQL は暗号学的に認証されたピアのみを信頼します。
Web GraphQL サーフェス (チャット)
クエリ: chatChannels (全チャネル一覧)、chatItems(id) (あるターゲットのメッセージ — id は "local"、peer:<id>、channel:<id>)、latestChatItems (全チャットにわたるプレビュー)。
チャットミューテーション: sendChatItem(toId, content)、deleteChatItem(id)、deleteChatItems(query)、retryChatItem(id)。
チャネルミューテーション: createChatChannel(name)、updateChatChannel(id, name)、deleteChatChannel(id)、leaveChatChannel(id)、addChatChannelMember(id, peerId)、removeChatChannelMember(id, peerId)、acceptChatChannelInvite(id)、declineChatChannelInvite(id)。
Peer GraphQL サーフェス (トランスポート)
/peer_graphql で公開され、Ed25519 署名 + ChaCha20 ボディ暗号化で認証されます。トランスポート境界を越えるミューテーションは 3 つだけです: createChatItem(content) (着信ピアメッセージ)、channelSystemMessage(type, payload) (invite/leave などのチャネルライフサイクルイベント)、startAware (ピアに Wi-Fi Aware サービスを起動してもらい、より高速なトランスポートに引き継ぐよう促すナッジ) です。
c-id HTTP ヘッダーが送信者の clientId を運びます。c-cid ヘッダーはリクエストがチャネルスコープの場合にチャネル id を運びます (これによりレシーバーはペアワイズピア鍵ではなく チャネル鍵 で復号します)。
ピアチャット: メッセージの送信 {#peer-chat-sending-a-message}
ユーザーがピア会話で Send をタップしたときのコールチェーンです:
各ホップで強制される主要な不変量:
ChatManager.createChatItemは常に最初に行を挿入し、その後 送信します。つまり UI は即座に「保留中」のバブルを見え、配信が完了していなくてもアプリクラッシュ後もメッセージは生き残ります。PeerGraphQLClient.buildSignedRequestはsignature|timestamp|requestJson形式のエンベロープを構築します。署名は"$timestamp$requestJson"に対する Ed25519 で、タイムスタンプをボディに紐付けることで、新鮮なタイムスタンプでリプレイできないようにします。PeerTransportRouter.sendはトランスポートをLan → WifiAware → Bleの順にイテレートします。各トランスポートはTransportUnavailableをスローしてルーターに次を試させることができます。- 受信側で
PeerChatParser.decryptはタイムスタンプが±5 分以内かをチェックし、GraphQL ミューテーションが実行される前に Ed25519 署名を検証します。 ChatMessageReceiver.receiveは"$fromPeerId|$signature|$timestamp"をキーとするseenSignaturesセットを保持し、重複があればReplayedMessageExceptionをスローします — トランスポートが同じペイロードを 2 回 (LAN + BLE) 配信する可能性があるため、これは不可欠です。
PeerChatSender.send が非 null のエラー文字列を返した場合、ChatSender は triggerPeerRediscovery(peerId) を呼び出し、暗号化されたダイレクト DISCOVER ブロードキャストを発して、ピアが現在の IP/ポートを再アナウンスできるようにします。
ピアチャット: メッセージの受信 {#peer-chat-receiving-a-message}
着信リクエストはローカル Ktor サーバーの /peer_graphql ルートに到着し、PeerGraphQLService が処理します:
通知
emitNotificationIfNeeded が最終ステップです。TempData.activeToId == targetId (つまりユーザーが現在その会話を見ている) の場合、または canShowNotifications() が false の場合に通知を抑制します。チャネル通知には送信者名がプレフィックスとして付きます。
チャネルチャット: リーダー選出と fan-out {#channel-chat-leader-election--fan-out}
チャネルはマルチパーティですが サーバーレス です。全メンバーが同じメッセージを N 回 fan-out するのを避けるため、送信側は 1 人の リーダー を選出し、そのリーダーがジョイン済み全メンバーにブロードキャストする役割を担います。
リーダー選出アルゴリズム (DChatChannel.electLeader)
- 現在オンラインのジョイン済みメンバーにフィルター (ローカルデバイスは常にオンラインとみなす)。
- オーナー がオンラインのジョイン済みメンバーにいれば → オーナーがリーダー。
- そうでなければ、オンラインのジョイン済みメンバーのうち 最小の
clientIdを持つメンバーがリーダー (決定的なタイブレーク、調整不要)。 - オンラインのジョイン済みメンバーがいない場合は
nullを返す。
送信フロー
なぜリーダーが必要なのか?
5 人のメンバーが全員にブロードキャストするチャネルを想像してください: 1 つのメッセージで 20 回のネットワークラウンドトリップと、各メンバーに 4 つの重複コピーが到着します。リーダーを 1 人選ぶことで、そのデバイスだけが fan-out を行います — 送信者は (自分がリーダーなら) 自ら fan-out を行うか、(そうでなければ) 単一のコピーをリーダーにリレーし、リーダーが fan-out します。
リーダーがオフラインの場合、送信者は Result.NoLeader にフォールバックし、ピア再ディスカバリをトリガーして (リーダーの IP を見つけられるように)、ステータスをクリアしてユーザーに再試行させます。
チャネル鍵ルーティング
チャネルメッセージはペアワイズピア鍵ではなく チャネルの ChaCha20 鍵 で暗号化されます。これにより、チャネル経由でしか他のメンバーと会ったことがない (1 対 1 でペアリングしたことがない) メンバーがメッセージを受信できます — その peers 行は status="channel" で key="" です。送信者は c-cid HTTP ヘッダーにチャネル id をセットし、レシーバーはペアワイズ鍵の代わりに ChannelCacher.getKeyBytes(channelId) をルックアップします。
受信者ごとのリトライ
各 sendToMember は DMessageDeliveryResult を返します。集約された DMessageStatusData はチャットアイテムの status_data JSON として永続化されます。UI は「Alice、Bob に配信済み。Carol は失敗」を表示し、ユーザーが Carol だけ Retry をタップできるようにします — ChatManager.sendToChannelMembers はリトライ対象のサブセットに対して sendToRecipients を再実行し、新しい結果を既存のものと マージ し、リトライしたピアだけを置き換えます。
チャネルシステムメッセージ {#channel-system-messages}
チャネル制御プレーンメッセージ (invite、accept、decline、update、kick、leave) は ピア GraphQL の channelSystemMessage ミューテーション経由で交換されます。これらは type 文字列で型付けされた JSON ペイロードです:
| 型 | 方向 | 署名? | 目的 |
|---|---|---|---|
channel_invite | オーナー → 招待対象 | あり | ピアを招待。チャネル鍵 + メンバーを運ぶ。 |
channel_invite_accept | 招待対象 → オーナー | なし | 承諾。承諾者の公開鍵を運ぶ。 |
channel_invite_decline | 招待対象 → オーナー | なし | 拒否。オーナーはメンバーを削除。 |
channel_update | オーナー → 全メンバー | あり | メンバーシップ/名前変更のブロードキャスト。 |
channel_kick | オーナー → 追放ピア | あり | 対象をキック。チャネル削除時にもブロードキャスト。 |
channel_leave | メンバー → オーナー | なし | メンバー主体の離脱通知。 |
署名ペイロードフォーマット
署名付きの 3 つの型 (invite、update、kick) は、正規のパイプ区切り文字列 "$channelId|$version|$action|$target" を使います。action は invite、update、kick のいずれかで、target は招待対象/キック対象のピア id です (ブロードキャスト kick では空)。
オーナーは自身の Ed25519 鍵でこの文字列に署名します。レシーバーは、署名をチェックする 前に channel.owner != fromId のメッセージを却下し、version がローカルバージョン ≤ の ChannelUpdate ペイロードを却下します (順序破りに対する古いバージョンガード)。
ピアの遅延ハイドレーション
ChannelInvite と ChannelUpdate は memberPeers: List<MemberPeerInfo> リストを運びます — 全メンバーの軽量なピア情報 (id、name、publicKey、deviceType、ip、port) です。レシーバーの ensureChannelPeer は、未確認のメンバーに対して status="channel" の DPeer 行を作成します。これは重要です。fan-out ルーティングがメッセージを送るために全メンバーのピアレコードを必要とするからです。
チャネルライフサイクル {#channel-lifecycle}
ピアトランスポート層 (LAN → Wi-Fi Aware → BLE) {#peer-transport-layer-lan--wi-fi-aware--ble}
PeerTransportRouter は サーキットブレーキング付き戦略チェーン です。トランスポートの順序付きリスト:
LanTransport— 最初の選択。HTTPS 上で ChaCha20 暗号インターセプター付きの OkHttp を使用。peer.ipが空 (まだディスカバリしていないサブネット越えピア) の場合はスキップ。WifiAwareTransport(Android 13+ のみ) — Wi-Fi Aware (NAN) データパスを使用。ピアのawareRunningフラグが false の場合はファストスキップ (BLE prewarmer スキャンで更新)。ピアの IPv6 はホスト名plain-aware-peerをリンクローカルアドレスにマッピングするカスタム DNS で解決。BleTransport— ペアリング済み任意のピアに対する保証済みフォールバック。GATT 経由でチャンク化 RPC をストリーミング。遅いが IP 接続性なしで動作。
なぜこの順序なのか?
- LAN が最速 (単一 HTTPS ラウンドトリップ、~10 ms タイムアウト)。
- Wi-Fi Aware は中間 (データパスセットアップ ~5 秒、その後 ~10 ms ラウンドトリップ) でサブネット越え (例: 一方はゲスト Wi-Fi、もう一方は IoT Wi-Fi) で動作。ピアの Aware サービスが動いていない場合はファストスキップするようチューニングされ、10 秒の
buildLinkタイムアウトを避けます。 - BLE は最遅 だが IP 接続性が全くなくても動作 — Wi-Fi がなくてもメッセージが届きます。ペアリング済みピアに対する保証済みフォールバックとして使用。
サーキットブレーカーにより、不安定なトランスポート (特にネットワーク変動時の Wi-Fi Aware) は 2 回の失敗後 30 秒間スキップされ、繰り返しの 10 秒タイムアウトを待つことなくフォールバックが素早く起きます。
Wi-Fi Aware ハンドシェイク
AwareSession はデータパスを開く前に 2 メッセージのハンドシェイクを行います:
MSG_HELLO(subscriber → publisher): 「見つけたよ、私のピアハンドルはこれ」。MSG_READY(publisher → subscriber): 「ネットワークスペシファイアを登録したよ、requestNetworkしていいよ」。
これは Android フレームワークの ~500 ms 窓内で両側の connectivityManager.requestNetwork(...) 呼び出しを同期します。subscriber は小さい clientId を持つ側です (決定的なロール分割 — 双方は調整なしで合意)。そしてリトライループを所有します。
ピアステータスとプレゼンス {#peer-status--presence}
プレゼンスは 長持ちする WebSocket 接続 経由で追跡されます。各ペアの一方だけがソケットを開きます — 決定的なルール TempData.clientId < peer.id で決まります。もう一方は /peer_status で着信接続を受け入れます。
PeerCacher.onlineMap がプレゼンスの真実の情報源です。これは onlinePeerIds: StateFlow<Set<String>> として公開され、チャネルリーダー選出 (electLeader(onlinePeerIds, myId)) で消費されます。
キャッシュ層 {#caching-layer}
2 つのキャッシュがデータベースのテーブルをメモリ上にミラーリングし、Compose が直接収集する StateFlow を公開します:
なぜ copy-on-write なのか?
Kotlin の MutableStateFlow.distinctUntilChanged は構造的等価性を使います。DPeer を直接ミューテートすると、派生した pairedPeers リストは前後で 同じ DPeer 参照を含み、distinctUntilChanged は差異を見ず、エミッションを抑制します。エンティティを先にコピーし、コピーをミューテートし、マップエントリを新しい PeerRuntime/ChannelRuntime で 置き換える ことで、派生リストは新しい参照のリストを得てフローが発火します。
ファイルダウンロード {#file-downloads}
着信ファイル/画像メッセージは、上限付きワーカープールにより自動的にダウンロードされます。各ダウンロードは利用可能なトランスポート (PeerTransportRouter.downloadFile) を経由してストリーミングし、テンポラリファイルに書き込み、その後アプリのメディアストアにインポートしてチャットアイテムの uri フィールドをパッチします。
トランスポート非依存のストリーミング
DownloadedResponse(status, ByteReadChannel, onClose): AutoCloseable 抽象により、LAN と Wi-Fi Aware はライブ HTTP ボディをストリーミングしつつ、BLE は同じ ByteReadChannel 経由でチャンク化 RPC (16 KiB チャンク、GET /fs?id=…&offset=…&length=… 経由) をストリーミングします。onClose コールバックにより、BLE はコンシューマがレスポンスを早めに閉じた (例: 一時停止時) 際にバックグラウンドダウンロードコルーチンをキャンセルできます。
デザインパターンのまとめ {#design-patterns-recap}
| パターン | 場所 | 理由 |
|---|---|---|
| Façade | ChatManager | 単一エントリポイント。呼び出し側は DB/トランスポートに直接触れない。 |
| Strategy + Chain of Resp. | PeerTransportRouter + LanTransport/WifiAwareTransport/BleTransport | プラグイン可能なトランスポート。TransportUnavailable をフォールスルーシグナルとして使用。 |
| Circuit Breaker | PeerCircuitBreaker | 2 回失敗 / 30 秒で (peer, transport) レッグをオープンし、Wi-Fi Aware がフォールバックをブロックしない。 |
| State Machine | PeerStatusManager.PeerState、AwarePeerLink.LinkState | ソケットライフサイクルと NDP リンクライフサイクルの明示的な遷移。 |
| Producer/Consumer + Pool | DownloadQueue (3 ワーカー、Channel.BUFFERED) | ファイルダウンロードの上限付き並行性。 |
| Observer / Reactive | あらゆる StateFlow | Compose が直接収集。手動リフレッシュなし。 |
| Replay Protection | ChatMessageReceiver.seenSignatures、PeerChatParser.MAX_TIMESTAMP_DIFF_MS | LAN+BLE デュアル配信からの重複をドロップ。窓外のタイムスタンプを拒否。 |
| Exponential Backoff | PeerStatusManager.scheduleReconnect | min(60 s, 1 s × 2^min(n-1, 6)) — 64 秒でキャップ。 |
| Copy-on-Write | PeerCacher.mutatePeer、ChannelCacher.mutateChannel | すべてのミューテーションで StateFlow.distinctUntilChanged を発火させる。 |
| Signed Envelope | PeerGraphQLClient.buildSignedRequest | signature|timestamp|body — タイムスタンプをボディに紐付けリプレイ防止。 |
| Deterministic Role Split | TempData.clientId < peer.id | WebSocket クライアント vs サーバー、Wi-Fi Aware subscriber vs publisher を決定。 |
| Lazy Hydration | invite/update での ensureChannelPeer | 未確認のチャネルメンバーの peers 行を作成し、fan-out ルーティングが動作するように。 |
| Encrypted Identity | LANDiscoverManager.discoverSpecificDevice | ダイレクト DISCOVER は対象 id をピア鍵で暗号化 — 対象だけが認識する。 |
関連記事
- Pairing Flow — 2 台のデバイスがどう信頼を確立し、本記事のすべてのトランスポートで使われる共通 ChaCha20 鍵を交換するか。
apitest/groups/chat-messages.shとapitest/groups/chat-channels.sh— すべての GraphQL ミューテーションをエンドツーエンドで実行するテスト計画。