Table des matières
- Architecture générale
- Modèle de données
- Surface de l'API GraphQL
- Chat pair : envoi d'un message
- Chat pair : réception d'un message
- Chat par canal : élection du leader et diffusion
- Messages système de canal
- Cycle de vie d'un canal
- Couche de transport pair (LAN → Wi-Fi Aware → BLE)
- Statut pair et présence
- Couche de cache
- Téléchargements de fichiers
- Récapitulatif des patterns de conception
Architecture générale {#high-level-architecture}
Le chat PlainApp est serverless. Chaque appareil fait tourner un serveur HTTP Ktor embarqué, et les appareils communiquent directement entre eux sur le réseau local, Wi-Fi Aware (NAN), ou Bluetooth Low Energy. Il n'y a ni serveur relais, ni boîte de réception cloud, ni identité basée sur un numéro de téléphone. Les appareils sont identifiés par un clientId auto-généré et authentifiés via une poignée de main Ed25519 + ECDH effectuée lors du jumelage.
Deux types de conversations existent :
| Type | Constante | Description |
|---|---|---|
PEER | ChatTargetType.PEER | Chat direct 1-à-1 entre deux appareils jumelés. |
CHANNEL | ChatTargetType.CHANNEL | Chat de groupe multi-parties possédé par un appareil ; les membres diffusent les messages entre eux. |
Une cible spéciale "local" est le bloc-notes de l'appareil lui-même (notes
personnelles) — lui envoyer est un no-op sur le fil.
Carte des composants
L'architecture est intentionnellement en couches :
- Les points d'entrée UI / GraphQL ne touchent jamais directement aux transports ni à la DB.
ChatManagerest une façade — chaque appelant (UI, résolveur GraphQL, récepteur pair) passe par lui.ChatSenderest un dispatcher qui se ramifie selonChatTargetTypeet délègue aux émetteurs pair ou canal.- La couche transport est une chaîne de stratégies enfichables avec disjoncteur, de sorte qu'une liaison Wi-Fi Aware instable ne bloque jamais un message qui pourrait passer par BLE.
Modèle de données {#data-model}
ChatTarget
La plus petite unité de routage est un ChatTarget — une paire
(toId, type) où type vaut PEER ou CHANNEL. Il expose un encodedToId
(peer:<id> ou channel:<id>) que l'UI utilise comme clé de routage stable
(par ex. TempData.activeToId pour que le récepteur sache s'il doit émettre
une notification), un contrôle isLocal() (toId == "local"), et un companion
parseId qui reconstruit la cible depuis une chaîne stockée.
Tables de la base de données
Toute la persistance utilise Room. Trois tables comptent pour le chat :
| Table | Entité | Rôle |
|---|---|---|
chats | DChat | Une ligne par message (texte / image / fichier). |
chat_channels | DChatChannel | Une ligne par canal de groupe. |
peers | DPeer | Une ligne par appareil connu (jumelé ou canal uniquement). |
Quelques points à noter :
- L'identité est
clientId, jamais MAC. Android randomise le MAC BLE à chaque connexion, donc la base utilise un id auto-généré stable de 13 caractères. Seul un préfixe SHA-256 de 8 octets (shortId) est diffusé via BLE pour permettre la découverte. - Les pairs
status="channel"sont membres d'un canal avec lequel cet appareil ne s'est jamais directement jumelé. Leurkeyest vide — ils s'authentifient en utilisant la clé de canal plutôt qu'une clé partagée par paire. owner="me"est un sentinel qui permet à un appareil fraîchement installé d'agir comme propriétaire avant que sonclientIdne soit stable ;isOwnedByMe()accepte à la fois"me"etTempData.clientId.
Surface de l'API GraphQL {#graphql-api-surface}
PlainApp expose deux schémas GraphQL :
- Web GraphQL (
addChatChannelSchema+addChatMessageSchemadansshared/src/commonMain/kotlin/com/ismartcoding/plain/httpserver/) — servi par le serveur Ktor local à l'UI navigateur et au harnaisapitest/. Authentifié par un jeton chiffré ChaCha20. - Peer GraphQL (
PeerGraphQLService.applyPeerSchema) — exposé sur/peer_graphqlpour les autres appareils via le transport pair chiffré. Authentifié par signature Ed25519 + chiffrement du corps ChaCha20.
Les deux schémas partagent les mêmes singletons métier (ChannelManager,
ChatMessageReceiver, …) mais exposent des surfaces différentes car le
modèle de confiance diffère : le web GraphQL fait confiance à l'UI
locale, tandis que le peer GraphQL ne fait confiance qu'aux pairs
cryptographiquement authentifiés.
Surface Web GraphQL (chat)
Requêtes : chatChannels (liste tous les canaux), chatItems(id) (messages
pour une cible — id est "local", peer:<id>, ou channel:<id>), et
latestChatItems (aperçu sur tous les chats).
Mutations de chat : sendChatItem(toId, content), deleteChatItem(id),
deleteChatItems(query), et retryChatItem(id).
Mutations de canal : createChatChannel(name),
updateChatChannel(id, name), deleteChatChannel(id),
leaveChatChannel(id), addChatChannelMember(id, peerId),
removeChatChannelMember(id, peerId), acceptChatChannelInvite(id), et
declineChatChannelInvite(id).
Surface Peer GraphQL (transport)
Exposée sur /peer_graphql et authentifiée par signature Ed25519 +
chiffrement du corps ChaCha20. Seules trois mutations franchissent la
frontière du transport : createChatItem(content) (un message pair entrant),
channelSystemMessage(type, payload) (événements de cycle de vie de canal
comme inviter/quitter), et startAware (une incitation demandant au pair de
démarrer son service Wi-Fi Aware afin qu'un transport plus rapide puisse
prendre le relais).
L'en-tête HTTP c-id porte le clientId de l'expéditeur ; l'en-tête c-cid
porte un id de canal lorsque la requête est à portée de canal (le récepteur
utilise alors la clé de canal plutôt que la clé de pair pour le
déchiffrement).
Chat pair : envoi d'un message {#peer-chat-sending-a-message}
Quand l'utilisateur appuie sur Envoyer dans une conversation pair, la chaîne d'appels est :
Les invariants clés appliqués à chaque saut :
ChatManager.createChatIteminsère toujours une ligne d'abord, puis envoie. Cela signifie que l'UI voit immédiatement une bulle « en attente » et que le message survit aux crashes de l'application même si la livraison n'a pas encore eu lieu.PeerGraphQLClient.buildSignedRequestconstruit une enveloppe de la formesignature|timestamp|requestJson. La signature est Ed25519 sur"$timestamp$requestJson", liant l'horodatage au corps pour qu'il ne puisse pas être rejoué avec un horodatage neuf.PeerTransportRouter.senditère les transports dans l'ordreLan → WifiAware → Ble. Chaque transport peut leverTransportUnavailablepour laisser le routeur essayer le suivant.- Du côté réception,
PeerChatParser.decryptvérifie que l'horodatage est dans±5 minet vérifie la signature Ed25519 avant même que la mutation GraphQL ne soit exécutée. ChatMessageReceiver.receiveconserve un setseenSignaturesindexé par"$fromPeerId|$signature|$timestamp"et lèveReplayedMessageExceptionsur les doublons — essentiel car le transport peut livrer la même charge utile deux fois (LAN + BLE).
Si PeerChatSender.send renvoie une chaîne d'erreur non nulle, ChatSender
appelle triggerPeerRediscovery(peerId), qui émet un broadcast DISCOVER
dirigé et chiffré pour que le pair puisse réannoncer son IP/port courant.
Chat pair : réception d'un message {#peer-chat-receiving-a-message}
Les requêtes entrantes arrivent sur la route /peer_graphql du serveur Ktor
local, gérées par PeerGraphQLService :
Notifications
emitNotificationIfNeeded est l'étape finale. Elle supprime la notification
lorsque TempData.activeToId == targetId (c.-à-d. l'utilisateur regarde
actuellement cette conversation) ou lorsque canShowNotifications() est
faux. Les notifications de canal sont préfixées du nom de l'expéditeur.
Chat par canal : élection du leader et diffusion {#channel-chat-leader-election--fan-out}
Les canaux sont multi-parties mais serverless. Pour éviter que chaque membre ne diffuse le même message N fois, le côté émetteur élit un seul leader dont le rôle est de diffuser à tous les membres joints.
Algorithme d'élection du leader (DChatChannel.electLeader)
- Filtrer sur les membres joints actuellement en ligne (l'appareil local est toujours considéré en ligne).
- Si le propriétaire est parmi les membres joints en ligne → le propriétaire est le leader.
- Sinon, le leader est le membre joint en ligne au plus petit
clientId(départage déterministe, aucune coordination requise). - Renvoie
nulls'il n'y a aucun membre joint en ligne.
Flux d'envoi
Pourquoi un leader ?
Imaginez un canal de 5 membres où tout le monde diffuse à tout le monde : un seul message générerait 20 allers-retours réseau et 4 copies en doublon arrivant chez chaque membre. En élisant un seul leader, seul cet appareil fait la diffusion — l'expéditeur effectue lui-même la diffusion (s'il est le leader) ou relaie une seule copie au leader, qui diffuse ensuite.
Si le leader est hors ligne, l'expéditeur se rabat sur Result.NoLeader,
déclenche la redécouverte du pair (pour que l'IP du leader puisse être
trouvée), et efface le statut pour laisser l'utilisateur réessayer.
Routage par clé de canal
Les messages de canal sont chiffrés avec la clé ChaCha20 du canal, pas
avec la clé de pair. C'est ce qui permet à un membre qui n'a rencontré les
autres membres que via le canal (jamais jumelé 1-à-1) de recevoir des
messages — sa ligne peers a status="channel" et key="". L'expéditeur
définit l'en-tête HTTP c-cid à l'id du canal ; le récepteur consulte
ChannelCacher.getKeyBytes(channelId) au lieu de la clé par paire.
Réessai par destinataire
Chaque sendToMember renvoie un DMessageDeliveryResult. Le
DMessageStatusData agrégé est persisté comme JSON status_data de
l'élément de chat. L'UI affiche « Livré à Alice, Bob ; Échec pour Carol » et
permet à l'utilisateur d'appuyer sur Réessayer spécifiquement pour Carol
— ChatManager.sendToChannelMembers réexécute sendToRecipients sur le
sous-ensemble de réessai et fusionne les nouveaux résultats avec ceux
existants, en remplaçant uniquement les pairs réessayés.
Messages système de canal {#channel-system-messages}
Les messages de plan de contrôle de canal (invite, accept, decline, update,
kick, leave) sont échangés via la mutation peer GraphQL channelSystemMessage.
Ce sont des charges utiles JSON typées par une chaîne type :
| Type | Direction | Signé ? | Rôle |
|---|---|---|---|
channel_invite | Propriétaire → invité | Oui | Inviter un pair ; porte la clé du canal + les membres. |
channel_invite_accept | Invité → propriétaire | Non | Acceptation ; porte la clé publique de l'accepteur. |
channel_invite_decline | Invité → propriétaire | Non | Refus ; le propriétaire supprime le membre. |
channel_update | Propriétaire → tous les membres | Oui | Diffusion de changement de membre/nom. |
channel_kick | Propriétaire → pair exclu | Oui | Exclusion ciblée ; aussi diffusée à la suppression du canal. |
channel_leave | Membre → propriétaire | Non | Avis de départ initié par le membre. |
Format de la charge utile signée
Les trois types signés (invite, update, kick) utilisent une chaîne
canonique séparée par des tuyaux : "$channelId|$version|$action|$target",
où action est l'un de invite, update, kick, et target est l'id du
pair invité/exclu (vide pour le kick en diffusion).
Le propriétaire signe cette chaîne avec sa clé Ed25519. Les récepteurs
rejettent tout message où channel.owner != fromId avant même de
vérifier la signature, et rejettent les charges ChannelUpdate dont la
version est ≤ à la version locale (garde contre les versions périmées
pour les livraisons hors séquence).
Hydratation paresseuse des pairs
ChannelInvite et ChannelUpdate transportent une liste
memberPeers: List<MemberPeerInfo> — informations légères sur les pairs
(id, name, publicKey, deviceType, ip, port) pour chaque membre. Le
ensureChannelPeer du récepteur crée une ligne DPeer avec
status="channel" pour tout membre qu'il n'a jamais vu. C'est critique car
le routage de diffusion a besoin de l'enregistrement pair de chaque membre
pour envoyer des messages.
Cycle de vie d'un canal {#channel-lifecycle}
Couche de transport pair (LAN → Wi-Fi Aware → BLE) {#peer-transport-layer-lan--wi-fi-aware--ble}
PeerTransportRouter est une chaîne de stratégies avec disjoncteur. La
liste ordonnée des transports est :
LanTransport— premier choix. Utilise OkHttp avec un intercepteur crypto ChaCha20 sur HTTPS. Entièrement ignoré lorsquepeer.ipest vide (pair sous-réseau croisé que nous n'avons pas encore découvert).WifiAwareTransport(Android 13+ uniquement) — utilise les chemins de données Wi-Fi Aware (NAN). Ignoré rapidement quand le drapeauawareRunningdu pair est faux (rafraîchi par le scan BLE de préchauffage). L'IPv6 du pair est résolu via un DNS personnalisé qui mappe le nom d'hôteplain-aware-peerà l'adresse link-local.BleTransport— alternative garantie pour tout pair jumelé. Transmet des RPC découpés en streaming sur GATT. Plus lent mais fonctionne sans aucune connectivité IP.
Pourquoi cet ordre ?
- LAN est le plus rapide (un seul aller-retour HTTPS, délai d'attente ~10 ms).
- Wi-Fi Aware est moyen (mise en place du data-path ~5 s, puis ~10 ms
allers-retours) et fonctionne sous-réseau croisé (par ex. un appareil sur
Wi-Fi invité, un autre sur Wi-Fi IoT). Réglé pour sauter rapidement quand
le service Aware du pair ne tourne pas, évitant un délai d'attente
buildLinkde 10 s. - BLE est le plus lent mais fonctionne sans aucune connectivité IP — même sans Wi-Fi, le message passe. Utilisé comme alternative garantie pour les pairs jumelés.
Le disjoncteur garantit qu'un transport instable (particulièrement Wi-Fi Aware pendant les remous réseau) est ignoré pendant 30 s après 2 échecs, de sorte que le repli se fait rapidement au lieu d'attendre des délais d'attente de 10 s répétés.
Poignée de main Wi-Fi Aware
AwareSession fait une poignée de main en deux messages avant d'ouvrir un
data path :
MSG_HELLO(abonné → éditeur) : « Je te vois, voici mon peer handle. »MSG_READY(éditeur → abonné) : « J'ai enregistré mon network specifier, tu peuxrequestNetworkmaintenant. »
Ceci synchronise les appels connectivityManager.requestNetwork(...) des
deux côtés dans la fenêtre de ~500 ms du framework Android. L'abonné est
le côté au plus petit clientId (séparation de rôles déterministe — les deux
parties sont d'accord sans coordination), et il possède la boucle de réessai.
Statut pair et présence {#peer-status--presence}
La présence est suivie via des connexions WebSocket longue durée. Une
seule partie de chaque paire ouvre le socket — décidée par la règle
déterministe TempData.clientId < peer.id. L'autre partie accepte la
connexion entrante sur /peer_status.
PeerCacher.onlineMap est la source de vérité de la présence. Il est exposé
comme onlinePeerIds: StateFlow<Set<String>>, consommé par l'élection du
leader de canal (electLeader(onlinePeerIds, myId)).
Couche de cache {#caching-layer}
Deux caches reflètent les tables de la base de données en mémoire et
exposent des StateFlow que Compose collecte directement :
Pourquoi de la copie à l'écriture ?
Le MutableStateFlow.distinctUntilChanged de Kotlin utilise l'égalité
structurelle. Si nous notions le DPeer en place, la liste dérivée
pairedPeers contiendrait la même référence DPeer avant et après, et
distinctUntilChanged ne verrait aucune différence et supprimerait
l'émission. En copiant l'entité d'abord, en mutant la copie, puis en
remplaçant l'entrée de la map par un nouveau
PeerRuntime/ChannelRuntime, la liste dérivée obtient une nouvelle liste
de nouvelles références et le flux se déclenche.
Téléchargements de fichiers {#file-downloads}
Les messages entrants de fichier/image sont téléchargés automatiquement par
un pool de workers borné. Chaque téléchargement traverse le transport
disponible (PeerTransportRouter.downloadFile) en streaming et écrit dans un
fichier temporaire, puis importe dans le media store de l'application et
patche le champ uri de l'élément de chat.
Streaming indépendant du transport
L'abstraction
DownloadedResponse(status, ByteReadChannel, onClose): AutoCloseable permet
à LAN et Wi-Fi Aware de streamer le corps HTTP en direct, tandis que BLE
stream les RPC découpés (morceaux de 16 Kio via
GET /fs?id=…&offset=…&length=…) à travers le même ByteReadChannel. Le
callback onClose permet à BLE d'annuler sa coroutine de téléchargement en
arrière-plan lorsque le consommateur ferme la réponse prématurément (par ex.
sur pause).
Récapitulatif des patterns de conception {#design-patterns-recap}
| Pattern | Où | Pourquoi |
|---|---|---|
| Façade | ChatManager | Point d'entrée unique ; les appelants ne touchent jamais directement DB/transport. |
| Stratégie + Chaîne de responsabilité | PeerTransportRouter + LanTransport/WifiAwareTransport/BleTransport | Transports enfichables avec TransportUnavailable comme signal de repli. |
| Disjoncteur | PeerCircuitBreaker | 2 échecs / 30 s ouvrent une jambe (pair, transport) pour que Wi-Fi Aware ne bloque pas le repli. |
| Machine à états | PeerStatusManager.PeerState, AwarePeerLink.LinkState | Transitions explicites pour le cycle de vie du socket et du lien NDP. |
| Producteur/Consommateur + Pool | DownloadQueue (3 workers, Channel.BUFFERED) | Concurrence bornée pour les téléchargements de fichiers. |
| Observateur / Réactif | StateFlow partout | Compose collecte directement ; pas de rafraîchissement manuel. |
| Protection anti-rejeu | ChatMessageReceiver.seenSignatures, PeerChatParser.MAX_TIMESTAMP_DIFF_MS | Abandonne les doublons de la double livraison LAN+BLE ; rejette les horodatages hors fenêtre. |
| Backoff exponentiel | PeerStatusManager.scheduleReconnect | min(60 s, 1 s × 2^min(n-1, 6)) — plafond à 64 s. |
| Copie à l'écriture | PeerCacher.mutatePeer, ChannelCacher.mutateChannel | Force StateFlow.distinctUntilChanged à se déclencher à chaque mutation. |
| Enveloppe signée | PeerGraphQLClient.buildSignedRequest | signature|timestamp|body — lie l'horodatage au corps pour empêcher le rejeu. |
| Séparation de rôle déterministe | TempData.clientId < peer.id | Décide client vs serveur WebSocket, et abonné vs éditeur Wi-Fi Aware. |
| Hydratation paresseuse | ensureChannelPeer sur invite/update | Crée des lignes peers pour les membres de canal inconnus afin que le routage de diffusion fonctionne. |
| Identité chiffrée | LANDiscoverManager.discoverSpecificDevice | Le DISCOVER dirigé chiffre l'id cible avec la clé du pair — seule la cible le reconnaît. |
Pour aller plus loin
- Flux de jumelage — comment deux appareils établissent la confiance et échangent la clé ChaCha20 partagée utilisée par chaque transport de cet article.
apitest/groups/chat-messages.shetapitest/groups/chat-channels.sh— plan de test exécutable qui exerce chaque mutation GraphQL de bout en bout.