Inhoudsopgave
- Architectuur op hoog niveau
- Datamodel
- GraphQL-API-oppervlak
- Peer-chat: een bericht verzenden
- Peer-chat: een bericht ontvangen
- Kanaal-chat: leiderkeuze en fan-out
- Kanaal-systeemberichten
- Kanaal-levenscyclus
- Peer-transportlaag (LAN → Wi-Fi Aware → BLE)
- Peer-status en presence
- Caching-laag
- Bestandsdownloads
- Samenvatting van ontwerppatronen
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:
| Type | Constant | Beschrijving |
|---|---|---|
PEER | ChatTargetType.PEER | 1-op-1 directe chat tussen twee gekoppelde apparaten. |
CHANNEL | ChatTargetType.CHANNEL | Groepsgesprek 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
De architectuur is opzettelijk gelaagd:
- UI / GraphQL-toegangspunten raken nooit transports of DB direct.
ChatManageris een façade — elke aanroeper (UI, GraphQL-resolver, peer-ontvanger) gaat erdoorheen.ChatSenderis een dispatcher die vertakt opChatTargetTypeen delegeert naar de peer- of kanaal-verzenders.- 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:
| Tabel | Entity | Doel |
|---|---|---|
chats | DChat | Eén rij per bericht (tekst / afbeelding / bestand). |
chat_channels | DChatChannel | Eén rij per groepskanaal. |
peers | DPeer | Eén rij per bekend apparaat (gekoppeld of alleen-kanaal). |
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. Hunkeyis 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 zijnclientIdstabiel is;isOwnedByMe()accepteert zowel"me"alsTempData.clientId.
GraphQL-API-oppervlak {#graphql-api-surface}
PlainApp ontsluit twee GraphQL-schema's:
- Web-GraphQL (
addChatChannelSchema+addChatMessageSchema) — geserveerd door de lokale Ktor-server aan de browser-UI en aan deapitest/-harness. Geauthenticeerd met een ChaCha20-versleutelde token. - Peer-GraphQL (
PeerGraphQLService.applyPeerSchema) — ontsloten op/peer_graphqlvoor 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:
De belangrijkste invarianten die bij elke hop worden afgedwongen:
ChatManager.createChatItemvoegt 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.PeerGraphQLClient.buildSignedRequestbouwt een envelope van de vormsignature|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.PeerTransportRouter.senditereert transports in volgordeLan → WifiAware → Ble. Elk transport kanTransportUnavailablegooien om de router het volgende te laten proberen.- Aan de ontvangende kant controleert
PeerChatParser.decryptof de timestamp binnen±5 minligt en verifieert de Ed25519-handtekening voordat de GraphQL-mutation überhaupt wordt uitgevoerd. ChatMessageReceiver.receivehoudt eenseenSignatures-set bij, geïndexeerd op"$fromPeerId|$signature|$timestamp", en gooitReplayedMessageExceptionbij 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:
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)
- Filter naar aangesloten leden die momenteel online zijn (het lokale apparaat wordt altijd als online beschouwd).
- Als de eigenaar zich onder de online aangesloten leden bevindt → de eigenaar is de leider.
- Anders is de leider het online aangesloten lid met de kleinste
clientId(deterministische tiebreak, geen coördinatie vereist). - Retourneert
nullals er geen online aangesloten leden zijn.
Verzendflow
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:
| Type | Richting | Ondertekend? | Doel |
|---|---|---|---|
channel_invite | Eigenaar → genodigde | Ja | Nodig een peer uit; vervoert kanaalsleutel + leden. |
channel_invite_accept | Genodigde → eigenaar | Nee | Acceptatie; vervoert publieke sleutel van accepter. |
channel_invite_decline | Genodigde → eigenaar | Nee | Weigeren; eigenaar verwijdert lid. |
channel_update | Eigenaar → alle leden | Ja | Broadcast van lidmaatschaps-/naamwijziging. |
channel_kick | Eigenaar → verwijderde peer | Ja | Gerichte verwijdering; ook broadcast bij kanaalverwijdering. |
channel_leave | Lid → eigenaar | Nee | Door 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).
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}
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:
LanTransport— eerste keuze. Gebruikt OkHttp met een ChaCha20-crypto-interceptor over HTTPS. Helemaal overgeslagen wanneerpeer.ipleeg is (peer in ander subnet die we nog niet hebben ontdekt).WifiAwareTransport(alleen Android 13+) — gebruikt Wi-Fi Aware (NAN)-data-paden. Snel overgeslagen wanneer deawareRunning-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 hostnaamplain-aware-peeraan het link-local-adres koppelt.BleTransport— gegarandeerde fallback voor elke gekoppelde peer. Streamt gechunkte RPC over GATT. Trager maar werkt zonder enige IP-connectiviteit.
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 nurequestNetworkdoen."
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.
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:
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.
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}
| Patroon | Waar | Waarom |
|---|---|---|
| Façade | ChatManager | Enkel toegangspunt; aanroepers raken DB/transport nooit direct. |
| Strategy + Chain of Resp. | PeerTransportRouter + LanTransport/WifiAwareTransport/BleTransport | Pluggable transports met TransportUnavailable als doorgeefsignaal. |
| Circuit Breaker | PeerCircuitBreaker | 2 fouten / 30 s opent een (peer, transport)-leg zodat Wi-Fi Aware fallback niet blokkeert. |
| State Machine | PeerStatusManager.PeerState, AwarePeerLink.LinkState | Expliciete overgangen voor socket-levenscyclus en NDP-link-levenscyclus. |
| Producer/Consumer + Pool | DownloadQueue (3 workers, Channel.BUFFERED) | Begrensde gelijktijdigheid voor bestandsdownloads. |
| Observer / Reactive | StateFlow overal | Compose verzamelt direct; geen handmatige refresh. |
| Replay-bescherming | ChatMessageReceiver.seenSignatures, PeerChatParser.MAX_TIMESTAMP_DIFF_MS | Duplicaten van LAN+BLE-dual-levering droppen; out-of-window-timestamps afwijzen. |
| Exponentiële backoff | PeerStatusManager.scheduleReconnect | min(60 s, 1 s × 2^min(n-1, 6)) — plafond op 64 s. |
| Copy-on-Write | PeerCacher.mutatePeer, ChannelCacher.mutateChannel | Forceren dat StateFlow.distinctUntilChanged bij elke mutatie vuurt. |
| Ondertekende envelope | PeerGraphQLClient.buildSignedRequest | signature|timestamp|body — bindt timestamp aan body om replay te voorkomen. |
| Deterministische rolverdeling | TempData.clientId < peer.id | Bepaalt WebSocket-client versus server, en Wi-Fi Aware-subscriber versus publisher. |
| Luie hydratatie | ensureChannelPeer bij invite/update | Maakt peers-rijen aan voor ongeziene kanaal-leden zodat fan-out-routing werkt. |
| Versleutelde identiteit | LANDiscoverManager.discoverSpecificDevice | Gerichte 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.shenapitest/groups/chat-channels.sh— uitvoerbaar testplan dat elke GraphQL-mutation end-to-end oefent.