Zurück zum Blog
Architecture12 min read

Peer- & Channel-Chat-Architektur

Dieser Artikel erklärt, wie PlainApps Offline-First-Chat End-to-End funktioniert: wie eine Nachricht von einem Tap in der UI über den Peer-Transport bis zum anderen Gerät reist, wie Gruppen-Channel-Nachrichten an viele Mitglieder fan-outen, und wie das System resilient bleibt, wenn Netzwerke verschwinden. Pairing (der Vertrauens- und Schlüsselaustausch, der zwei Geräte bootstrapt) ist im separaten Artikel zum Pairing-Ablauf behandelt.

Inhaltsverzeichnis

High-Level-Architektur {#high-level-architecture}

PlainApp-Chat ist serverlos. Jedes Gerät betreibt einen eingebetteten Ktor-HTTP-Server, und die Geräte kommunizieren direkt über das lokale Netzwerk, Wi-Fi Aware (NAN) oder Bluetooth Low Energy miteinander. Es gibt keinen Relay-Server, keine Cloud-Inbox, keine auf Telefonnummern basierende Identität. Geräte werden durch eine selbst erzeugte clientId identifiziert und über einen Ed25519 + ECDH-Handshake authentifiziert, der beim Pairing durchgeführt wird.

Es gibt zwei Arten von Konversationen:

TypKonstanteBeschreibung
PEERChatTargetType.PEER1-zu-1-Direktchat zwischen zwei gepaarten Geräten.
CHANNELChatTargetType.CHANNELMehrparteien-Gruppenchat, der einem Gerät gehört; Mitglieder fan-outen Nachrichten aneinander.

Ein spezielles "local"-Ziel ist der Schreibblock des eigenen Geräts (Notizen an sich selbst) — Senden daran ist über den Wire ein No-Op.

Komponentenübersicht

Diagram 1
1

Die Architektur ist bewusst geschichtet:

  1. UI-/GraphQL-Einstiegspunkte fassen Transporte oder DB nie direkt an.
  2. ChatManager ist eine Fassade — jeder Aufrufer (UI, GraphQL-Resolver, Peer-Empfänger) geht darüber.
  3. ChatSender ist ein Dispatcher, der je nach ChatTargetType verzweigt und an die Peer- oder Channel-Sender delegiert.
  4. Die Transportebene ist eine steckbare Strategiekette mit Circuit Breaking, sodass ein unzuverlässiger Wi-Fi Aware-Link nie eine Nachricht blockiert, die über BLE gehen könnte.

Datenmodell {#data-model}

ChatTarget

Die kleinste Routing-Einheit ist ein ChatTarget — ein (toId, type)-Paar, wobei type entweder PEER oder CHANNEL ist. Es exposes ein encodedToId (peer:<id> oder channel:<id>), das die UI als stabilen Routing-Key verwendet (z.B. TempData.activeToId, damit der Empfänger weiß, ob er eine Notification emitten soll), eine isLocal()-Prüfung (toId == "local") und eine parseId-Companion, die das Ziel aus einem gespeicherten String rekonstruiert.

Datenbanktabellen

Die gesamte Persistierung verwendet Room. Für den Chat sind drei Tabellen relevant:

TabelleEntityZweck
chatsDChatEine Zeile pro Nachricht (Text / Bild / Datei).
chat_channelsDChatChannelEine Zeile pro Gruppen-Channel.
peersDPeerEine Zeile pro bekanntem Gerät (gepaart oder nur Channel).

Diagram 2
2

Ein paar Dinge sind erwähnenswert:

  • Identität ist clientId, niemals MAC. Android randomisiert die BLE-MAC bei jeder Verbindung, daher verwendet die Datenbank eine stabile, 13-Zeichen-selbsterzeugte ID. Nur ein 8-Byte-SHA-256-Präfix (shortId) wird über BLE gesendet, um Discovery zu erlauben.
  • Peers mit status="channel" sind Mitglieder eines Channels, mit denen dieses Gerät nie direkt gepaart wurde. Ihr key ist leer — sie authentifizieren sich mit dem Channel-Schlüssel statt mit einem paarweisen gemeinsamen Schlüssel.
  • owner="me" ist ein Sentinel, der einem frisch installierten Gerät erlaubt, als Owner zu agieren, bevor seine clientId stabil ist; isOwnedByMe() akzeptiert sowohl "me" als auch TempData.clientId.

GraphQL-API-Oberfläche {#graphql-api-surface}

PlainApp exponiert zwei GraphQL-Schemata:

  1. Web-GraphQL (addChatChannelSchema + addChatMessageSchema) — vom lokalen Ktor-Server an die Browser-UI und das apitest/-Harness ausgeliefert. Authentifiziert durch einen ChaCha20-verschlüsselten Token.
  2. Peer-GraphQL (PeerGraphQLService.applyPeerSchema) — exponiert unter /peer_graphql für andere Geräte über den verschlüsselten Peer-Transport. Authentifiziert durch Ed25519-Signatur + ChaCha20-Body-Verschlüsselung.

Die beiden Schemata teilen sich dieselben Business-Logic-Singletons (ChannelManager, ChatMessageReceiver, …), exponieren aber unterschiedliche Oberflächen, weil sich das Vertrauensmodell unterscheidet: das Web-GraphQL vertraut der lokalen UI, das Peer-GraphQL vertraut nur kryptografisch authentifizierten Peers.

Web-GraphQL-Oberfläche (Chat)

Queries: chatChannels (alle Channel auflisten), chatItems(id) (Nachrichten für ein Ziel — id ist "local", peer:<id> oder channel:<id>), und latestChatItems (Vorschau über alle Chats hinweg).

Chat-Mutations: sendChatItem(toId, content), deleteChatItem(id), deleteChatItems(query) und retryChatItem(id).

Channel-Mutations: createChatChannel(name), updateChatChannel(id, name), deleteChatChannel(id), leaveChatChannel(id), addChatChannelMember(id, peerId), removeChatChannelMember(id, peerId), acceptChatChannelInvite(id) und declineChatChannelInvite(id).

Peer-GraphQL-Oberfläche (Transport)

Exponiert unter /peer_graphql und authentifiziert durch Ed25519-Signatur + ChaCha20-Body-Verschlüsselung. Nur drei Mutations überschreiten die Transport-Grenze: createChatItem(content) (eine eingehende Peer-Nachricht), channelSystemMessage(type, payload) (Channel-Lebenszyklus-Ereignisse wie Invite/Leave) und startAware (ein Stupser, der den Peer bittet, seinen Wi-Fi Aware-Service zu starten, sodass ein schnellerer Transport übernehmen kann).

Der c-id-HTTP-Header trägt die clientId des Senders; der c-cid-Header trägt eine Channel-ID, wenn die Anfrage Channel-scoped ist (sodass der Empfänger den Channel-Schlüssel statt des paarweisen Peer-Schlüssels zur Entschlüsselung wählt).

Peer-Chat: Senden einer Nachricht {#peer-chat-sending-a-message}

Wenn der Nutzer in einer Peer-Konversation auf Send tippt, ist die Aufrufkette:

Diagram 3
3

Die wichtigsten Invarianten, die an jedem Hop durchgesetzt werden:

  1. ChatManager.createChatItem fügt immer zuerst eine Zeile ein, dann wird gesendet. Das bedeutet, dass die UI sofort eine „Pending"-Blase sieht und die Nachricht App-Abstürze überlebt, selbst wenn die Zustellung noch nicht erfolgt ist.
  2. PeerGraphQLClient.buildSignedRequest baut einen Umschlag der Form signature|timestamp|requestJson. Die Signatur ist Ed25519 über "$timestamp$requestJson" und bindet den Zeitstempel an den Body, sodass er nicht mit einem frischen Zeitstempel replayed werden kann.
  3. PeerTransportRouter.send iteriert Transporte in der Reihenfolge Lan → WifiAware → Ble. Jeder Transport kann TransportUnavailable werfen, um den Router den nächsten versuchen zu lassen.
  4. Auf der Empfangsseite prüft PeerChatParser.decrypt, ob der Zeitstempel innerhalb von ±5 min liegt, und verifiziert die Ed25519-Signatur, bevor die GraphQL-Mutation überhaupt ausgeführt wird.
  5. ChatMessageReceiver.receive hält ein seenSignatures-Set, das über "$fromPeerId|$signature|$timestamp" geschlüsselt ist, und wirft ReplayedMessageException bei Duplikaten — essenziell, weil der Transport dieselbe Nutzlast zweimal zustellen kann (LAN + BLE).

Wenn PeerChatSender.send einen non-null-Fehlerstring zurückgibt, ruft ChatSender triggerPeerRediscovery(peerId) auf, das einen gerichteten, verschlüsselten DISCOVER-Broadcast feuert, sodass der Peer seine aktuelle IP/Port neu ankündigen kann.

Peer-Chat: Empfangen einer Nachricht {#peer-chat-receiving-a-message}

Eingehende Anfragen landen an der /peer_graphql-Route des lokalen Ktor-Servers, behandelt von PeerGraphQLService:

Diagram 4
4

Notifications

emitNotificationIfNeeded ist der letzte Schritt. Es unterdrückt die Notification, wenn TempData.activeToId == targetId (d.h. der Nutzer sieht sich gerade diese Konversation an) oder wenn canShowNotifications() false ist. Channel-Notifications werden mit dem Namen des Senders präfixiert.

Channel-Chat: Leader-Wahl & Fan-Out {#channel-chat-leader-election--fan-out}

Channels sind Mehrparteien-, aber serverlos. Um zu vermeiden, dass jedes Mitglied dieselbe Nachricht N-mal fan-outet, wählt die Senderseite einen einzigen Leader, dessen Aufgabe es ist, an alle beigetretenen Mitglieder zu broadcasten.

Leader-Wahl-Algorithmus (DChatChannel.electLeader)

  1. Filtere auf beigetretene Mitglieder, die aktuell online sind (das lokale Gerät gilt immer als online).
  2. Falls der Owner unter den online beigetretenen Mitgliedern ist → der Owner ist der Leader.
  3. Andernfalls ist der Leader das online beigetretene Mitglied mit der kleinsten clientId (deterministischer Tiebreak, keine Koordination erforderlich).
  4. Gibt null zurück, falls es keine online beigetretenen Mitglieder gibt.

Sende-Fluss

Diagram 5
5

Warum überhaupt ein Leader?

Stellen Sie sich einen 5-Mitglieder-Channel vor, in dem jeder an jeden anderen broadcastet: Eine einzelne Nachricht würde 20 Netzwerk-Round-Trips und 4 doppelte Kopien an jedem Mitglied erzeugen. Durch die Wahl eines Leaders macht nur dieses Gerät das Fan-out — der Sender führt das Fan-out selbst aus (wenn er der Leader ist) oder leitet eine einzelne Kopie an den Leader weiter, der dann fan-outet.

Falls der Leader offline ist, fällt der Sender auf Result.NoLeader zurück, löst Peer-Rediscovery aus (sodass die IP des Leaders gefunden werden kann) und cleared den Status, um den Nutzer erneut versuchen zu lassen.

Channel-Schlüssel-Routing

Channel-Nachrichten werden mit dem ChaCha20-Schlüssel des Channels verschlüsselt, nicht mit dem paarweisen Peer-Schlüssel. Das erlaubt es einem Mitglied, das die anderen Mitglieder nur über den Channel kennengelernt hat (nie 1-zu-1 gepaart), Nachrichten zu empfangen — seine peers-Zeile hat status="channel" und key="". Der Sender setzt den c-cid-HTTP-Header auf die Channel-ID; der Empfänger schlägt ChannelCacher.getKeyBytes(channelId) statt des paarweisen Schlüssels nach.

Per-Empfänger-Retry

Jedes sendToMember gibt ein DMessageDeliveryResult zurück. Das aggregierte DMessageStatusData wird als status_data-JSON des Chat-Items persistiert. Die UI zeigt „Zugestellt an Alice, Bob; Fehlgeschlagen für Carol" und erlaubt dem Nutzer, für Carol spezifisch auf Retry zu tippen — ChatManager.sendToChannelMembers führt sendToRecipients für die Retry-Teilmenge erneut aus und mergt die neuen Ergebnisse mit den bestehenden, wobei nur die retried Peers ersetzt werden.

Channel-Systemnachrichten {#channel-system-messages}

Channel-Control-Plane-Nachrichten (Invite, Accept, Decline, Update, Kick, Leave) werden über die Peer-GraphQL-Mutation channelSystemMessage ausgetauscht. Sie sind JSON-Nutzlasten, die über einen type-String getypt sind:

TypRichtungSigniert?Zweck
channel_inviteOwner → EingeladenerJaPeer einladen; trägt Channel-Schlüssel + Mitglieder.
channel_invite_acceptEingeladener → OwnerNeinAnnahme; trägt Public-Key des Annehmenden.
channel_invite_declineEingeladener → OwnerNeinAblehnung; Owner entfernt Mitglied.
channel_updateOwner → alle MitgliederJaMitglieds-/Namensänderungs-Broadcast.
channel_kickOwner → gekickter PeerJaGezielter Kick; auch bei Channel-Lösche broadcastet.
channel_leaveMitglied → OwnerNeinMitglied-initiierter Leave-Hinweis.

Signiertes Nutzlastformat

Die drei signierten Typen (invite, update, kick) verwenden einen kanonischen Pipe-getrennten String: "$channelId|$version|$action|$target", wobei action eines von invite, update, kick ist und target die ID des eingeladenen/gekickten Peers ist (leer für Broadcast-kick).

Der Owner signiert diesen String mit seinem Ed25519-Schlüssel. Empfänger lehnen jede Nachricht ab, bei der channel.owner != fromId ist, bevor überhaupt die Signatur geprüft wird, und lehnen ChannelUpdate-Nutzlasten ab, deren version der lokalen Version ist (Stale-Version-Guard gegen Out-of-Order-Zustellung).

Diagram 6
6

Lazy-Peer-Hydration

ChannelInvite und ChannelUpdate tragen eine memberPeers: List<MemberPeerInfo>-Liste — leichte Peer-Info (id, name, publicKey, deviceType, ip, port) für jedes Mitglied. Der ensureChannelPeer des Empfängers erstellt eine DPeer-Zeile mit status="channel" für jedes Mitglied, das er noch nie gesehen hat. Das ist kritisch, weil das Fan-out-Routing für jedes Mitglied den Peer-Eintrag benötigt, um Nachrichten zu senden.

Channel-Lebenszyklus {#channel-lifecycle}

Diagram 7
7

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

PeerTransportRouter ist eine Strategiekette mit Circuit Breaking. Die geordnete Liste der Transporte ist:

  1. LanTransport — erste Wahl. Verwendet OkHttp mit einem ChaCha20-Crypto-Interceptor über HTTPS. Wird komplett übersprungen, wenn peer.ip leer ist (Cross-Subnet-Peer, den wir noch nicht entdeckt haben).
  2. WifiAwareTransport (nur Android 13+) — verwendet Wi-Fi Aware (NAN) Data Paths. Fast-Skip, wenn das awareRunning-Flag des Peers false ist (vom BLE-Prewarmer-Scan aktualisiert). Die IPv6 des Peers wird über ein eigenes DNS aufgelöst, das den Hostnamen plain-aware-peer auf die Link-Local-Adresse mappt.
  3. BleTransport — garantierter Fallback für jeden gepaarten Peer. Streamt Chunked-RPC über GATT. Langsamer, aber funktioniert ohne jegliche IP-Konnektivität.

Diagram 8
8

Warum diese Reihenfolge?

  • LAN ist am schnellsten (ein einzelner HTTPS-Round-Trip, ~10 ms Timeout).
  • Wi-Fi Aware ist mittel (Data-Path-Setup ~5 s, danach ~10 ms Round Trips) und funktioniert Cross-Subnet (z.B. ein Gerät im Guest-Wi-Fi, ein anderes im IoT-Wi-Fi). So getunt, dass es schnell überspringt, wenn der Aware-Service des Peers nicht läuft, und ein 10-s-buildLink-Timeout vermieden wird.
  • BLE ist am langsamsten, funktioniert aber ohne jegliche IP-Konnektivität — selbst ohne Wi-Fi kommt die Nachricht durch. Verwendet als garantierter Fallback für gepaarte Peers.

Der Circuit Breaker sorgt dafür, dass ein unzuverlässiger Transport (insbesondere Wi-Fi Aware bei Network-Churn) nach 2 Fehlern für 30 s übersprungen wird, sodass der Fallback schnell stattfindet, statt auf wiederholte 10-s-Timeouts zu warten.

Wi-Fi Aware-Handshake

Die AwareSession macht einen Zwei-Nachrichten-Handshake, bevor ein Data Path geöffnet wird:

  • MSG_HELLO (Subscriber → Publisher): „Ich sehe dich, hier ist mein Peer-Handle."
  • MSG_READY (Publisher → Subscriber): „Ich habe meinen Network-Specifier registriert, du kannst jetzt requestNetwork aufrufen."

Das synchronisiert die connectivityManager.requestNetwork(...)-Aufrufe beider Seiten innerhalb des ~500-ms-Fensters des Android-Frameworks. Der Subscriber ist die Seite mit der kleineren clientId (deterministischer Role-Split — beide Seiten einigen sich ohne Koordination), und er besitzt die Retry-Schleife.

Peer-Status & Presence {#peer-status--presence}

Presence wird über langlebige WebSocket-Verbindungen verfolgt. Nur eine Seite jedes Paares öffnet den Socket — entschieden durch die deterministische Regel TempData.clientId < peer.id. Die andere Seite akzeptiert die eingehende Verbindung unter /peer_status.

Diagram 9
9

PeerCacher.onlineMap ist die Source of Truth für Presence. Es wird als onlinePeerIds: StateFlow<Set<String>> exponiert, das von der Channel- Leader-Wahl konsumiert wird (electLeader(onlinePeerIds, myId)).

Caching-Schicht {#caching-layer}

Zwei Caches spiegeln die Datenbanktabellen im Speicher wider und exponieren StateFlows, die Compose direkt sammelt:

Diagram 10
10

Warum Copy-on-Write?

Kotlins MutableStateFlow.distinctUntilChanged verwendet strukturelle Gleichheit. Wenn wir das DPeer in Place mutieren würden, enthielte die abgeleitete pairedPeers-Liste dieselbe DPeer-Referenz vorher und nachher, und distinctUntilChanged sähe keinen Unterschied und unterdrückte die Emission. Indem die Entity zuerst kopiert, die Kopie mutiert und der Map-Eintrag durch ein neues PeerRuntime/ChannelRuntime ersetzt wird, erhält die abgeleitete Liste eine neue Liste-aus-neuen-Referenzen, und der Flow feuert.

Datei-Downloads {#file-downloads}

Eingehende Datei-/Bildnachrichten werden automatisch von einem begrenzten Worker-Pool heruntergeladen. Jeder Download streamt durch den jeweils verfügbaren Transport (PeerTransportRouter.downloadFile) und schreibt in eine Temp-Datei, dann wird in den Media-Store der App importiert und das uri-Feld des Chat-Items gepatcht.

Diagram 11
11

Transport-agnostisches Streaming

Die Abstraktion DownloadedResponse(status, ByteReadChannel, onClose): AutoCloseable erlaubt es LAN und Wi-Fi Aware, den Live-HTTP-Body zu streamen, während BLE Chunked-RPC (16-KiB-Chunks via GET /fs?id=…&offset=…&length=…) durch denselben ByteReadChannel streamt. Der onClose-Callback erlaubt es BLE, seine Hintergrund-Download-Coroutine abzubrechen, wenn der Konsument die Antwort frühzeitig schließt (z.B. bei Pause).

Zusammenfassung der Design-Pattern {#design-patterns-recap}

PatternWoWarum
FassadeChatManagerSingle-Einstiegspunkt; Aufrufer fassen DB/Transport nie direkt an.
Strategy + Chain of Resp.PeerTransportRouter + LanTransport/WifiAwareTransport/BleTransportSteckbare Transporte mit TransportUnavailable als Fall-Through-Signal.
Circuit BreakerPeerCircuitBreaker2 Fehler / 30 s öffnen ein (Peer, Transport)-Leg, sodass Wi-Fi Aware den Fallback nicht blockiert.
State MachinePeerStatusManager.PeerState, AwarePeerLink.LinkStateExplizite Übergänge für Socket-Lebenszyklus und NDP-Link-Lebenszyklus.
Producer/Consumer + PoolDownloadQueue (3 Worker, Channel.BUFFERED)Begrenzte Nebenläufigkeit für Datei-Downloads.
Observer / ReactiveStateFlow überallCompose sammelt direkt; kein manuelles Refresh.
Replay-SchutzChatMessageReceiver.seenSignatures, PeerChatParser.MAX_TIMESTAMP_DIFF_MSDuplikate aus LAN+BLE-Dual-Delivery verwerfen; Out-of-Window-Zeitstempel ablehnen.
Exponentieller BackoffPeerStatusManager.scheduleReconnectmin(60 s, 1 s × 2^min(n-1, 6)) — gedeckelt bei 64 s.
Copy-on-WritePeerCacher.mutatePeer, ChannelCacher.mutateChannelZwingt StateFlow.distinctUntilChanged, bei jeder Mutation zu feuern.
Signierter UmschlagPeerGraphQLClient.buildSignedRequestsignature|timestamp|body — bindet Zeitstempel an Body, um Replay zu verhindern.
Deterministischer Role-SplitTempData.clientId < peer.idEntscheidet WebSocket-Client vs. Server und Wi-Fi Aware Subscriber vs. Publisher.
Lazy HydrationensureChannelPeer bei Invite/UpdateErstellt peers-Zeilen für ungesehene Channel-Mitglieder, damit Fan-out-Routing funktioniert.
Verschlüsselte IdentitätLANDiscoverManager.discoverSpecificDeviceGerichteter DISCOVER verschlüsselt Ziel-ID mit Peer-Schlüssel — nur das Ziel erkennt sie.

Weiterführende Literatur

  • Pairing-Ablauf — wie zwei Geräte Vertrauen aufbauen und den gemeinsamen ChaCha20-Schlüssel austauschen, den jeder Transport in diesem Artikel verwendet.
  • apitest/groups/chat-messages.sh und apitest/groups/chat-channels.sh — ausführbarer Testplan, der jede GraphQL-Mutation End-to-End testet.