Mục lục
- Kiến trúc Tổng quan
- Mô hình Dữ liệu
- Bề mặt API GraphQL
- Chat Peer: Gửi một Tin nhắn
- Chat Peer: Nhận một Tin nhắn
- Chat Channel: Bầu Leader & Phân phát
- Tin nhắn Hệ thống Channel
- Vòng đời Channel
- Tầng Vận chuyển Peer (LAN → Wi-Fi Aware → BLE)
- Trạng thái Peer & Trạng thái Hiện diện
- Tầng Bộ nhớ Đệm
- Tải xuống Tệp
- Tóm tắt Design Pattern
Kiến trúc Tổng quan {#high-level-architecture}
Chat của PlainApp là không máy chủ. Mỗi thiết bị chạy một máy chủ Ktor HTTP nhúng, và các thiết bị giao tiếp trực tiếp với nhau qua mạng nội bộ, Wi-Fi Aware (NAN), hoặc Bluetooth Low Energy. Không có máy chủ trung chuyển, không có hộp thư đám mây, không có nhận dạng dựa trên số điện thoại. Các thiết bị được định danh bằng một clientId tự sinh và xác thực qua một lần bắt tay Ed25519 + ECDH được thực hiện trong quá trình pairing.
Hai loại cuộc hội thoại tồn tại:
| Loại | Hằng số | Mô tả |
|---|---|---|
PEER | ChatTargetType.PEER | Chat trực tiếp 1-1 giữa hai thiết bị đã pair. |
CHANNEL | ChatTargetType.CHANNEL | Chat nhóm đa phương do một thiết bị sở hữu; các thành viên phân phát tin nhắn cho nhau. |
Một target đặc biệt "local" là khu vực ghi chú của chính thiết bị (ghi chú cho bản thân) —
gửi đến nó là một thao tác rỗng trên đường truyền.
Bản đồ thành phần
Kiến trúc được cố ý phân lớp:
- Điểm vào UI / GraphQL không bao giờ tiếp cận tầng vận chuyển hay cơ sở dữ liệu trực tiếp.
ChatManagerlà một mặt tiền — mọi bên gọi (UI, GraphQL resolver, peer receiver) đều đi qua nó.ChatSenderlà một bộ phân phát rẽ nhánh trênChatTargetTypevà ủy quyền cho peer hoặc channel sender.- Tầng vận chuyển là một chuỗi chiến lược có thể cắm rời với ngắt mạch, để một đường truyền Wi-Fi Aware không ổn định không bao giờ chặn một tin nhắn có thể đi qua BLE.
Mô hình Dữ liệu {#data-model}
ChatTarget
Đơn vị định tuyến nhỏ nhất là một ChatTarget — một cặp (toId, type) trong đó
type là PEER hoặc CHANNEL. Nó cung cấp một encodedToId (peer:<id>
hoặc channel:<id>) mà UI dùng làm khóa định tuyến ổn định (ví dụ
TempData.activeToId để bên nhận biết có nên phát thông báo),
một kiểm tra isLocal() (toId == "local"), và một parseId companion tái dựng
target từ một chuỗi đã lưu.
Các bảng dữ liệu
Toàn bộ việc lưu trữ dùng Room. Ba bảng quan trọng cho chat:
| Bảng | Thực thể | Mục đích |
|---|---|---|
chats | DChat | Một hàng mỗi tin nhắn (text / image / file). |
chat_channels | DChatChannel | Một hàng mỗi kênh nhóm. |
peers | DPeer | Một hàng mỗi thiết bị đã biết (đã pair hoặc chỉ-qua-kênh). |
Vài điều đáng chú ý:
- Nhận dạng là
clientId, không bao giờ là MAC. Android ngẫu nhiên hóa BLE MAC trên mỗi kết nối, nên cơ sở dữ liệu dùng một id 13 ký tự tự sinh ổn định. Chỉ tiền tố SHA-256 8-byte (shortId) được phát sóng qua BLE để cho phép khám phá. - Peer
status="channel"là thành viên của một kênh mà thiết bị này chưa bao giờ pair trực tiếp.keycủa họ rỗng — họ xác thực dùng khóa kênh thay vì khóa chia sẻ theo cặp. owner="me"là một sentinel cho phép một thiết bị mới cài đặt đóng vai trò làm owner trước khiclientIdcủa nó ổn định;isOwnedByMe()chấp nhận cả"me"vàTempData.clientId.
Bề mặt API GraphQL {#graphql-api-surface}
PlainApp cung cấp hai GraphQL schema:
- Web GraphQL (
addChatChannelSchema+addChatMessageSchema) — phục vụ bởi máy chủ Ktor nội bộ cho UI trình duyệt và cho bộ kiểm thửapitest/. Được xác thực bằng token mã hóa ChaCha20. - Peer GraphQL (
PeerGraphQLService.applyPeerSchema) — cung cấp tại/peer_graphqlcho các thiết bị khác qua tầng vận chuyển peer mã hóa. Được xác thực bằng chữ ký Ed25519 + mã hóa body ChaCha20.
Hai schema chia sẻ cùng các singleton logic nghiệp vụ (ChannelManager,
ChatMessageReceiver, …) nhưng cung cấp các bề mặt khác nhau vì mô hình tin cậy
khác nhau: web GraphQL tin tưởng UI nội bộ, trong khi peer GraphQL chỉ tin tưởng
các peer đã được xác thực bằng mật mã.
Bề mặt Web GraphQL (chat)
Truy vấn: chatChannels (liệt kê tất cả các kênh), chatItems(id) (tin nhắn cho một
target — id là "local", peer:<id>, hoặc channel:<id>), và
latestChatItems (xem trước qua tất cả chat).
Chat mutation: sendChatItem(toId, content), deleteChatItem(id),
deleteChatItems(query), và retryChatItem(id).
Channel mutation: createChatChannel(name), updateChatChannel(id, name),
deleteChatChannel(id), leaveChatChannel(id), addChatChannelMember(id, peerId), removeChatChannelMember(id, peerId), acceptChatChannelInvite(id),
và declineChatChannelInvite(id).
Bề mặt Peer GraphQL (vận chuyển)
Cung cấp tại /peer_graphql và xác thực bằng chữ ký Ed25519 + mã hóa
body ChaCha20. Chỉ ba mutation vượt qua ranh giới tầng vận chuyển:
createChatItem(content) (một tin nhắn peer đến), channelSystemMessage(type, payload) (sự kiện vòng đời kênh như mời/rời), và startAware (một
lời nhắc nhờ peer khởi động dịch vụ Wi-Fi Aware để một tầng vận chuyển nhanh hơn có thể
tiếp quản).
HTTP header c-id mang clientId của người gửi; header c-cid mang
channel id khi request là phạm vi kênh (để bên nhận chọn
khóa kênh thay vì khóa peer theo cặp cho giải mã).
Chat Peer: Gửi một Tin nhắn {#peer-chat-sending-a-message}
Khi người dùng chạm Gửi trong một cuộc hội thoại peer, chuỗi gọi là:
Các bất biến chính được áp dụng ở mỗi hop:
ChatManager.createChatItemluôn chèn một hàng trước, sau đó gửi. Điều này nghĩa là UI thấy một bong bóng "đang chờ" ngay lập tức và tin nhắn tồn tại qua các sự cố ứng dụng kể cả khi việc giao chưa xảy ra.PeerGraphQLClient.buildSignedRequestxây dựng một phong bì dạngsignature|timestamp|requestJson. Chữ ký là Ed25519 trên"$timestamp$requestJson", liên kết timestamp với body để không thể phát lại với timestamp mới.PeerTransportRouter.sendlặp qua các transport theo thứ tựLan → WifiAware → Ble. Mỗi transport có thể némTransportUnavailableđể router thử transport kế tiếp.- Ở bên nhận,
PeerChatParser.decryptkiểm tra timestamp nằm trong±5 minvà xác minh chữ ký Ed25519 trước khi GraphQL mutation được thực thi. ChatMessageReceiver.receivegiữ một tậpseenSignatureskhóa bởi"$fromPeerId|$signature|$timestamp"và némReplayedMessageExceptionkhi trùng lặp — thiết yếu vì transport có thể giao cùng payload hai lần (LAN + BLE).
Nếu PeerChatSender.send trả về một chuỗi lỗi không rỗng, ChatSender gọi
triggerPeerRediscovery(peerId), kích hoạt một broadcast DISCOVER có hướng,
mã hóa để peer có thể thông báo lại IP/port hiện tại.
Chat Peer: Nhận một Tin nhắn {#peer-chat-receiving-a-message}
Các request đến hạ cánh tại route /peer_graphql của máy chủ Ktor nội bộ,
xử lý bởi PeerGraphQLService:
Thông báo
emitNotificationIfNeeded là bước cuối. Nó kìm hãm thông báo
khi TempData.activeToId == targetId (tức người dùng đang xem
cuộc hội thoại đó) hoặc khi canShowNotifications() là false. Thông báo
kênh được tiền tố với tên người gửi.
Chat Channel: Bầu Leader & Phân phát {#channel-chat-leader-election--fan-out}
Kênh là đa phương nhưng không máy chủ. Để tránh mỗi thành viên phân phát cùng một tin nhắn N lần, bên gửi bầu một leader duy nhất có nhiệm vụ phát sóng đến tất cả thành viên đã tham gia.
Thuật toán bầu leader (DChatChannel.electLeader)
- Lọc đến các thành viên đã tham gia đang trực tuyến (thiết bị nội bộ luôn được coi là trực tuyến).
- Nếu owner trong số các thành viên đã tham gia trực tuyến → owner là leader.
- Nếu không, leader là thành viên đã tham gia trực tuyến có
clientIdnhỏ nhất (phá thế hòa deterministic, không cần phối hợp). - Trả về
nullnếu không có thành viên đã tham gia trực tuyến nào.
Luồng gửi
Tại sao lại cần leader?
Tưởng tượng một kênh 5 thành viên nơi mọi người phát sóng đến tất cả người khác: một tin nhắn duy nhất sẽ tạo 20 vòng mạng và 4 bản sao trùng lặp đến mỗi thành viên. Bằng cách bầu một leader, chỉ thiết bị đó làm việc phân phát — người gửi hoặc tự thực hiện phân phát (nếu là leader) hoặc chuyển tiếp một bản sao duy nhất cho leader, sau đó leader phân phát.
Nếu leader ngoại tuyến, người gửi quay về Result.NoLeader,
kích hoạt khám phá lại peer (để IP của leader có thể được tìm thấy), và xóa
trạng thái để người dùng thử lại.
Định tuyến khóa kênh
Tin nhắn kênh được mã hóa với khóa ChaCha20 của kênh, không phải
khóa peer theo cặp. Đây là điều cho phép một thành viên chỉ từng gặp
các thành viên khác qua kênh (chưa bao giờ pair 1-1) nhận tin nhắn —
hàng peers của họ có status="channel" và key="". Người gửi đặt
HTTP header c-cid thành channel id; bên nhận tra cứu
ChannelCacher.getKeyBytes(channelId) thay vì khóa theo cặp.
Thử lại theo từng người nhận
Mỗi sendToMember trả về một DMessageDeliveryResult. DMessageStatusData
tổng hợp được lưu trữ làm JSON status_data của chat item.
UI hiển thị "Đã giao cho Alice, Bob; Thất bại cho Carol" và cho phép người dùng
chạm Thử lại cho Carol cụ thể — ChatManager.sendToChannelMembers
chạy lại sendToRecipients cho tập con thử lại và hợp nhất kết quả mới
với kết quả hiện có, thay thế chỉ các peer được thử lại.
Tin nhắn Hệ thống Channel {#channel-system-messages}
Tin nhắn mặt phẳng điều khiển kênh (mời, chấp nhận, từ chối, cập nhật, đuổi, rời)
được trao đổi qua peer GraphQL mutation channelSystemMessage. Chúng
là payload JSON được định kiểu bởi một chuỗi type:
| Loại | Hướng | Đã ký? | Mục đích |
|---|---|---|---|
channel_invite | Owner → người được mời | Có | Mời một peer; mang khóa kênh + thành viên. |
channel_invite_accept | Người được mời → owner | Không | Chấp nhận; mang public key của người chấp nhận. |
channel_invite_decline | Người được mời → owner | Không | Từ chối; owner xóa thành viên. |
channel_update | Owner → tất cả thành viên | Có | Phát sóng thay đổi thành viên/tên. |
channel_kick | Owner → peer bị đuổi | Có | Đuổi có mục tiêu; cũng phát sóng khi xóa kênh. |
channel_leave | Thành viên → owner | Không | Thông báo rời do thành viên khởi xướng. |
Định dạng payload đã ký
Ba loại đã ký (invite, update, kick) dùng một chuỗi phân tách bằng ống chuẩn: "$channelId|$version|$action|$target", trong đó action là
một trong invite, update, kick, và target là id của peer được mời/đuổi
(rỗng cho kick phát sóng).
Owner ký chuỗi này với khóa Ed25519. Bên nhận từ chối bất kỳ
tin nhắn nào mà channel.owner != fromId trước khi kiểm tra
chữ ký, và từ chối payload ChannelUpdate có version≤ phiên bản nội bộ (bảo vệ stale-version chống delivery ngoài thứ tự).
Hydrate peer lười biếng
ChannelInvite và ChannelUpdate mang một danh sách memberPeers: List<MemberPeerInfo>
— thông tin peer nhẹ (id, name, publicKey, deviceType, ip, port) cho
mọi thành viên. ensureChannelPeer của bên nhận tạo một hàng DPeer với
status="channel" cho bất kỳ thành viên nào chưa từng thấy. Điều này then chốt
vì định tuyến phân phát cần bản ghi peer của mọi thành viên để gửi tin nhắn.
Vòng đời Channel {#channel-lifecycle}
Tầng Vận chuyển Peer (LAN → Wi-Fi Aware → BLE) {#peer-transport-layer-lan--wi-fi-aware--ble}
PeerTransportRouter là một chuỗi chiến lược với ngắt mạch. Danh sách
transport theo thứ tự:
LanTransport— lựa chọn đầu tiên. Dùng OkHttp với một interceptor crypto ChaCha20 qua HTTPS. Bỏ qua hoàn toàn khipeer.iprỗng (peer chéo-subnet chưa khám phá).WifiAwareTransport(chỉ Android 13+) — dùng đường dữ liệu Wi-Fi Aware (NAN) . Bỏ qua nhanh khi cờawareRunningcủa peer là false (làm mới bởi BLE prewarmer scan). IPv6 của peer được giải quyết qua một DNS tùy chỉnh ánh xạ hostnameplain-aware-peersang địa chỉ link-local.BleTransport— fallback đảm bảo cho bất kỳ paired peer nào. Stream chunked RPC qua GATT. Chậm hơn nhưng hoạt động không cần kết nối IP.
Tại sao thứ tự này?
- LAN nhanh nhất (một vòng HTTPS đơn, timeout ~10 ms).
- Wi-Fi Aware trung bình (thiết lập data-path ~5 s, sau đó ~10 ms mỗi vòng)
và hoạt động chéo-subnet (ví dụ một thiết bị trên guest Wi-Fi, thiết bị khác trên IoT
Wi-Fi). Tinh chỉnh để bỏ qua nhanh khi Aware service của peer không chạy,
tránh một timeout
buildLink10 s. - BLE chậm nhất nhưng hoạt động không cần bất kỳ kết nối IP nào — kể cả không có Wi-Fi, tin nhắn vẫn được gửi. Dùng làm fallback đảm bảo cho paired peer.
Circuit breaker đảm bảo một transport không ổn định (đặc biệt Wi-Fi Aware trong biến động mạng) bị bỏ qua trong 30 s sau 2 lần thất bại, để fallback xảy ra nhanh thay vì chờ timeout 10 s lặp lại.
Bắt tay Wi-Fi Aware
AwareSession thực hiện một bắt tay hai tin nhắn trước khi mở data path:
MSG_HELLO(subscriber → publisher): "Tôi thấy bạn, đây là peer handle của tôi."MSG_READY(publisher → subscriber): "Tôi đã đăng ký network specifier, bạn có thểrequestNetworkrồi."
Điều này đồng bộ hóa các lệnh gọi connectivityManager.requestNetwork(...) của cả hai bên
trong cửa sổ ~500 ms của khung Android. Subscriber là bên
có clientId nhỏ hơn (phân vai deterministic — cả hai bên đồng ý
không cần phối hợp), và nó sở hữu vòng thử lại.
Trạng thái Peer & Trạng thái Hiện diện {#peer-status--presence}
Trạng thái hiện diện được theo dõi qua các kết nối WebSocket tồn tại lâu. Chỉ một bên
của mỗi cặp mở socket — quyết định bởi quy tắc deterministic
TempData.clientId < peer.id. Bên kia chấp nhận kết nối inbound
tại /peer_status.
PeerCacher.onlineMap là nguồn sự thật cho trạng thái hiện diện. Nó được cung cấp làm
onlinePeerIds: StateFlow<Set<String>>, được tiêu thụ bởi channel
leader election (electLeader(onlinePeerIds, myId)).
Tầng Bộ nhớ Đệm {#caching-layer}
Hai bộ nhớ đệm phản ánh các bảng cơ sở dữ liệu trong bộ nhớ và cung cấp các StateFlow mà
Compose thu thập trực tiếp:
Tại sao copy-on-write?
MutableStateFlow.distinctUntilChanged của Kotlin dùng structural equality. Nếu
chúng ta thay đổi DPeer tại chỗ, danh sách pairedPeers dẫn xuất sẽ chứa
cùng tham chiếu DPeer trước và sau, và distinctUntilChanged
sẽ thấy không khác biệt và kìm hãm phát. Bằng cách sao chép entity trước,
thay đổi bản sao, và thay thế mục nhập bản đồ bằng một
PeerRuntime/ChannelRuntime mới, danh sách dẫn xuất nhận một danh sách-của-mới-
tham chiếu mới và flow kích hoạt.
Tải xuống Tệp {#file-downloads}
Tin nhắn file/image đến được tự động tải xuống bởi một bounded
worker pool. Mỗi lần tải xuống stream qua transport nào available
(PeerTransportRouter.downloadFile) và ghi vào temp file, sau đó import
vào media store của app và vá trường uri của chat item.
Stream không phụ thuộc transport
Trừu tượng DownloadedResponse(status, ByteReadChannel, onClose): AutoCloseable
cho phép LAN và Wi-Fi Aware stream body HTTP trực tiếp, trong khi BLE
stream chunked RPC (chunk 16 KiB qua GET /fs?id=…&offset=…&length=…)
qua cùng ByteReadChannel. Callback onClose cho phép BLE hủy
background download coroutine khi consumer đóng response
sớm (ví dụ khi tạm dừng).
Tóm tắt Design Pattern {#design-patterns-recap}
| Mẫu | Nơi | Lý do |
|---|---|---|
| Façade | ChatManager | Điểm vào duy nhất; caller không bao giờ tiếp cận DB/transport trực tiếp. |
| Strategy + Chain of Resp. | PeerTransportRouter + LanTransport/WifiAwareTransport/BleTransport | Transport có thể cắm rời với TransportUnavailable làm tín hiệu fall-through. |
| Circuit Breaker | PeerCircuitBreaker | 2 fail / 30 s mở một leg (peer, transport) để Wi-Fi Aware không chặn fallback. |
| State Machine | PeerStatusManager.PeerState, AwarePeerLink.LinkState | Chuyển đổi explicit cho socket lifecycle và NDP link lifecycle. |
| Producer/Consumer + Pool | DownloadQueue (3 worker, Channel.BUFFERED) | Đồng thời có giới hạn cho file download. |
| Observer / Reactive | StateFlow ở khắp nơi | Compose thu thập trực tiếp; không cần refresh thủ công. |
| Replay Protection | ChatMessageReceiver.seenSignatures, PeerChatParser.MAX_TIMESTAMP_DIFF_MS | Bỏ trùng lặp từ dual delivery LAN+BLE; từ chối timestamp ngoài cửa sổ. |
| Exponential Backoff | PeerStatusManager.scheduleReconnect | min(60 s, 1 s × 2^min(n-1, 6)) — giới hạn tại 64 s. |
| Copy-on-Write | PeerCacher.mutatePeer, ChannelCacher.mutateChannel | Buộc StateFlow.distinctUntilChanged kích hoạt trên mỗi mutation. |
| Signed Envelope | PeerGraphQLClient.buildSignedRequest | signature|timestamp|body — liên kết timestamp với body để chống replay. |
| Deterministic Role Split | TempData.clientId < peer.id | Quyết định WebSocket client vs server, và Wi-Fi Aware subscriber vs publisher. |
| Lazy Hydration | ensureChannelPeer trên invite/update | Tạo hàng peers cho channel member chưa thấy để fan-out routing hoạt động. |
| Encrypted Identity | LANDiscoverManager.discoverSpecificDevice | DISCOVER có hướng mã hóa target id với peer key — chỉ target mới nhận ra. |
Đọc thêm {#further-reading}
- Pairing Flow — cách hai thiết bị thiết lập tin cậy và trao đổi khóa ChaCha20 chia sẻ được dùng bởi mọi transport trong bài viết này.
apitest/groups/chat-messages.shvàapitest/groups/chat-channels.sh— kế hoạch kiểm thử thực thi chạy mọi GraphQL mutation đầu cuối.