Back to blog
Architecture12 min read

Peer & Channel Chat Architecture

This article explains how PlainApp's offline-first chat works end-to-end: how a message travels from a tap in the UI all the way to another device over the peer transport, how group channels fan out messages to many members, and how the system stays resilient when networks disappear. Pairing (the trust and key exchange that bootstraps two devices) is covered in the separate Pairing Flow article.

Table of Contents

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:

TypeConstantDescription
PEERChatTargetType.PEER1-to-1 direct chat between two paired devices.
CHANNELChatTargetType.CHANNELMulti-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

Diagram 1
1

The architecture is intentionally layered:

  1. UI / GraphQL entry points never touch transports or DB directly.
  2. ChatManager is a façade — every caller (UI, GraphQL resolver, peer receiver) goes through it.
  3. ChatSender is a dispatcher that branches on ChatTargetType and delegates to the peer or channel senders.
  4. 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:

TableEntityPurpose
chatsDChatOne row per message (text / image / file).
chat_channelsDChatChannelOne row per group channel.
peersDPeerOne row per known device (paired or channel-only).

Diagram 2
2

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. Their key is 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 its clientId is stable; isOwnedByMe() accepts both "me" and TempData.clientId.

GraphQL API Surface

PlainApp exposes two GraphQL schemas:

  1. Web GraphQL (addChatChannelSchema + addChatMessageSchema in shared/src/commonMain/kotlin/com/ismartcoding/plain/httpserver/) — served by the local Ktor server to the browser UI and to the apitest/ harness. Authenticated by a ChaCha20-encrypted token.
  2. Peer GraphQL (PeerGraphQLService.applyPeerSchema) — exposed at /peer_graphql for 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:

Diagram 3
3

The key invariants enforced at each hop:

  1. ChatManager.createChatItem always 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.
  2. PeerGraphQLClient.buildSignedRequest builds an envelope of the form signature|timestamp|requestJson. The signature is Ed25519 over "$timestamp$requestJson", binding the timestamp to the body so it cannot be replayed with a fresh timestamp.
  3. PeerTransportRouter.send iterates transports in order Lan → WifiAware → Ble. Each transport can throw TransportUnavailable to let the router try the next one.
  4. On the receiving side, PeerChatParser.decrypt checks the timestamp is within ±5 min and verifies the Ed25519 signature before the GraphQL mutation is even executed.
  5. ChatMessageReceiver.receive keeps a seenSignatures set keyed by "$fromPeerId|$signature|$timestamp" and throws ReplayedMessageException on 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:

Diagram 4
4

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)

  1. Filter to joined members that are currently online (the local device is always considered online).
  2. If the owner is among the online joined members → the owner is the leader.
  3. Otherwise, the leader is the online joined member with the smallest clientId (deterministic tiebreak, no coordination required).
  4. Returns null if no online joined members exist.

Send flow

Diagram 5
5

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:

TypeDirectionSigned?Purpose
channel_inviteOwner → inviteeYesInvite a peer; carries channel key + members.
channel_invite_acceptInvitee → ownerNoAcceptance; carries accepter's public key.
channel_invite_declineInvitee → ownerNoDecline; owner removes member.
channel_updateOwner → all membersYesMembership/name change broadcast.
channel_kickOwner → kicked peerYesTargeted kick; also broadcast on channel delete.
channel_leaveMember → ownerNoMember-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).

Diagram 6
6

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

Diagram 7
7

Peer Transport Layer (LAN → Wi-Fi Aware → BLE)

PeerTransportRouter is a strategy chain with circuit breaking. The ordered list of transports is:

  1. LanTransport — first choice. Uses OkHttp with a ChaCha20 crypto interceptor over HTTPS. Skipped entirely when peer.ip is empty (cross- subnet peer we haven't discovered yet).
  2. WifiAwareTransport (Android 13+ only) — uses Wi-Fi Aware (NAN) data paths. Fast-skip when the peer's awareRunning flag is false (refreshed by the BLE prewarmer scan). The peer's IPv6 is resolved via a custom DNS that maps the hostname plain-aware-peer to the link-local address.
  3. BleTransport — guaranteed fallback for any paired peer. Streams chunked RPC over GATT. Slower but works without any IP connectivity.

Diagram 8
8

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 buildLink timeout.
  • 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 can requestNetwork now."

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.

Diagram 9
9

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:

Diagram 10
10

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.

Diagram 11
11

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

PatternWhereWhy
FaçadeChatManagerSingle entry point; callers never touch DB/transport directly.
Strategy + Chain of Resp.PeerTransportRouter + LanTransport/WifiAwareTransport/BleTransportPluggable transports with TransportUnavailable as the fall-through signal.
Circuit BreakerPeerCircuitBreaker2 fails / 30 s opens a (peer, transport) leg so Wi-Fi Aware doesn't block fallback.
State MachinePeerStatusManager.PeerState, AwarePeerLink.LinkStateExplicit transitions for socket lifecycle and NDP link lifecycle.
Producer/Consumer + PoolDownloadQueue (3 workers, Channel.BUFFERED)Bounded concurrency for file downloads.
Observer / ReactiveStateFlow everywhereCompose collects directly; no manual refresh.
Replay ProtectionChatMessageReceiver.seenSignatures, PeerChatParser.MAX_TIMESTAMP_DIFF_MSDrop duplicates from LAN+BLE dual delivery; reject out-of-window timestamps.
Exponential BackoffPeerStatusManager.scheduleReconnectmin(60 s, 1 s × 2^min(n-1, 6)) — caps at 64 s.
Copy-on-WritePeerCacher.mutatePeer, ChannelCacher.mutateChannelForces StateFlow.distinctUntilChanged to fire on every mutation.
Signed EnvelopePeerGraphQLClient.buildSignedRequestsignature|timestamp|body — binds timestamp to body to prevent replay.
Deterministic Role SplitTempData.clientId < peer.idDecides WebSocket client vs server, and Wi-Fi Aware subscriber vs publisher.
Lazy HydrationensureChannelPeer on invite/updateCreates peers rows for unseen channel members so fan-out routing works.
Encrypted IdentityLANDiscoverManager.discoverSpecificDeviceDirected 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.sh and apitest/groups/chat-channels.sh — executable test plan exercising every GraphQL mutation end-to-end.