Torna al blog
Architecture12 min read

Architettura Chat Peer e Canali

Questo articolo spiega come funziona end-to-end la chat offline-first di PlainApp: come un messaggio viaggia da un tap nella UI fino a un altro dispositivo via peer transport, come i canali di gruppo distribuiscono i messaggi a molti membri, e come il sistema rimane resiliente quando le reti spariscono. Il pairing (la fiducia e lo scambio di chiavi che bootstrap due dispositivi) è trattato nel separato articolo Pairing Flow.

Sommario

Architettura di alto livello {#high-level-architecture}

La chat di PlainApp è serverless. Ogni dispositivo fa girare un server HTTP Ktor embedded, e i dispositivi comunicano direttamente tra loro via rete locale, Wi-Fi Aware (NAN) o Bluetooth Low Energy. Non c'è server relay, nessuna inbox cloud, nessuna identità basata su numero di telefono. I dispositivi sono identificati da un clientId auto-generato e autenticati tramite un handshake Ed25519 + ECDH eseguito durante il pairing.

Esistono due tipi di conversazione:

TipoCostanteDescrizione
PEERChatTargetType.PEERChat diretta 1-a-1 tra due dispositivi paired.
CHANNELChatTargetType.CHANNELChat di gruppo multi-party di proprietà di un dispositivo; i membri fanno fan-out dei messaggi tra loro.

Una target speciale "local" è il blocco note del dispositivo stesso (note a se stessi) — inviarle è un no-op sul wire.

Mappa dei componenti

Diagram 1
1

L'architettura è volutamente a strati:

  1. Entry point UI / GraphQL non toccano mai trasporti o DB direttamente.
  2. ChatManager è una façade — ogni chiamante (UI, resolver GraphQL, receiver peer) passa attraverso di essa.
  3. ChatSender è un dispatcher che fa branching su ChatTargetType e delega ai sender peer o canale.
  4. Il livello di trasporto è una catena di strategie pluggabili con circuit breaking, così un link Wi-Fi Aware instabile non blocca mai un messaggio che potrebbe andare via BLE.

Modello dati {#data-model}

ChatTarget

La più piccola unità di routing è un ChatTarget — una coppia (toId, type) dove type è PEER o CHANNEL. Espone un encodedToId (peer:<id> o channel:<id>) che la UI usa come chiave di routing stabile (ad es. TempData.activeToId così il receiver sa se emettere una notifica), un check isLocal() (toId == "local"), e un companion parseId che ricostruisce la target da una stringa memorizzata.

Tabelle del database

Tutta la persistenza usa Room. Tre tabelle contano per la chat:

TabellaEntitàScopo
chatsDChatUna riga per messaggio (text / image / file).
chat_channelsDChatChannelUna riga per canale di gruppo.
peersDPeerUna riga per dispositivo noto (paired o solo canale).

Diagram 2
2

Alcune cose degne di nota:

  • L'identità è clientId, mai MAC. Android randomizza il MAC BLE su ogni connessione, quindi il database usa un id auto-generato stabile di 13 caratteri. Solo un prefisso SHA-256 di 8 byte (shortId) è trasmesso via BLE per consentire il discovery.
  • I peer con status="channel" sono membri di un canale con cui questo dispositivo non ha mai fatto direttamente pairing. La loro key è vuota — si autenticano usando la channel key invece di una chiave condivisa pairwise.
  • owner="me" è un sentinel che permette a un dispositivo appena installato di fare da owner prima che il suo clientId sia stabile; isOwnedByMe() accetta sia "me" sia TempData.clientId.

Superficie API GraphQL {#graphql-api-surface}

PlainApp espone due schemi GraphQL:

  1. Web GraphQL (addChatChannelSchema + addChatMessageSchema) — servito dal server Ktor locale alla UI del browser e all'harness apitest/. Autenticato da un token cifrato ChaCha20.
  2. Peer GraphQL (PeerGraphQLService.applyPeerSchema) — esposto su /peer_graphql per gli altri dispositivi sul trasporto peer cifrato. Autenticato da firma Ed25519 + cifratura del body ChaCha20.

I due schemi condividono gli stessi singleton di business logic (ChannelManager, ChatMessageReceiver, …) ma espongono superfici diverse perché il modello di fiducia differisce: la web GraphQL si fida della UI locale, mentre la peer GraphQL si fida solo di peer autenticati crittograficamente.

Superficie web GraphQL (chat)

Query: chatChannels (elenca tutti i canali), chatItems(id) (messaggi per una target — id è "local", peer:<id> o channel:<id>), e latestChatItems (anteprima across tutte le chat).

Mutation di chat: sendChatItem(toId, content), deleteChatItem(id), deleteChatItems(query), e retryChatItem(id).

Mutation di canale: createChatChannel(name), updateChatChannel(id, name), deleteChatChannel(id), leaveChatChannel(id), addChatChannelMember(id, peerId), removeChatChannelMember(id, peerId), acceptChatChannelInvite(id), e declineChatChannelInvite(id).

Superficie peer GraphQL (trasporto)

Esposta su /peer_graphql e autenticata da firma Ed25519 + cifratura del body ChaCha20. Solo tre mutation attraversano il confine di trasporto: createChatItem(content) (un messaggio peer in arrivo), channelSystemMessage(type, payload) (eventi del ciclo di vita del canale come invite/leave), e startAware (un sollecito che chiede al peer di avviare il proprio servizio Wi-Fi Aware così che un trasporto più veloce possa subentrare).

L'header HTTP c-id porta il clientId del mittente; l'header c-cid porta un id canale quando la richiesta è channel-scoped (così il receiver recupera la channel key invece della chiave peer pairwise per la decifratura).

Chat peer: inviare un messaggio {#peer-chat-sending-a-message}

Quando l'utente tocca Send in una conversazione peer, la catena di chiamata è:

Diagram 3
3

Gli invarianti chiave applicati a ogni hop:

  1. ChatManager.createChatItem inserisce sempre prima una riga, poi invia. Questo significa che la UI vede immediatamente un bubble «pending» e il messaggio sopravvive a crash dell'app anche se la consegna non è ancora avvenuta.
  2. PeerGraphQLClient.buildSignedRequest costruisce una envelope nella forma signature|timestamp|requestJson. La firma è Ed25519 su "$timestamp$requestJson", vincolando il timestamp al body così non può essere riprodotto con un timestamp fresco.
  3. PeerTransportRouter.send itera i trasporti in ordine Lan → WifiAware → Ble. Ogni trasporto può lanciare TransportUnavailable per lasciare che il router provi il successivo.
  4. Sul lato ricevente, PeerChatParser.decrypt verifica che il timestamp sia entro ±5 min e verifica la firma Ed25519 prima ancora che la mutation GraphQL venga eseguita.
  5. ChatMessageReceiver.receive mantiene un set seenSignatures keyed da "$fromPeerId|$signature|$timestamp" e lancia ReplayedMessageException sui duplicati — essenziale perché il trasporto può consegnare lo stesso payload due volte (LAN + BLE).

Se PeerChatSender.send ritorna una stringa di errore non-null, ChatSender chiama triggerPeerRediscovery(peerId), che emette un broadcast DISCOVER direzionato e cifrato così che il peer possa ri-annunciare il suo IP/port correnti.

Chat peer: ricevere un messaggio {#peer-chat-receiving-a-message}

Le richieste in inbound atterrano sulla route /peer_graphql del server Ktor locale, gestite da PeerGraphQLService:

Diagram 4
4

Notifiche

emitNotificationIfNeeded è il passo finale. Sopprime la notifica quando TempData.activeToId == targetId (cioè l'utente sta attualmente guardando quella conversazione) o quando canShowNotifications() è false. Le notifiche del canale sono prefissate con il nome del mittente.

Chat su canale: elezione del leader e fan-out {#channel-chat-leader-election--fan-out}

I canali sono multi-party ma serverless. Per evitare che ogni membro faccia fan-out dello stesso messaggio N volte, il lato sender elegge un singolo leader il cui compito è fare broadcast a tutti i membri joined.

Algoritmo di elezione del leader (DChatChannel.electLeader)

  1. Filtra i membri joined che sono attualmente online (il dispositivo locale è sempre considerato online).
  2. Se l'owner è tra i membri joined online → l'owner è il leader.
  3. Altrimenti, il leader è il membro joined online con il clientId più piccolo (tiebreak deterministico, nessuna coordinazione richiesta).
  4. Ritorna null se non esistono membri joined online.

Flusso di invio

Diagram 5
5

Perché un leader?

Si immagini un canale di 5 membri in cui tutti fanno broadcast a tutti gli altri: un singolo messaggio genererebbe 20 round trip di rete e 4 copie duplicate che arrivano a ciascun membro. Eleggendo un leader, solo quel dispositivo fa il fan-out — il sender o esegue il fan-out stesso (se è il leader) o ritrasmette una singola copia al leader, che poi fa fan-out.

Se il leader è offline, il sender ricade su Result.NoLeader, innesca il rediscovery del peer (così che l'IP del leader possa essere trovato), e pulisce lo status per lasciare che l'utente riprovi.

Routing della channel key

I messaggi del canale sono cifrati con la chiave ChaCha20 del canale, non la chiave peer pairwise. Questo è ciò che permette a un membro che ha incontrato gli altri membri solo via il canale (mai paired 1-a-1) di ricevere messaggi — la sua riga peers ha status="channel" e key="". Il sender imposta l'header HTTP c-cid all'id del canale; il receiver recupera ChannelCacher.getKeyBytes(channelId) invece della chiave pairwise.

Retry per-recipient

Ogni sendToMember ritorna un DMessageDeliveryResult. Il DMessageStatusData aggregato è persistito come JSON status_data del chat item. La UI mostra «Consegnato ad Alice, Bob; Fallito per Carol» e permette all'utente di toccare Retry specificamente per Carol — ChatManager.sendToChannelMembers esegue di nuovo sendToRecipients per il sottoinsieme di retry e fonde i nuovi risultati con quelli esistenti, sostituendo solo i peer ritentati.

Messaggi di sistema del canale {#channel-system-messages}

I messaggi di control-plane del canale (invite, accept, decline, update, kick, leave) sono scambiati via la mutation channelSystemMessage della peer GraphQL. Sono payload JSON tipizzati da una stringa type:

TypeDirezioneFirmato?Scopo
channel_inviteOwner → invitatoSìInvita un peer; porta channel key + members.
channel_invite_acceptInvitato → ownerNoAccettazione; porta la chiave pubblica dell'accettante.
channel_invite_declineInvitato → ownerNoDeclino; l'owner rimuove il membro.
channel_updateOwner → tutti i membriSìBroadcast di cambio membership/nome.
channel_kickOwner → peer kickatoSìKick mirato; anche broadcast su delete del canale.
channel_leaveMembro → ownerNoAvviso di leave inizializzato dal membro.

Formato del payload firmato

I tre tipi firmati (invite, update, kick) usano una stringa canonica pipe-delimited: "$channelId|$version|$action|$target", dove action è uno tra invite, update, kick, e target è l'id del peer invitato/kickato (vuoto per il kick in broadcast).

L'owner firma questa stringa con la propria chiave Ed25519. I receiver rifiutano qualsiasi messaggio in cui channel.owner != fromId prima ancora di controllare la firma, e rifiutano i payload ChannelUpdate il cui version è ≤ alla versione locale (guardia stale-version contro la consegna out-of-order).

Diagram 6
6

Hydration lazy del peer

ChannelInvite e ChannelUpdate portano una lista memberPeers: List<MemberPeerInfo> — info leggere sul peer (id, name, publicKey, deviceType, ip, port) per ogni membro. Il ensureChannelPeer del receiver crea una riga DPeer con status="channel" per qualsiasi membro che non abbia mai visto prima. Questo è critico perché il routing del fan-out ha bisogno del record peer di ogni membro per inviare messaggi.

Ciclo di vita del canale {#channel-lifecycle}

Diagram 7
7

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

PeerTransportRouter è una strategy chain con circuit breaking. La lista ordinata di trasporti è:

  1. LanTransport — prima scelta. Usa OkHttp con un interceptor crypto ChaCha20 su HTTPS. Saltato del tutto quando peer.ip è vuoto (peer cross-subnet non ancora scoperto).
  2. WifiAwareTransport (solo Android 13+) — usa data path Wi-Fi Aware (NAN). Fast-skip quando il flag awareRunning del peer è false (aggiornato dallo scan BLE prewarmer). L'IPv6 del peer è risolto via un DNS custom che mappa l'hostname plain-aware-peer all'indirizzo link-local.
  3. BleTransport — fallback garantito per qualsiasi peer paired. Streama RPC chunked su GATT. Più lento ma funziona senza alcuna connettività IP.

Diagram 8
8

Perché questo ordine?

  • LAN è la più veloce (singolo round trip HTTPS, ~10 ms di timeout).
  • Wi-Fi Aware è media (setup data-path ~5 s, poi ~10 ms round trip) e funziona cross-subnet (ad es. un dispositivo su guest Wi-Fi, un altro su IoT Wi-Fi). Tarato per saltare veloce quando il servizio Aware del peer non è in esecuzione, evitando un timeout buildLink di 10 s.
  • BLE è la più lenta ma funziona senza alcuna connettività IP — anche senza Wi-Fi, il messaggio passa comunque. Usata come fallback garantito per i peer paired.

Il circuit breaker garantisce che un trasporto instabile (specialmente Wi-Fi Aware durante network churn) venga saltato per 30 s dopo 2 fallimenti, così il fallback avviene rapidamente invece di attendere timeout di 10 s ripetuti.

Handshake Wi-Fi Aware

L'AwareSession fa un handshake a due messaggi prima di aprire un data path:

  • MSG_HELLO (subscriber → publisher): «Ti vedo, ecco il mio peer handle.»
  • MSG_READY (publisher → subscriber): «Ho registrato il mio network specifier, puoi fare requestNetwork ora.»

Questo sincronizza le chiamate connectivityManager.requestNetwork(...) di entrambe le parti entro la finestra di ~500 ms del framework Android. Il subscriber è il lato con il clientId più piccolo (split di ruolo deterministico — entrambe le parti si accordano senza coordinazione), ed è il proprietario del retry loop.

Stato peer e presenza {#peer-status--presence}

La presenza è tracciata via connessioni WebSocket long-lived. Solo un lato di ciascuna coppia apre il socket — deciso dalla regola deterministica TempData.clientId < peer.id. L'altro lato accetta la connessione inbound su /peer_status.

Diagram 9
9

PeerCacher.onlineMap è la fonte di verità per la presenza. È esposto come onlinePeerIds: StateFlow<Set<String>>, consumato dall'elezione del leader del canale (electLeader(onlinePeerIds, myId)).

Livello di caching {#caching-layer}

Due cache rispecchiano le tabelle del database in memoria ed espongono StateFlow che Compose colleziona direttamente:

Diagram 10
10

Perché copy-on-write?

MutableStateFlow.distinctUntilChanged di Kotlin usa l'uguaglianza strutturale. Se mutassimo il DPeer in place, la lista derivata pairedPeers conterrebbe il medesimo riferimento DPeer prima e dopo, e distinctUntilChanged non vedrebbe differenza e sopprimerebbe l'emissione. Copiando prima l'entità, mutando la copia, e sostituendo l'entry della mappa con un nuovo PeerRuntime/ChannelRuntime, la lista derivata ottiene una nuova lista-di-nuovi-riferimenti e il flow fa scattare l'emissione.

Download file {#file-downloads}

I messaggi con file/immagine in inbound sono scaricati automaticamente da un pool di worker bounded. Ogni download streama attraverso qualsiasi trasporto sia disponibile (PeerTransportRouter.downloadFile) e scrive su un file temp, poi importa nel media store dell'app e patcha il campo uri del chat item.

Diagram 11
11

Streaming agnostico al trasporto

L'astrazione DownloadedResponse(status, ByteReadChannel, onClose): AutoCloseable permette a LAN e Wi-Fi Aware di streamare il body HTTP live, mentre BLE streama RPC chunked (chunk da 16 KiB via GET /fs?id=…&offset=…&length=…) attraverso lo stesso ByteReadChannel. La callback onClose permette a BLE di cancellare la propria coroutine di download in background quando il consumer chiude la risposta in anticipo (ad es. su pausa).

Riepilogo dei design pattern {#design-patterns-recap}

PatternDovePerché
FaçadeChatManagerSingolo entry point; i chiamanti non toccano mai DB/trasporto direttamente.
Strategy + Chain of Resp.PeerTransportRouter + LanTransport/WifiAwareTransport/BleTransportTrasporti pluggabili con TransportUnavailable come segnale di fall-through.
Circuit BreakerPeerCircuitBreaker2 fail / 30 s apre una gamba (peer, transport) così Wi-Fi Aware non blocca il fallback.
State MachinePeerStatusManager.PeerState, AwarePeerLink.LinkStateTransizioni esplicite per il ciclo di vita del socket e del link NDP.
Producer/Consumer + PoolDownloadQueue (3 worker, Channel.BUFFERED)Concorrenza bounded per i download dei file.
Observer / ReactiveStateFlow ovunqueCompose colleziona direttamente; nessun refresh manuale.
Replay ProtectionChatMessageReceiver.seenSignatures, PeerChatParser.MAX_TIMESTAMP_DIFF_MSScarta duplicati dalla doppia consegna LAN+BLE; rifiuta timestamp out-of-window.
Exponential BackoffPeerStatusManager.scheduleReconnectmin(60 s, 1 s × 2^min(n-1, 6)) — capped a 64 s.
Copy-on-WritePeerCacher.mutatePeer, ChannelCacher.mutateChannelForza StateFlow.distinctUntilChanged a fare emissione su ogni mutazione.
Signed EnvelopePeerGraphQLClient.buildSignedRequestsignature|timestamp|body — vincola il timestamp al body per prevenire replay.
Deterministic Role SplitTempData.clientId < peer.idDecide WebSocket client vs server, e Wi-Fi Aware subscriber vs publisher.
Lazy HydrationensureChannelPeer su invite/updateCrea righe peers per membri del canale unseen così il routing del fan-out funziona.
Encrypted IdentityLANDiscoverManager.discoverSpecificDeviceDISCOVER direzionato cifra la target id con la chiave del peer — solo la target la riconosce.

Letture aggiuntive

  • Pairing Flow — come due dispositivi stabiliscono fiducia e scambiano la chiave ChaCha20 condivisa usata da ogni trasporto in questo articolo.
  • apitest/groups/chat-messages.sh e apitest/groups/chat-channels.sh — piano di test eseguibile che esercita ogni mutation GraphQL end-to-end.