Table of Contents
- High-Level Architecture
- Data Model
- GraphQL API Surface
- Peer Chat: Sending a Message
- Peer Chat: Receiving a Message
- Channel Chat: Leader Election & Fan-Out
- Channel System Messages
- Channel Lifecycle
- Peer Transport Layer (LAN → Wi-Fi Aware → BLE)
- Peer Status & Presence
- Caching Layer
- File Downloads
- Design Patterns Recap
High-Level Architecture
PlainApp chat is serverless. Every device runs an embedded Ktor HTTP server, and devices talk to each other directly over the local network, Wi-Fi Aware (NAN), or Bluetooth Low Energy. There is no relay server, no cloud inbox, no phone-number-based identity. Devices are identified by a self-generated clientId and authenticated through an Ed25519 + ECDH handshake performed during pairing.
Two kinds of conversations exist:
| Type | Constant | Description |
|---|---|---|
PEER | ChatTargetType.PEER | 1-to-1 direct chat between two paired devices. |
CHANNEL | ChatTargetType.CHANNEL | Multi-party group chat owned by one device; members fan out messages to each other. |
A special "local" target is the device's own scratchpad (notes to self) —
sending to it is a no-op over the wire.
Component map
The architecture is intentionally layered:
- UI / GraphQL entry points never touch transports or DB directly.
ChatManageris a façade — every caller (UI, GraphQL resolver, peer receiver) goes through it.ChatSenderis a dispatcher that branches onChatTargetTypeand delegates to the peer or channel senders.- The transport layer is a pluggable strategy chain with circuit breaking, so a flaky Wi-Fi Aware link never blocks a message that could go over BLE.
Data Model
ChatTarget
The smallest unit of routing is a ChatTarget — a (toId, type) pair where
type is either PEER or CHANNEL. It exposes an encodedToId (peer:<id>
or channel:<id>) that the UI uses as a stable routing key (e.g.
TempData.activeToId so the receiver knows whether to emit a notification),
an isLocal() check (toId == "local"), and a parseId companion that
reconstructs the target from a stored string.
Database tables
All persistence uses Room. Three tables matter for chat:
| Table | Entity | Purpose |
|---|---|---|
chats | DChat | One row per message (text / image / file). |
chat_channels | DChatChannel | One row per group channel. |
peers | DPeer | One row per known device (paired or channel-only). |
A few things worth noting:
- Identity is
clientId, never MAC. Android randomizes the BLE MAC on every connection, so the database uses a stable 13-character self-generated id. Only an 8-byte SHA-256 prefix (shortId) is broadcast over BLE to allow discovery. status="channel"peers are members of a channel that this device has never directly paired with. Theirkeyis empty — they authenticate using the channel key instead of a pairwise shared key.owner="me"is a sentinel that lets a freshly-installed device act as owner before itsclientIdis stable;isOwnedByMe()accepts both"me"andTempData.clientId.
GraphQL API Surface
PlainApp exposes two GraphQL schemas:
- Web GraphQL (
addChatChannelSchema+addChatMessageSchemainshared/src/commonMain/kotlin/com/ismartcoding/plain/httpserver/) — served by the local Ktor server to the browser UI and to theapitest/harness. Authenticated by a ChaCha20-encrypted token. - Peer GraphQL (
PeerGraphQLService.applyPeerSchema) — exposed at/peer_graphqlfor other devices over the encrypted peer transport. Authenticated by Ed25519 signature + ChaCha20 body encryption.
The two schemas share the same business logic singletons (ChannelManager,
ChatMessageReceiver, …) but expose different surfaces because the trust
model differs: the web GraphQL trusts the local UI, while the peer GraphQL
only trusts cryptographically authenticated peers.
Web GraphQL surface (chat)
Queries: chatChannels (list all channels), chatItems(id) (messages for a
target — id is "local", peer:<id>, or channel:<id>), and
latestChatItems (preview across all chats).
Chat mutations: sendChatItem(toId, content), deleteChatItem(id),
deleteChatItems(query), and retryChatItem(id).
Channel mutations: createChatChannel(name), updateChatChannel(id, name),
deleteChatChannel(id), leaveChatChannel(id), addChatChannelMember(id, peerId), removeChatChannelMember(id, peerId), acceptChatChannelInvite(id),
and declineChatChannelInvite(id).
Peer GraphQL surface (transport)
Exposed at /peer_graphql and authenticated by Ed25519 signature + ChaCha20
body encryption. Only three mutations cross the transport boundary:
createChatItem(content) (an incoming peer message), channelSystemMessage(type, payload) (channel lifecycle events like invite/leave), and startAware (a
nudge asking the peer to start its Wi-Fi Aware service so a faster transport
can take over).
The c-id HTTP header carries the sender's clientId; the c-cid header
carries a channel id when the request is channel-scoped (so the receiver picks
the channel key rather than the pairwise peer key for decryption).
Peer Chat: Sending a Message
When the user taps Send in a peer conversation, the call chain is:
The key invariants enforced at each hop:
ChatManager.createChatItemalways inserts a row first, then sends. This means the UI sees a "pending" bubble immediately and the message survives app crashes even if delivery hasn't happened yet.PeerGraphQLClient.buildSignedRequestbuilds an envelope of the formsignature|timestamp|requestJson. The signature is Ed25519 over"$timestamp$requestJson", binding the timestamp to the body so it cannot be replayed with a fresh timestamp.PeerTransportRouter.senditerates transports in orderLan → WifiAware → Ble. Each transport can throwTransportUnavailableto let the router try the next one.- On the receiving side,
PeerChatParser.decryptchecks the timestamp is within±5 minand verifies the Ed25519 signature before the GraphQL mutation is even executed. ChatMessageReceiver.receivekeeps aseenSignaturesset keyed by"$fromPeerId|$signature|$timestamp"and throwsReplayedMessageExceptionon duplicates — essential because the transport may deliver the same payload twice (LAN + BLE).
If PeerChatSender.send returns a non-null error string, ChatSender calls
triggerPeerRediscovery(peerId), which fires a directed, encrypted
DISCOVER broadcast so the peer can re-announce its current IP/port.
Peer Chat: Receiving a Message
Inbound requests land at the local Ktor server's /peer_graphql route,
handled by PeerGraphQLService:
Notifications
emitNotificationIfNeeded is the final step. It suppresses the notification
when TempData.activeToId == targetId (i.e. the user is currently looking at
that conversation) or when canShowNotifications() is false. Channel
notifications are prefixed with the sender's name.
Channel Chat: Leader Election & Fan-Out
Channels are multi-party but serverless. To avoid every member fanning out the same message N times, the sender side elects a single leader whose job is to broadcast to all joined members.
Leader election algorithm (DChatChannel.electLeader)
- Filter to joined members that are currently online (the local device is always considered online).
- If the owner is among the online joined members → the owner is the leader.
- Otherwise, the leader is the online joined member with the smallest
clientId(deterministic tiebreak, no coordination required). - Returns
nullif no online joined members exist.
Send flow
Why a leader at all?
Imagine a 5-member channel where everyone broadcasts to everyone else: a single message would generate 20 network round trips and 4 duplicate copies arriving at each member. By electing one leader, only that device does the fan-out — the sender either performs the fan-out itself (if it's the leader) or relays a single copy to the leader, which then fans out.
If the leader is offline, the sender falls back to Result.NoLeader,
triggers peer rediscovery (so the leader's IP can be found), and clears the
status to let the user retry.
Channel key routing
Channel messages are encrypted with the channel's ChaCha20 key, not the
pairwise peer key. This is what allows a member that has only ever met the
other members via the channel (never paired 1-to-1) to receive messages —
their peers row has status="channel" and key="". The sender sets the
c-cid HTTP header to the channel id; the receiver looks up
ChannelCacher.getKeyBytes(channelId) instead of the pairwise key.
Per-recipient retry
Each sendToMember returns a DMessageDeliveryResult. The aggregated
DMessageStatusData is persisted as the chat item's status_data JSON.
The UI shows "Delivered to Alice, Bob; Failed for Carol" and lets the user
tap Retry for Carol specifically — ChatManager.sendToChannelMembers
re-runs sendToRecipients for the retry subset and merges the new results
with existing ones, replacing only the retried peers.
Channel System Messages
Channel control-plane messages (invite, accept, decline, update, kick, leave)
are exchanged over the peer GraphQL channelSystemMessage mutation. They
are JSON payloads typed by a type string:
| Type | Direction | Signed? | Purpose |
|---|---|---|---|
channel_invite | Owner → invitee | Yes | Invite a peer; carries channel key + members. |
channel_invite_accept | Invitee → owner | No | Acceptance; carries accepter's public key. |
channel_invite_decline | Invitee → owner | No | Decline; owner removes member. |
channel_update | Owner → all members | Yes | Membership/name change broadcast. |
channel_kick | Owner → kicked peer | Yes | Targeted kick; also broadcast on channel delete. |
channel_leave | Member → owner | No | Member-initiated leave notice. |
Signed payload format
The three signed types (invite, update, kick) use a canonical pipe-
delimited string: "$channelId|$version|$action|$target", where action is
one of invite, update, kick, and target is the invitee/kicked peer id
(empty for broadcast kick).
The owner signs this string with its Ed25519 key. Receivers reject any
message where channel.owner != fromId before even checking the
signature, and reject ChannelUpdate payloads whose version is
≤ the local version (stale-version guard against out-of-order delivery).
Lazy peer hydration
ChannelInvite and ChannelUpdate carry a memberPeers: List<MemberPeerInfo>
list — lightweight peer info (id, name, publicKey, deviceType, ip, port) for
every member. The receiver's ensureChannelPeer creates a DPeer row with
status="channel" for any member it has never seen before. This is critical
because fan-out routing needs every member's peer record to send messages.
Channel Lifecycle
Peer Transport Layer (LAN → Wi-Fi Aware → BLE)
PeerTransportRouter is a strategy chain with circuit breaking. The
ordered list of transports is:
LanTransport— first choice. Uses OkHttp with a ChaCha20 crypto interceptor over HTTPS. Skipped entirely whenpeer.ipis empty (cross- subnet peer we haven't discovered yet).WifiAwareTransport(Android 13+ only) — uses Wi-Fi Aware (NAN) data paths. Fast-skip when the peer'sawareRunningflag is false (refreshed by the BLE prewarmer scan). The peer's IPv6 is resolved via a custom DNS that maps the hostnameplain-aware-peerto the link-local address.BleTransport— guaranteed fallback for any paired peer. Streams chunked RPC over GATT. Slower but works without any IP connectivity.
Why this order?
- LAN is the fastest (single HTTPS round trip, ~10 ms timeout).
- Wi-Fi Aware is medium (data-path setup ~5 s, then ~10 ms round trips)
and works cross-subnet (e.g. one device on guest Wi-Fi, another on IoT
Wi-Fi). Tuned to skip fast when the peer's Aware service isn't running,
avoiding a 10 s
buildLinktimeout. - BLE is slowest but works without any IP connectivity at all — even with no Wi-Fi, the message still gets through. Used as the guaranteed fallback for paired peers.
The circuit breaker ensures that a flaky transport (especially Wi-Fi Aware during network churn) is skipped for 30 s after 2 failures, so the fallback happens quickly instead of waiting for repeated 10 s timeouts.
Wi-Fi Aware handshake
The AwareSession does a two-message handshake before opening a data path:
MSG_HELLO(subscriber → publisher): "I see you, here is my peer handle."MSG_READY(publisher → subscriber): "I've registered my network specifier, you canrequestNetworknow."
This synchronizes both sides' connectivityManager.requestNetwork(...) calls
within the Android framework's ~500 ms window. The subscriber is the side
with the smaller clientId (deterministic role split — both sides agree
without coordination), and it owns the retry loop.
Peer Status & Presence
Presence is tracked via long-lived WebSocket connections. Only one side
of each pair opens the socket — decided by the deterministic rule
TempData.clientId < peer.id. The other side accepts the inbound connection
at /peer_status.
PeerCacher.onlineMap is the source of truth for presence. It is exposed as
onlinePeerIds: StateFlow<Set<String>>, which is consumed by the channel
leader election (electLeader(onlinePeerIds, myId)).
Caching Layer
Two caches mirror the database tables in memory and expose StateFlows that
Compose collects directly:
Why copy-on-write?
Kotlin's MutableStateFlow.distinctUntilChanged uses structural equality. If
we mutated the DPeer in place, the derived pairedPeers list would contain
the same DPeer reference before and after, and distinctUntilChanged
would see no difference and suppress emission. By copying the entity first,
mutating the copy, and replacing the map entry with a new
PeerRuntime/ChannelRuntime, the derived list gets a new list-of-new-
references and the flow fires.
File Downloads
Inbound file/image messages are downloaded automatically by a bounded
worker pool. Each download streams through whatever transport is available
(PeerTransportRouter.downloadFile) and writes to a temp file, then imports
into the app's media store and patches the chat item's uri field.
Transport-agnostic streaming
The DownloadedResponse(status, ByteReadChannel, onClose): AutoCloseable
abstraction lets LAN and Wi-Fi Aware stream the live HTTP body, while BLE
streams chunked RPC (16 KiB chunks via GET /fs?id=…&offset=…&length=…)
through the same ByteReadChannel. The onClose callback lets BLE cancel
its background download coroutine when the consumer closes the response
early (e.g. on pause).
Design Patterns Recap
| Pattern | Where | Why |
|---|---|---|
| Façade | ChatManager | Single entry point; callers never touch DB/transport directly. |
| Strategy + Chain of Resp. | PeerTransportRouter + LanTransport/WifiAwareTransport/BleTransport | Pluggable transports with TransportUnavailable as the fall-through signal. |
| Circuit Breaker | PeerCircuitBreaker | 2 fails / 30 s opens a (peer, transport) leg so Wi-Fi Aware doesn't block fallback. |
| State Machine | PeerStatusManager.PeerState, AwarePeerLink.LinkState | Explicit transitions for socket lifecycle and NDP link lifecycle. |
| Producer/Consumer + Pool | DownloadQueue (3 workers, Channel.BUFFERED) | Bounded concurrency for file downloads. |
| Observer / Reactive | StateFlow everywhere | Compose collects directly; no manual refresh. |
| Replay Protection | ChatMessageReceiver.seenSignatures, PeerChatParser.MAX_TIMESTAMP_DIFF_MS | Drop duplicates from LAN+BLE dual delivery; reject out-of-window timestamps. |
| Exponential Backoff | PeerStatusManager.scheduleReconnect | min(60 s, 1 s × 2^min(n-1, 6)) — caps at 64 s. |
| Copy-on-Write | PeerCacher.mutatePeer, ChannelCacher.mutateChannel | Forces StateFlow.distinctUntilChanged to fire on every mutation. |
| Signed Envelope | PeerGraphQLClient.buildSignedRequest | signature|timestamp|body — binds timestamp to body to prevent replay. |
| Deterministic Role Split | TempData.clientId < peer.id | Decides WebSocket client vs server, and Wi-Fi Aware subscriber vs publisher. |
| Lazy Hydration | ensureChannelPeer on invite/update | Creates peers rows for unseen channel members so fan-out routing works. |
| Encrypted Identity | LANDiscoverManager.discoverSpecificDevice | Directed DISCOVER encrypts target id with peer key — only the target recognizes it. |
Further Reading
- Pairing Flow — how two devices establish trust and exchange the shared ChaCha20 key used by every transport in this article.
apitest/groups/chat-messages.shandapitest/groups/chat-channels.sh— executable test plan exercising every GraphQL mutation end-to-end.