目錄
- 高階架構
- 資料模型
- GraphQL API 介面
- 對端聊天:傳送一則訊息
- 對端聊天:接收一則訊息
- 頻道聊天:領導者選舉與扇出
- 頻道系統訊息
- 頻道生命週期
- 對端傳輸層(LAN → Wi-Fi Aware → BLE)
- 對端狀態與上線狀態
- 快取層
- 檔案下載
- 設計模式回顧
高階架構 {#high-level-architecture}
PlainApp 聊天是無伺服器的。每台裝置執行一個內嵌的 Ktor HTTP 伺服器, 裝置之間透過區域網路、Wi-Fi Aware(NAN)或藍牙低功耗直接通訊。沒有中繼伺服器、沒有雲端收件匣、沒有以電話號碼為基礎的身分。裝置以自我產生的 clientId 識別,並透過在配對期間執行的 Ed25519 + ECDH 握手進行認證。
存在兩種對話:
| 類型 | 常數 | 說明 |
|---|---|---|
PEER | ChatTargetType.PEER | 兩台已配對裝置之間的 1 對 1 直接聊天。 |
CHANNEL | ChatTargetType.CHANNEL | 由一台裝置擁有的多方群組聊天;成員彼此扇出訊息。 |
一個特殊的 "local" 目標是裝置自己的草稿區(給自己的備忘)——
傳送到它對線路是無動作(no-op)。
元件圖
架構刻意分層:
- UI / GraphQL 進入點 永不直接觸及傳輸層或資料庫。
ChatManager是一個外觀模式(façade)——每個呼叫端(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。三個資料表與聊天相關:
| 資料表 | 實體 | 用途 |
|---|---|---|
chats | DChat | 每則訊息一筆記錄(文字/影像/檔案)。 |
chat_channels | DChatChannel | 每個群組頻道一筆記錄。 |
peers | DPeer | 每台已知裝置一筆記錄(已配對或僅頻道)。 |
有幾點值得注意:
- 身分是
clientId,絕非 MAC。 Android 在每次連線時隨機化 BLE MAC, 因此資料庫使用穩定的 13 字元自我產生 ID。只有 8 位元組的 SHA-256 前綴(shortId)會透過 BLE 廣播以允許 探索。 status="channel"的對端 是某個頻道的成員,但本裝置從未 直接與之配對。它們的key為空——它們使用 頻道金鑰 而非成對共享金鑰進行認證。owner="me"是一個哨兵值,讓剛安裝的裝置在clientId尚未 穩定前可作為擁有者;isOwnedByMe()同時接受"me"與TempData.clientId。
GraphQL API 介面 {#graphql-api-surface}
PlainApp 公開兩個 GraphQL 結構描述:
- Web GraphQL(
addChatChannelSchema+addChatMessageSchema,位於shared/src/commonMain/kotlin/com/ismartcoding/plain/httpserver/) —— 由本地 Ktor 伺服器提供給瀏覽器 UI 與apitest/測試框架。以 ChaCha20 加密的權杖進行認證。 - Peer GraphQL(
PeerGraphQLService.applyPeerSchema)—— 公開於/peer_graphql,供其他裝置透過加密的對端傳輸層存取。 以 Ed25519 簽章 + ChaCha20 主體加密進行認證。
兩個結構描述共用相同的商業邏輯單例(ChannelManager、
ChatMessageReceiver,…),但公開不同的介面,因為信任
模型不同
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
主體加密進行認證。只有三個變更跨越傳輸邊界:
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—— 這至關重要,因為 傳輸層可能會將同一負載送達兩次(LAN + BLE)。
若 PeerChatSender.send 傳回非空錯誤字串,ChatSender 會呼叫
triggerPeerRediscovery(peerId),觸發定向、加密的
DISCOVER 廣播,讓對端可重新公告其當前 IP/連接埠。
對端聊天:接收一則訊息 {#peer-chat-receiving-a-message}
入境請求抵達本地 Ktor 伺服器的 /peer_graphql 路由,
由 PeerGraphQLService 處理:
通知
emitNotificationIfNeeded 是最後一步。它在
TempData.activeToId == targetId(即使用者目前正在檢視
該對話)或 canShowNotifications() 為 false 時抑制通知。頻道
通知會以發送方的名稱作為前綴。
頻道聊天:領導者選舉與扇出 {#channel-chat-leader-election--fan-out}
頻道是多方的,但無伺服器。為避免每個成員將同一訊息扇出 N 次, 傳送端會選出單一領導者,其職責是 向所有已加入的成員廣播。
領導者選舉演算法(DChatChannel.electLeader)
- 篩選出目前上線的已加入成員(本地裝置 始終視為上線)。
- 若擁有者在已上線的已加入成員中 → 擁有者為 領導者。
- 否則,領導者為已上線的已加入成員中
clientId最小者 (確定性打破平手,無需協調)。 - 若無已上線的已加入成員,則傳回
null。
傳送流程
為什麼要有領導者?
想像一個 5 成員頻道,每個人都向其他所有人廣播:一則 訊息會產生 20 次網路往返,且每個成員收到 4 份重複副本。 選出一個領導者後,只有該裝置負責 扇出——傳送端要嘛自己執行扇出(若它是領導者), 要嘛將單一副本中繼給領導者,再由領導者扇出。
若領導者離線,傳送端退回 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 | 成員 → 擁有者 | 否 | 成員發起的離開通知。 |
已簽章負載格式
三個已簽章類型(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 記錄。這非常關鍵,
因為扇出路由需要每個成員的對端記錄才能傳送訊息。
頻道生命週期 {#channel-lifecycle}
對端傳輸層(LAN → Wi-Fi Aware → BLE) {#peer-transport-layer-lan--wi-fi-aware--ble}
PeerTransportRouter 是一條具斷路的策略鏈。有序的
傳輸層清單為:
LanTransport—— 首選。使用 OkHttp,具備 ChaCha20 加密 攔截器,透過 HTTPS。當peer.ip為空時完全跳過(跨 子網對端,尚未探索到)。WifiAwareTransport(僅 Android 13+)—— 使用 Wi-Fi Aware(NAN)資料 路徑。當對端的awareRunning旗標為 false 時(由 BLE 預熱掃描器重新整理)快速跳過。對端的 IPv6 透過自訂 DNS 解析, 將主機名稱plain-aware-peer對應到 link-local 位址。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 在開啟資料路徑之前進行兩次訊息握手:
MSG_HELLO(訂閱者 → 發布者):「我看見你了,這是我的對端 代碼。」MSG_READY(發布者 → 訂閱者):「我已註冊我的網路 規範器,你現在可以requestNetwork了。」
這在 Android 框架約 500 ms 的視窗內同步雙方的
connectivityManager.requestNetwork(...) 呼叫。訂閱者 是
clientId 較小的一方(確定性的角色切分——雙方無需協調即可
同意),並擁有重試迴圈。
對端狀態與上線狀態 {#peer-status--presence}
上線狀態透過長存 WebSocket 連線追蹤。每一對中
只有一方開啟 socket——由確定性規則
TempData.clientId < peer.id 決定。另一方在
/peer_status 接受入境連線。
PeerCacher.onlineMap 是上線狀態的真實來源。它以
onlinePeerIds: StateFlow<Set<String>> 公開,由頻道
領導者選舉(electLeader(onlinePeerIds, myId))消費。
快取層 {#caching-layer}
兩個快取在記憶體中映射資料表,並公開 Compose 直接收集的 StateFlow:
為什麼使用 copy-on-write?
Kotlin 的 MutableStateFlow.distinctUntilChanged 使用結構相等性。若
我們就地修改 DPeer,衍生的 pairedPeers 清單在
前後會包含相同的 DPeer 參考,而 distinctUntilChanged
會看不到差異並抑制發射。透過先複製實體,
修改副本,並以新的
PeerRuntime/ChannelRuntime 替換對應項目,衍生清單會得到新的「新參考清單」,
而 flow 會觸發。
檔案下載 {#file-downloads}
入境檔案/影像訊息由有界的工作者集區自動下載。
每次下載串流透過任何可用的傳輸層
(PeerTransportRouter.downloadFile),寫入暫存檔,然後匯入
應用程式的媒體櫃並修補聊天項目的 uri 欄位。
與傳輸層無關的串流
DownloadedResponse(status, ByteReadChannel, onClose): AutoCloseable
抽象讓 LAN 與 Wi-Fi Aware 串流即時 HTTP 主體,而 BLE
則透過相同的 ByteReadChannel 串流分塊 RPC(透過 GET /fs?id=…&offset=…&length=… 取得 16 KiB 分塊)。
onClose 回呼讓 BLE 在消費者提前關閉回應
(例如暫停時)時取消其背景下載協程。
設計模式回顧 {#design-patterns-recap}
| 模式 | 所在位置 | 原因 |
|---|---|---|
| 外觀模式 | ChatManager | 單一進入點;呼叫端永不直接觸及資料庫/傳輸層。 |
| 策略 + 職責鏈 | PeerTransportRouter + LanTransport/WifiAwareTransport/BleTransport | 可插拔的傳輸層,以 TransportUnavailable 作為 fall-through 訊號。 |
| 斷路器 | PeerCircuitBreaker | 2 次失敗 / 30 秒開啟(對端, 傳輸層)這一腿,讓 Wi-Fi Aware 不會阻塞備援。 |
| 狀態機 | PeerStatusManager.PeerState、AwarePeerLink.LinkState | socket 生命週期與 NDP 連結生命週期的明確轉換。 |
| 生產者/消費者 + 集區 | DownloadQueue(3 個工作者,Channel.BUFFERED) | 檔案下載的有界並行。 |
| 觀察者 / 反應式 | 處處可見的 StateFlow | Compose 直接收集;無需手動重新整理。 |
| 重放保護 | ChatMessageReceiver.seenSignatures、PeerChatParser.MAX_TIMESTAMP_DIFF_MS | 捨棄 LAN+BLE 雙重送達的重複;拒絕視窗外時間戳。 |
| 指數退避 | PeerStatusManager.scheduleReconnect | min(60 s, 1 s × 2^min(n-1, 6)) —— 上限 64 秒。 |
| Copy-on-Write | PeerCacher.mutatePeer、ChannelCacher.mutateChannel | 強制 StateFlow.distinctUntilChanged 在每次變更時觸發。 |
| 已簽章封套 | PeerGraphQLClient.buildSignedRequest | signature|timestamp|body —— 將時間戳綁定到主體以防止重放。 |
| 確定性角色切分 | TempData.clientId < peer.id | 決定 WebSocket 用戶端 vs 伺服器,以及 Wi-Fi Aware 訂閱者 vs 發布者。 |
| 延遲充實 | invite/update 時的 ensureChannelPeer | 為未見過的頻道成員建立 peers 記錄,讓扇出路由運作。 |
| 加密身分 | LANDiscoverManager.discoverSpecificDevice | 定向 DISCOVER 以對端金鑰加密目標 ID——只有目標能識別它。 |
延伸閱讀
- Pairing Flow —— 兩台裝置如何建立信任並 交換本文中每個傳輸層使用的共享 ChaCha20 金鑰。
apitest/groups/chat-messages.sh與apitest/groups/chat-channels.sh——可執行的測試計畫,端對端演練每個 GraphQL 變更。