Terug naar blog
Architecture12 min read

Peer- en kanaal-chatarchitectuur

Dit artikel legt uit hoe de offline-first-chat van PlainApp end-to-end werkt: hoe een bericht reist van een tik in de UI helemaal naar een ander apparaat via het peer-transport, hoe groepskanalen berichten uitwaaieren naar veel leden, en hoe het systeem veerkrachtig blijft wanneer netwerken verdwijnen. Koppeling (de vertrouwens- en sleuteluitwisseling die twee apparaten bootstrapt) wordt behandeld in het afzonderlijke artikel Koppelingsproces.

Inhoudsopgave

Architectuur op hoog niveau {#high-level-architecture}

PlainApp-chat is serverless. Elk apparaat draait een ingebouwde Ktor-HTTP-server, en apparaten communiceren direct met elkaar over het lokale netwerk, Wi-Fi Aware (NAN) of Bluetooth Low Energy. Er is geen relay-server, geen cloud-inbox, geen op telefoonnummers gebaseerde identiteit. Apparaten worden geïdentificeerd door een zelfgegenereerde clientId en geverifieerd via een Ed25519 + ECDH-handshake die tijdens het koppelen wordt uitgevoerd.

Er bestaan twee soorten gesprekken:

TypeConstantBeschrijving
PEERChatTargetType.PEER1-op-1 directe chat tussen twee gekoppelde apparaten.
CHANNELChatTargetType.CHANNELGroepsgesprek met meerdere deelnemers dat eigendom is van één apparaat; leden waaieren berichten naar elkaar uit.

Een speciaal "local"-doel is het eigen kladblok van het apparaat (notities aan jezelf) — ernaar verzenden is een no-op over de wire.

Componentenoverzicht

Diagram 1
1

De architectuur is opzettelijk gelaagd:

  1. UI / GraphQL-toegangspunten raken nooit transports of DB direct.
  2. ChatManager is een façade — elke aanroeper (UI, GraphQL-resolver, peer-ontvanger) gaat erdoorheen.
  3. ChatSender is een dispatcher die vertakt op ChatTargetType en delegeert naar de peer- of kanaal-verzenders.
  4. De transportlaag is een pluggable strategy-chain met circuit breaking, zodat een onbetrouwbare Wi-Fi Aware-verbinding nooit een bericht blokkeert dat via BLE zou kunnen gaan.

Datamodel {#data-model}

ChatTarget

De kleinste eenheid van routing is een ChatTarget — een (toId, type)- paar waarbij type ofwel PEER of CHANNEL is. Het stelt een encodedToId (peer:<id> of channel:<id>) beschikbaar dat de UI als stabiele routing-sleutel gebruikt (bijv. TempData.activeToId zodat de ontvanger weet of hij een notificatie moet afgeven), een isLocal()-check (toId == "local"), en een parseId-companion die het doel reconstrueert uit een opgeslagen string.

Databasetabellen

Alle persistentie gebruikt Room. Drie tabellen zijn relevant voor chat:

TabelEntityDoel
chatsDChatEén rij per bericht (tekst / afbeelding / bestand).
chat_channelsDChatChannelEén rij per groepskanaal.
peersDPeerEén rij per bekend apparaat (gekoppeld of alleen-kanaal).

Diagram 2
2

Enkele dingen die de moeite waard zijn om op te merken:

  • Identiteit is clientId, nooit MAC. Android wijzigt de BLE-MAC bij elke verbinding, dus de database gebruikt een stabiele 13-tekens-zelfgegenereerde id. Slechts een 8-byte SHA-256-prefix (shortId) wordt via BLE uitgezonden om ontdekking mogelijk te maken.
  • status="channel"-peers zijn leden van een kanaal waarmee dit apparaat nooit direct heeft gekoppeld. Hun key is leeg — ze authenticeren met de kanaalsleutel in plaats van met een paarsgewijze gedeelde sleutel.
  • owner="me" is een sentinel die een vers geïnstalleerd apparaat laat optreden als eigenaar voordat zijn clientId stabiel is; isOwnedByMe() accepteert zowel "me" als TempData.clientId.

GraphQL-API-oppervlak {#graphql-api-surface}

PlainApp ontsluit twee GraphQL-schema's:

  1. Web-GraphQL (addChatChannelSchema + addChatMessageSchema) — geserveerd door de lokale Ktor-server aan de browser-UI en aan de apitest/-harness. Geauthenticeerd met een ChaCha20-versleutelde token.
  2. Peer-GraphQL (PeerGraphQLService.applyPeerSchema) — ontsloten op /peer_graphql voor andere apparaten over het versleutelde peer-transport. Geauthenticeerd door Ed25519-handtekening + ChaCha20-body-versleuteling.

De twee schema's delen dezelfde bedrijfslogica-singletons (ChannelManager, ChatMessageReceiver, …) maar ontsluiten verschillende oppervlakken omdat het vertrouwensmodel verschilt: de web-GraphQL vertrouwt de lokale UI, terwijl de peer-GraphQL alleen cryptografisch geauthenticeerde peers vertrouwt.

Web-GraphQL-oppervlak (chat)

Queries: chatChannels (alle kanalen opsommen), chatItems(id) (berichten voor een doel — id is "local", peer:<id> of channel:<id>), en latestChatItems (voorbeeld over alle chats).

Chat-mutations: sendChatItem(toId, content), deleteChatItem(id), deleteChatItems(query) en retryChatItem(id).

Kanaal-mutations: createChatChannel(name), updateChatChannel(id, name), deleteChatChannel(id), leaveChatChannel(id), addChatChannelMember(id, peerId), removeChatChannelMember(id, peerId), acceptChatChannelInvite(id) en declineChatChannelInvite(id).

Peer-GraphQL-oppervlak (transport)

Ontsloten op /peer_graphql en geauthenticeerd door Ed25519-handtekening + ChaCha20-body-versleuteling. Slechts drie mutations overschrijden de transportgrens: createChatItem(content) (een inkomend peer-bericht), channelSystemMessage(type, payload) (kanaal-levenscyclusgebeurtenissen zoals invite/leave) en startAware (een por die de peer vraagt zijn Wi-Fi Aware-service te starten zodat een sneller transport kan overnemen).

De c-id-HTTP-header vervoert de clientId van de afzender; de c-cid- header vervoert een kanaal-id wanneer het verzoek kanaal-scoped is (zodat de ontvanger de kanaalsleutel kiest in plaats van de paarsgewijze peer-sleutel voor ontcijfering).

Peer-chat: een bericht verzenden {#peer-chat-sending-a-message}

Wanneer de gebruiker op Verzenden tikt in een peer-gesprek, is de aanroepketen:

Diagram 3
3

De belangrijkste invarianten die bij elke hop worden afgedwongen:

  1. ChatManager.createChatItem voegt altijd eerst een rij in, daarna verzendt. Dit betekent dat de UI onmiddellijk een "in afwachting"-bel ziet en dat het bericht app-crashes overleeft, zelfs als levering nog niet heeft plaatsgevonden.
  2. PeerGraphQLClient.buildSignedRequest bouwt een envelope van de vorm signature|timestamp|requestJson. De handtekening is Ed25519 over "$timestamp$requestJson", wat de timestamp aan de body bindt zodat deze niet kan worden herreplayed met een verse timestamp.
  3. PeerTransportRouter.send itereert transports in volgorde Lan → WifiAware → Ble. Elk transport kan TransportUnavailable gooien om de router het volgende te laten proberen.
  4. Aan de ontvangende kant controleert PeerChatParser.decrypt of de timestamp binnen ±5 min ligt en verifieert de Ed25519-handtekening voordat de GraphQL-mutation überhaupt wordt uitgevoerd.
  5. ChatMessageReceiver.receive houdt een seenSignatures-set bij, geïndexeerd op "$fromPeerId|$signature|$timestamp", en gooit ReplayedMessageException bij duplicaten — essentieel omdat het transport dezelfde payload twee keer kan leveren (LAN + BLE).

Als PeerChatSender.send een niet-lege foutmelding retourneert, roept ChatSender triggerPeerRediscovery(peerId) aan, wat een gerichte, versleutelde DISCOVER-broadcast afvuurt zodat de peer zijn huidige IP/poort opnieuw kan aankondigen.

Peer-chat: een bericht ontvangen {#peer-chat-receiving-a-message}

Binnenkomende verzoeken landen op de /peer_graphql-route van de lokale Ktor-server, afgehandeld door PeerGraphQLService:

Diagram 4
4

Notificaties

emitNotificationIfNeeded is de laatste stap. Het onderdrukt de notificatie wanneer TempData.activeToId == targetId (d.w.z. de gebruiker kijkt momenteel naar dat gesprek) of wanneer canShowNotifications() onwaar is. Kanaalnotificaties worden voorzien van de naam van de afzender als prefix.

Kanaal-chat: leiderkeuze en fan-out {#channel-chat-leader-election--fan-out}

Kanalen zijn multi-party maar serverless. Om te voorkomen dat elk lid hetzelfde bericht N keer uitwaaiert, kiest de verzendkant een enkele leider wiens taak het is om naar alle aangesloten leden te broadcasten.

Algoritme voor leiderkeuze (DChatChannel.electLeader)

  1. Filter naar aangesloten leden die momenteel online zijn (het lokale apparaat wordt altijd als online beschouwd).
  2. Als de eigenaar zich onder de online aangesloten leden bevindt → de eigenaar is de leider.
  3. Anders is de leider het online aangesloten lid met de kleinste clientId (deterministische tiebreak, geen coördinatie vereist).
  4. Retourneert null als er geen online aangesloten leden zijn.

Verzendflow

Diagram 5
5

Waarom überhaupt een leider?

Stel je een 5-ledenkanaal voor waarin iedereen naar iedereen anders broadcast: één bericht zou 20 netwerk-round-trips en 4 duplicaatkopieën genereren die bij elk lid aankomen. Door één leider te kiezen, doet alleen dat apparaat de fan-out — de verzender voert de fan-out zelf uit (als hij de leider is) of stuurt één kopie door naar de leider, die dan uitwaaiert.

Als de leider offline is, valt de verzender terug op Result.NoLeader, triggert peer-herontdekking (zodat het IP van de leider kan worden gevonden), en wist de status zodat de gebruiker het opnieuw kan proberen.

Kanaalsleutel-routing

Kanaalberichten worden versleuteld met de ChaCha20-sleutel van het kanaal, niet met de paarsgewijze peer-sleutel. Dit is wat een lid dat de andere leden alleen via het kanaal heeft ontmoet (nooit 1-op-1 gekoppeld) in staat stelt berichten te ontvangen — hun peers-rij heeft status="channel" en key="". De verzender stelt de c-cid-HTTP-header in op de kanaal-id; de ontvanger zoekt ChannelCacher.getKeyBytes(channelId) op in plaats van de paarsgewijze sleutel.

Hertry per ontvanger

Elke sendToMember retourneert een DMessageDeliveryResult. De geaggregeerde DMessageStatusData wordt gepersisteerd als de status_data-JSON van het chatitem. De UI toont "Geleverd aan Alice, Bob; Mislukt voor Carol" en laat de gebruiker specifiek voor Carol op Opnieuw proberen tikken — ChatManager.sendToChannelMembers draait sendToRecipients opnieuw voor de hertry-subset en samelt de nieuwe resultaten met bestaande, waarbij alleen de hertry-peers worden vervangen.

Kanaal-systeemberichten {#channel-system-messages}

Kanaal-control-plane-berichten (invite, accept, decline, update, kick, leave) worden uitgewisseld via de peer-GraphQL-channelSystemMessage- mutation. Het zijn JSON-payloads getypeerd door een type-string:

TypeRichtingOndertekend?Doel
channel_inviteEigenaar → genodigdeJaNodig een peer uit; vervoert kanaalsleutel + leden.
channel_invite_acceptGenodigde → eigenaarNeeAcceptatie; vervoert publieke sleutel van accepter.
channel_invite_declineGenodigde → eigenaarNeeWeigeren; eigenaar verwijdert lid.
channel_updateEigenaar → alle ledenJaBroadcast van lidmaatschaps-/naamwijziging.
channel_kickEigenaar → verwijderde peerJaGerichte verwijdering; ook broadcast bij kanaalverwijdering.
channel_leaveLid → eigenaarNeeDoor lid geïnitialiseerde vertreknotificatie.

Formaat van ondertekende payload

De drie ondertekende types (invite, update, kick) gebruiken een canonieke pipe-gescheiden string: "$channelId|$version|$action|$target", waarbij action één van invite, update, kick is, en target de id is van de genodigde/verwijderde peer (leeg voor broadcast-kick).

De eigenaar ondertekent deze string met zijn Ed25519-sleutel. Ontvangers wijzen elk bericht af waar channel.owner != fromId voordat zelfs de handtekening wordt gecontroleerd, en wijzen ChannelUpdate-payloads af waarvan version de lokale versie is (stale-version-bewaking tegen out-of-order levering).

Diagram 6
6

Luie peer-hydratatie

ChannelInvite en ChannelUpdate vervoeren een memberPeers: List<MemberPeerInfo>-lijst — lichtgewicht peer-info (id, name, publicKey, deviceType, ip, port) voor elk lid. De ensureChannelPeer van de ontvanger maakt een DPeer-rij met status="channel" aan voor elk lid dat hij nog nooit heeft gezien. Dit is kritiek omdat fan-out-routing het peer-record van elk lid nodig heeft om berichten te verzenden.

Kanaal-levenscyclus {#channel-lifecycle}

Diagram 7
7

Peer-transportlaag (LAN → Wi-Fi Aware → BLE) {#peer-transport-layer-lan--wi-fi-aware--ble}

PeerTransportRouter is een strategy-chain met circuit breaking. De geordende lijst van transports is:

  1. LanTransport — eerste keuze. Gebruikt OkHttp met een ChaCha20-crypto-interceptor over HTTPS. Helemaal overgeslagen wanneer peer.ip leeg is (peer in ander subnet die we nog niet hebben ontdekt).
  2. WifiAwareTransport (alleen Android 13+) — gebruikt Wi-Fi Aware (NAN)-data-paden. Snel overgeslagen wanneer de awareRunning-vlag van de peer onwaar is (ververst door de BLE-prewarmer-scan). De IPv6 van de peer wordt opgelost via een aangepaste DNS die de hostnaam plain-aware-peer aan het link-local-adres koppelt.
  3. BleTransport — gegarandeerde fallback voor elke gekoppelde peer. Streamt gechunkte RPC over GATT. Trager maar werkt zonder enige IP-connectiviteit.

Diagram 8
8

Waarom deze volgorde?

  • LAN is het snelst (één HTTPS-round-trip, ~10 ms timeout).
  • Wi-Fi Aware is gemiddeld (data-path-setup ~5 s, daarna ~10 ms round trips) en werkt cross-subnet (bijv. één apparaat op gast-Wi-Fi, een ander op IoT-Wi-Fi). Afgestemd om snel over te slaan wanneer de Aware-service van de peer niet draait, waardoor een 10 s-buildLink- timeout wordt vermeden.
  • BLE is het traagst maar werkt zonder enige IP-connectiviteit — zelfs zonder Wi-Fi komt het bericht nog door. Gebruikt als gegarandeerde fallback voor gekoppelde peers.

De circuit breaker zorgt dat een onbetrouwbaar transport (vooral Wi-Fi Aware tijdens netwerkwisselingen) 30 s wordt overgeslagen na 2 storingen, zodat de fallback snel gebeurt in plaats van te wachten op herhaalde 10 s-timeouts.

Wi-Fi Aware-handshake

De AwareSession doet een twee-bericht-handshake voordat een data-path wordt geopend:

  • MSG_HELLO (subscriber → publisher): "Ik zie je, hier is mijn peer-handle."
  • MSG_READY (publisher → subscriber): "Ik heb mijn network-specifier geregistreerd, je kunt nu requestNetwork doen."

Dit synchroniseert de connectivityManager.requestNetwork(...)-aanroepen van beide kanten binnen het ~500 ms-venster van het Android-framework. De subscriber is de kant met de kleinste clientId (deterministische rolverdeling — beide kanten zijn het zonder coördinatie eens), en hij beheert de hertry-loop.

Peer-status en presence {#peer-status--presence}

Presence wordt bijgehouden via langlevende WebSocket-verbindingen. Slechts één kant van elk paar opent de socket — beslist door de deterministische regel TempData.clientId < peer.id. De andere kant accepteert de inkomende verbinding op /peer_status.

Diagram 9
9

PeerCacher.onlineMap is de bron van waarheid voor presence. Het wordt ontsloten als onlinePeerIds: StateFlow<Set<String>>, dat wordt geconsumeerd door de leiderkeuze van het kanaal (electLeader(onlinePeerIds, myId)).

Caching-laag {#caching-layer}

Twee caches spiegelen de databasetabellen in memory en ontsluiten StateFlows die Compose direct verzamelt:

Diagram 10
10

Waarom copy-on-write?

MutableStateFlow.distinctUntilChanged van Kotlin gebruikt structurele gelijkheid. Als we de DPeer ter plaatse zouden muteren, zou de afgeleide pairedPeers-lijst dezelfde DPeer-referentie bevatten ervoor en erna, en zou distinctUntilChanged geen verschil zien en de emissie onderdrukken. Door de entity eerst te kopiëren, de kopie te muteren, en de map-entry te vervangen door een nieuwe PeerRuntime/ChannelRuntime, krijgt de afgeleide lijst een nieuwe lijst-van-nieuwe-referenties en vuurt de flow.

Bestandsdownloads {#file-downloads}

Binnenkomende bestands/afbeeldingsberichten worden automatisch gedownload door een begrensde worker-pool. Elke download stroomt door welk transport beschikbaar is (PeerTransportRouter.downloadFile) en schrijft naar een temp-bestand, waarna het wordt geïmporteerd in de media-store van de app en het uri-veld van het chatitem wordt bijgewerkt.

Diagram 11
11

Transport-agnostisch streamen

De abstractie DownloadedResponse(status, ByteReadChannel, onClose): AutoCloseable laat LAN en Wi-Fi Aware de live HTTP-body streamen, terwijl BLE gechunkte RPC (16 KiB-chunks via GET /fs?id=…&offset=…&length=…) door dezelfde ByteReadChannel streamt. De onClose-callback laat BLE zijn achtergrond-download-coroutine annuleren wanneer de consument de respons vroegtijdig sluit (bijv. bij pauze).

Samenvatting van ontwerppatronen {#design-patterns-recap}

PatroonWaarWaarom
FaçadeChatManagerEnkel toegangspunt; aanroepers raken DB/transport nooit direct.
Strategy + Chain of Resp.PeerTransportRouter + LanTransport/WifiAwareTransport/BleTransportPluggable transports met TransportUnavailable als doorgeefsignaal.
Circuit BreakerPeerCircuitBreaker2 fouten / 30 s opent een (peer, transport)-leg zodat Wi-Fi Aware fallback niet blokkeert.
State MachinePeerStatusManager.PeerState, AwarePeerLink.LinkStateExpliciete overgangen voor socket-levenscyclus en NDP-link-levenscyclus.
Producer/Consumer + PoolDownloadQueue (3 workers, Channel.BUFFERED)Begrensde gelijktijdigheid voor bestandsdownloads.
Observer / ReactiveStateFlow overalCompose verzamelt direct; geen handmatige refresh.
Replay-beschermingChatMessageReceiver.seenSignatures, PeerChatParser.MAX_TIMESTAMP_DIFF_MSDuplicaten van LAN+BLE-dual-levering droppen; out-of-window-timestamps afwijzen.
Exponentiële backoffPeerStatusManager.scheduleReconnectmin(60 s, 1 s × 2^min(n-1, 6)) — plafond op 64 s.
Copy-on-WritePeerCacher.mutatePeer, ChannelCacher.mutateChannelForceren dat StateFlow.distinctUntilChanged bij elke mutatie vuurt.
Ondertekende envelopePeerGraphQLClient.buildSignedRequestsignature|timestamp|body — bindt timestamp aan body om replay te voorkomen.
Deterministische rolverdelingTempData.clientId < peer.idBepaalt WebSocket-client versus server, en Wi-Fi Aware-subscriber versus publisher.
Luie hydratatieensureChannelPeer bij invite/updateMaakt peers-rijen aan voor ongeziene kanaal-leden zodat fan-out-routing werkt.
Versleutelde identiteitLANDiscoverManager.discoverSpecificDeviceGerichte DISCOVER versleutelt doel-id met peer-sleutel — alleen het doel herkent het.

Verder lezen

  • Koppelingsproces — hoe twee apparaten vertrouwen opbouwen en de gedeelde ChaCha20-sleutel uitwisselen die door elk transport in dit artikel wordt gebruikt.
  • apitest/groups/chat-messages.sh en apitest/groups/chat-channels.sh — uitvoerbaar testplan dat elke GraphQL-mutation end-to-end oefent.