Inhaltsverzeichnis
- High-Level-Architektur
- Datenmodell
- GraphQL-API-Oberfläche
- Peer-Chat: Senden einer Nachricht
- Peer-Chat: Empfangen einer Nachricht
- Channel-Chat: Leader-Wahl & Fan-Out
- Channel-Systemnachrichten
- Channel-Lebenszyklus
- Peer-Transportebene (LAN → Wi-Fi Aware → BLE)
- Peer-Status & Presence
- Caching-Schicht
- Datei-Downloads
- Zusammenfassung der Design-Pattern
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:
| Typ | Konstante | Beschreibung |
|---|---|---|
PEER | ChatTargetType.PEER | 1-zu-1-Direktchat zwischen zwei gepaarten Geräten. |
CHANNEL | ChatTargetType.CHANNEL | Mehrparteien-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
Die Architektur ist bewusst geschichtet:
- UI-/GraphQL-Einstiegspunkte fassen Transporte oder DB nie direkt an.
ChatManagerist eine Fassade — jeder Aufrufer (UI, GraphQL-Resolver, Peer-Empfänger) geht darüber.ChatSenderist ein Dispatcher, der je nachChatTargetTypeverzweigt und an die Peer- oder Channel-Sender delegiert.- 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:
| Tabelle | Entity | Zweck |
|---|---|---|
chats | DChat | Eine Zeile pro Nachricht (Text / Bild / Datei). |
chat_channels | DChatChannel | Eine Zeile pro Gruppen-Channel. |
peers | DPeer | Eine Zeile pro bekanntem Gerät (gepaart oder nur Channel). |
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. Ihrkeyist 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 seineclientIdstabil ist;isOwnedByMe()akzeptiert sowohl"me"als auchTempData.clientId.
GraphQL-API-Oberfläche {#graphql-api-surface}
PlainApp exponiert zwei GraphQL-Schemata:
- Web-GraphQL (
addChatChannelSchema+addChatMessageSchema) — vom lokalen Ktor-Server an die Browser-UI und dasapitest/-Harness ausgeliefert. Authentifiziert durch einen ChaCha20-verschlüsselten Token. - Peer-GraphQL (
PeerGraphQLService.applyPeerSchema) — exponiert unter/peer_graphqlfü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:
Die wichtigsten Invarianten, die an jedem Hop durchgesetzt werden:
ChatManager.createChatItemfü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.PeerGraphQLClient.buildSignedRequestbaut einen Umschlag der Formsignature|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.PeerTransportRouter.senditeriert Transporte in der ReihenfolgeLan → WifiAware → Ble. Jeder Transport kannTransportUnavailablewerfen, um den Router den nächsten versuchen zu lassen.- Auf der Empfangsseite prüft
PeerChatParser.decrypt, ob der Zeitstempel innerhalb von±5 minliegt, und verifiziert die Ed25519-Signatur, bevor die GraphQL-Mutation überhaupt ausgeführt wird. ChatMessageReceiver.receivehält einseenSignatures-Set, das über"$fromPeerId|$signature|$timestamp"geschlüsselt ist, und wirftReplayedMessageExceptionbei 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:
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)
- Filtere auf beigetretene Mitglieder, die aktuell online sind (das lokale Gerät gilt immer als online).
- Falls der Owner unter den online beigetretenen Mitgliedern ist → der Owner ist der Leader.
- Andernfalls ist der Leader das online beigetretene Mitglied mit der
kleinsten
clientId(deterministischer Tiebreak, keine Koordination erforderlich). - Gibt
nullzurück, falls es keine online beigetretenen Mitglieder gibt.
Sende-Fluss
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:
| Typ | Richtung | Signiert? | Zweck |
|---|---|---|---|
channel_invite | Owner → Eingeladener | Ja | Peer einladen; trägt Channel-Schlüssel + Mitglieder. |
channel_invite_accept | Eingeladener → Owner | Nein | Annahme; trägt Public-Key des Annehmenden. |
channel_invite_decline | Eingeladener → Owner | Nein | Ablehnung; Owner entfernt Mitglied. |
channel_update | Owner → alle Mitglieder | Ja | Mitglieds-/Namensänderungs-Broadcast. |
channel_kick | Owner → gekickter Peer | Ja | Gezielter Kick; auch bei Channel-Lösche broadcastet. |
channel_leave | Mitglied → Owner | Nein | Mitglied-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).
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}
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:
LanTransport— erste Wahl. Verwendet OkHttp mit einem ChaCha20-Crypto-Interceptor über HTTPS. Wird komplett übersprungen, wennpeer.ipleer ist (Cross-Subnet-Peer, den wir noch nicht entdeckt haben).WifiAwareTransport(nur Android 13+) — verwendet Wi-Fi Aware (NAN) Data Paths. Fast-Skip, wenn dasawareRunning-Flag des Peers false ist (vom BLE-Prewarmer-Scan aktualisiert). Die IPv6 des Peers wird über ein eigenes DNS aufgelöst, das den Hostnamenplain-aware-peerauf die Link-Local-Adresse mappt.BleTransport— garantierter Fallback für jeden gepaarten Peer. Streamt Chunked-RPC über GATT. Langsamer, aber funktioniert ohne jegliche IP-Konnektivität.
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 jetztrequestNetworkaufrufen."
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.
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:
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.
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}
| Pattern | Wo | Warum |
|---|---|---|
| Fassade | ChatManager | Single-Einstiegspunkt; Aufrufer fassen DB/Transport nie direkt an. |
| Strategy + Chain of Resp. | PeerTransportRouter + LanTransport/WifiAwareTransport/BleTransport | Steckbare Transporte mit TransportUnavailable als Fall-Through-Signal. |
| Circuit Breaker | PeerCircuitBreaker | 2 Fehler / 30 s öffnen ein (Peer, Transport)-Leg, sodass Wi-Fi Aware den Fallback nicht blockiert. |
| State Machine | PeerStatusManager.PeerState, AwarePeerLink.LinkState | Explizite Übergänge für Socket-Lebenszyklus und NDP-Link-Lebenszyklus. |
| Producer/Consumer + Pool | DownloadQueue (3 Worker, Channel.BUFFERED) | Begrenzte Nebenläufigkeit für Datei-Downloads. |
| Observer / Reactive | StateFlow überall | Compose sammelt direkt; kein manuelles Refresh. |
| Replay-Schutz | ChatMessageReceiver.seenSignatures, PeerChatParser.MAX_TIMESTAMP_DIFF_MS | Duplikate aus LAN+BLE-Dual-Delivery verwerfen; Out-of-Window-Zeitstempel ablehnen. |
| Exponentieller Backoff | PeerStatusManager.scheduleReconnect | min(60 s, 1 s × 2^min(n-1, 6)) — gedeckelt bei 64 s. |
| Copy-on-Write | PeerCacher.mutatePeer, ChannelCacher.mutateChannel | Zwingt StateFlow.distinctUntilChanged, bei jeder Mutation zu feuern. |
| Signierter Umschlag | PeerGraphQLClient.buildSignedRequest | signature|timestamp|body — bindet Zeitstempel an Body, um Replay zu verhindern. |
| Deterministischer Role-Split | TempData.clientId < peer.id | Entscheidet WebSocket-Client vs. Server und Wi-Fi Aware Subscriber vs. Publisher. |
| Lazy Hydration | ensureChannelPeer bei Invite/Update | Erstellt peers-Zeilen für ungesehene Channel-Mitglieder, damit Fan-out-Routing funktioniert. |
| Verschlüsselte Identität | LANDiscoverManager.discoverSpecificDevice | Gerichteter 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.shundapitest/groups/chat-channels.sh— ausführbarer Testplan, der jede GraphQL-Mutation End-to-End testet.