Sommario
- Architettura di alto livello
- Modello dati
- Superficie API GraphQL
- Chat peer: inviare un messaggio
- Chat peer: ricevere un messaggio
- Chat su canale: elezione del leader e fan-out
- Messaggi di sistema del canale
- Ciclo di vita del canale
- Livello di trasporto peer (LAN → Wi-Fi Aware → BLE)
- Stato peer e presenza
- Livello di caching
- Download file
- Riepilogo dei design pattern
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:
| Tipo | Costante | Descrizione |
|---|---|---|
PEER | ChatTargetType.PEER | Chat diretta 1-a-1 tra due dispositivi paired. |
CHANNEL | ChatTargetType.CHANNEL | Chat 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
L'architettura è volutamente a strati:
- Entry point UI / GraphQL non toccano mai trasporti o DB direttamente.
ChatManagerè una façade — ogni chiamante (UI, resolver GraphQL, receiver peer) passa attraverso di essa.ChatSenderè un dispatcher che fa branching suChatTargetTypee delega ai sender peer o canale.- 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:
| Tabella | Entità | Scopo |
|---|---|---|
chats | DChat | Una riga per messaggio (text / image / file). |
chat_channels | DChatChannel | Una riga per canale di gruppo. |
peers | DPeer | Una riga per dispositivo noto (paired o solo canale). |
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 lorokeyè 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 suoclientIdsia stabile;isOwnedByMe()accetta sia"me"siaTempData.clientId.
Superficie API GraphQL {#graphql-api-surface}
PlainApp espone due schemi GraphQL:
- Web GraphQL (
addChatChannelSchema+addChatMessageSchema) — servito dal server Ktor locale alla UI del browser e all'harnessapitest/. Autenticato da un token cifrato ChaCha20. - Peer GraphQL (
PeerGraphQLService.applyPeerSchema) — esposto su/peer_graphqlper 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 è:
Gli invarianti chiave applicati a ogni hop:
ChatManager.createChatIteminserisce 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.PeerGraphQLClient.buildSignedRequestcostruisce una envelope nella formasignature|timestamp|requestJson. La firma è Ed25519 su"$timestamp$requestJson", vincolando il timestamp al body così non può essere riprodotto con un timestamp fresco.PeerTransportRouter.senditera i trasporti in ordineLan → WifiAware → Ble. Ogni trasporto può lanciareTransportUnavailableper lasciare che il router provi il successivo.- Sul lato ricevente,
PeerChatParser.decryptverifica che il timestamp sia entro±5 mine verifica la firma Ed25519 prima ancora che la mutation GraphQL venga eseguita. ChatMessageReceiver.receivemantiene un setseenSignatureskeyed da"$fromPeerId|$signature|$timestamp"e lanciaReplayedMessageExceptionsui 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:
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)
- Filtra i membri joined che sono attualmente online (il dispositivo locale è sempre considerato online).
- Se l'owner è tra i membri joined online → l'owner è il leader.
- Altrimenti, il leader è il membro joined online con il
clientIdpiù piccolo (tiebreak deterministico, nessuna coordinazione richiesta). - Ritorna
nullse non esistono membri joined online.
Flusso di invio
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:
| Type | Direzione | Firmato? | Scopo |
|---|---|---|---|
channel_invite | Owner → invitato | Sì | Invita un peer; porta channel key + members. |
channel_invite_accept | Invitato → owner | No | Accettazione; porta la chiave pubblica dell'accettante. |
channel_invite_decline | Invitato → owner | No | Declino; l'owner rimuove il membro. |
channel_update | Owner → tutti i membri | Sì | Broadcast di cambio membership/nome. |
channel_kick | Owner → peer kickato | Sì | Kick mirato; anche broadcast su delete del canale. |
channel_leave | Membro → owner | No | Avviso 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).
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}
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 è:
LanTransport— prima scelta. Usa OkHttp con un interceptor crypto ChaCha20 su HTTPS. Saltato del tutto quandopeer.ipè vuoto (peer cross-subnet non ancora scoperto).WifiAwareTransport(solo Android 13+) — usa data path Wi-Fi Aware (NAN). Fast-skip quando il flagawareRunningdel peer è false (aggiornato dallo scan BLE prewarmer). L'IPv6 del peer è risolto via un DNS custom che mappa l'hostnameplain-aware-peerall'indirizzo link-local.BleTransport— fallback garantito per qualsiasi peer paired. Streama RPC chunked su GATT. Più lento ma funziona senza alcuna connettività IP.
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
buildLinkdi 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 farerequestNetworkora.»
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.
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:
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.
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}
| Pattern | Dove | Perché |
|---|---|---|
| Façade | ChatManager | Singolo entry point; i chiamanti non toccano mai DB/trasporto direttamente. |
| Strategy + Chain of Resp. | PeerTransportRouter + LanTransport/WifiAwareTransport/BleTransport | Trasporti pluggabili con TransportUnavailable come segnale di fall-through. |
| Circuit Breaker | PeerCircuitBreaker | 2 fail / 30 s apre una gamba (peer, transport) così Wi-Fi Aware non blocca il fallback. |
| State Machine | PeerStatusManager.PeerState, AwarePeerLink.LinkState | Transizioni esplicite per il ciclo di vita del socket e del link NDP. |
| Producer/Consumer + Pool | DownloadQueue (3 worker, Channel.BUFFERED) | Concorrenza bounded per i download dei file. |
| Observer / Reactive | StateFlow ovunque | Compose colleziona direttamente; nessun refresh manuale. |
| Replay Protection | ChatMessageReceiver.seenSignatures, PeerChatParser.MAX_TIMESTAMP_DIFF_MS | Scarta duplicati dalla doppia consegna LAN+BLE; rifiuta timestamp out-of-window. |
| Exponential Backoff | PeerStatusManager.scheduleReconnect | min(60 s, 1 s × 2^min(n-1, 6)) — capped a 64 s. |
| Copy-on-Write | PeerCacher.mutatePeer, ChannelCacher.mutateChannel | Forza StateFlow.distinctUntilChanged a fare emissione su ogni mutazione. |
| Signed Envelope | PeerGraphQLClient.buildSignedRequest | signature|timestamp|body — vincola il timestamp al body per prevenire replay. |
| Deterministic Role Split | TempData.clientId < peer.id | Decide WebSocket client vs server, e Wi-Fi Aware subscriber vs publisher. |
| Lazy Hydration | ensureChannelPeer su invite/update | Crea righe peers per membri del canale unseen così il routing del fan-out funziona. |
| Encrypted Identity | LANDiscoverManager.discoverSpecificDevice | DISCOVER 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.sheapitest/groups/chat-channels.sh— piano di test eseguibile che esercita ogni mutation GraphQL end-to-end.