Retour au blog
Architecture12 min read

Architecture du chat pair-à-pair et par canaux

Cet article explique comment fonctionne le chat offline-first de PlainApp de bout en bout : comment un message voyage depuis une pression dans l'UI jusqu'à un autre appareil via le transport pair, comment les canaux de groupe diffusent les messages à de nombreux membres, et comment le système reste résilient lorsque les réseaux disparaissent. Le jumelage (l'échange de confiance et de clés qui amorce deux appareils) est traité dans l'article séparé Flux de jumelage.

Table des matières

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 :

TypeConstanteDescription
PEERChatTargetType.PEERChat direct 1-à-1 entre deux appareils jumelés.
CHANNELChatTargetType.CHANNELChat 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

Diagram 1
1

L'architecture est intentionnellement en couches :

  1. Les points d'entrée UI / GraphQL ne touchent jamais directement aux transports ni à la DB.
  2. ChatManager est une façade — chaque appelant (UI, résolveur GraphQL, récepteur pair) passe par lui.
  3. ChatSender est un dispatcher qui se ramifie selon ChatTargetType et délègue aux émetteurs pair ou canal.
  4. 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 :

TableEntitéRôle
chatsDChatUne ligne par message (texte / image / fichier).
chat_channelsDChatChannelUne ligne par canal de groupe.
peersDPeerUne ligne par appareil connu (jumelé ou canal uniquement).

Diagram 2
2

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é. Leur key est 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 son clientId ne soit stable ; isOwnedByMe() accepte à la fois "me" et TempData.clientId.

Surface de l'API GraphQL {#graphql-api-surface}

PlainApp expose deux schémas GraphQL :

  1. Web GraphQL (addChatChannelSchema + addChatMessageSchema dans shared/src/commonMain/kotlin/com/ismartcoding/plain/httpserver/) — servi par le serveur Ktor local à l'UI navigateur et au harnais apitest/. Authentifié par un jeton chiffré ChaCha20.
  2. Peer GraphQL (PeerGraphQLService.applyPeerSchema) — exposé sur /peer_graphql pour 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 :

Diagram 3
3

Les invariants clés appliqués à chaque saut :

  1. ChatManager.createChatItem insè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.
  2. PeerGraphQLClient.buildSignedRequest construit une enveloppe de la forme signature|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.
  3. PeerTransportRouter.send itère les transports dans l'ordre Lan → WifiAware → Ble. Chaque transport peut lever TransportUnavailable pour laisser le routeur essayer le suivant.
  4. Du côté réception, PeerChatParser.decrypt vérifie que l'horodatage est dans ±5 min et vérifie la signature Ed25519 avant même que la mutation GraphQL ne soit exécutée.
  5. ChatMessageReceiver.receive conserve un set seenSignatures indexé par "$fromPeerId|$signature|$timestamp" et lève ReplayedMessageException sur 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 :

Diagram 4
4

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)

  1. Filtrer sur les membres joints actuellement en ligne (l'appareil local est toujours considéré en ligne).
  2. Si le propriétaire est parmi les membres joints en ligne → le propriétaire est le leader.
  3. Sinon, le leader est le membre joint en ligne au plus petit clientId (départage déterministe, aucune coordination requise).
  4. Renvoie null s'il n'y a aucun membre joint en ligne.

Flux d'envoi

Diagram 5
5

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 :

TypeDirectionSigné ?Rôle
channel_invitePropriétaire → invitéOuiInviter un pair ; porte la clé du canal + les membres.
channel_invite_acceptInvité → propriétaireNonAcceptation ; porte la clé publique de l'accepteur.
channel_invite_declineInvité → propriétaireNonRefus ; le propriétaire supprime le membre.
channel_updatePropriétaire → tous les membresOuiDiffusion de changement de membre/nom.
channel_kickPropriétaire → pair excluOuiExclusion ciblée ; aussi diffusée à la suppression du canal.
channel_leaveMembre → propriétaireNonAvis 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).

Diagram 6
6

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}

Diagram 7
7

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 :

  1. LanTransport — premier choix. Utilise OkHttp avec un intercepteur crypto ChaCha20 sur HTTPS. Entièrement ignoré lorsque peer.ip est vide (pair sous-réseau croisé que nous n'avons pas encore découvert).
  2. WifiAwareTransport (Android 13+ uniquement) — utilise les chemins de données Wi-Fi Aware (NAN). Ignoré rapidement quand le drapeau awareRunning du 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ôte plain-aware-peer à l'adresse link-local.
  3. 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.

Diagram 8
8

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 buildLink de 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 peux requestNetwork maintenant. »

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.

Diagram 9
9

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 :

Diagram 10
10

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.

Diagram 11
11

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}

PatternOùPourquoi
FaçadeChatManagerPoint d'entrée unique ; les appelants ne touchent jamais directement DB/transport.
Stratégie + Chaîne de responsabilitéPeerTransportRouter + LanTransport/WifiAwareTransport/BleTransportTransports enfichables avec TransportUnavailable comme signal de repli.
DisjoncteurPeerCircuitBreaker2 échecs / 30 s ouvrent une jambe (pair, transport) pour que Wi-Fi Aware ne bloque pas le repli.
Machine à étatsPeerStatusManager.PeerState, AwarePeerLink.LinkStateTransitions explicites pour le cycle de vie du socket et du lien NDP.
Producteur/Consommateur + PoolDownloadQueue (3 workers, Channel.BUFFERED)Concurrence bornée pour les téléchargements de fichiers.
Observateur / RéactifStateFlow partoutCompose collecte directement ; pas de rafraîchissement manuel.
Protection anti-rejeuChatMessageReceiver.seenSignatures, PeerChatParser.MAX_TIMESTAMP_DIFF_MSAbandonne les doublons de la double livraison LAN+BLE ; rejette les horodatages hors fenêtre.
Backoff exponentielPeerStatusManager.scheduleReconnectmin(60 s, 1 s × 2^min(n-1, 6)) — plafond à 64 s.
Copie à l'écriturePeerCacher.mutatePeer, ChannelCacher.mutateChannelForce StateFlow.distinctUntilChanged à se déclencher à chaque mutation.
Enveloppe signéePeerGraphQLClient.buildSignedRequestsignature|timestamp|body — lie l'horodatage au corps pour empêcher le rejeu.
Séparation de rôle déterministeTempData.clientId < peer.idDécide client vs serveur WebSocket, et abonné vs éditeur Wi-Fi Aware.
Hydratation paresseuseensureChannelPeer sur invite/updateCrée des lignes peers pour les membres de canal inconnus afin que le routage de diffusion fonctionne.
Identité chiffréeLANDiscoverManager.discoverSpecificDeviceLe 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.sh et apitest/groups/chat-channels.sh — plan de test exécutable qui exerce chaque mutation GraphQL de bout en bout.