Retour au blog
Transport15 min read

Conception du transport BLE — Messages et téléchargements de fichiers

Cet article explique comment PlainApp pousse les messages de chat et télécharge des fichiers via Bluetooth Low Energy lorsque ni le LAN ni Wi-Fi Aware ne sont disponibles. BLE est l'alternative garantie : lente, mais qui fonctionne sans aucune connectivité IP. L'article couvre le format filaire, la conception de découpage à deux couches, comment le trafic concurrent est (ou non) priorisé, et pourquoi chaque connexion est démontée après chaque requête.

Pour l'architecture de chat plus large qui consomme ce transport, voir Architecture du Chat. Pour la façon dont deux appareils obtiennent la clé ChaCha20 partagée utilisée pour chiffrer chaque charge utile BLE, voir Flux de jumelage.

Table des matières

Pourquoi un transport BLE ? {#why-a-ble-transport-at-all}

PlainApp est serverless et offline-first. La couche de transport est une chaîne de repli ordonnée : LAN → Wi-Fi Aware → BLE. LAN est le chemin nominal (HTTPS sur Wi-Fi, ~10 ms aller-retour). Wi-Fi Aware couvre les pairs sous-réseaux croisés (SSID différents, VLAN invité vs IoT). Les deux exigent une connectivité IP quelconque. BLE est le seul transport qui fonctionne :

  • Lorsque les appareils ne sont pas du tout sur le même réseau IP.
  • Lorsque le Wi-Fi est éteint ou en mode avion (la radio BLE est séparée).
  • Lorsque Wi-Fi Aware n'est pas supporté (Android < 13, toutes les variantes iOS de PlainApp).

BLE est lent — des dizaines de Ko/s, des secondes de latence par requête — mais il est garanti pour tout pair jumelé, car la seule chose dont il a besoin est le clientId du pair, qui est toujours diffusé dans la scan response BLE.

Diagram 1
1

Disposition du service GATT {#gatt-service-layout}

PlainApp annonce un service GATT personnalisé unique avec deux caractéristiques. Il n'y a pas d'UUID 16 bits enregistré — le service utilise un UUID 128 bits dont les octets de fin se décodent en ASCII comme plpai\x01 :

Diagram 2
2

Pourquoi deux caractéristiques ?

Les deux protocoles ont des modèles de confiance et des formes de charge utile complètement différents :

  • NEARBY transporte les messages de jumelage. Ils arrivent avant que le pair ne soit jumelé (pas encore de clé partagée), ils utilisent donc leurs propres charges utiles JSON signées Ed25519 avec leur propre routage par préfixe. Le corps est une simple chaîne.
  • HTTP transporte tout le trafic post-jumelage (chat, fichiers, présence). Il est toujours chiffré ChaCha20 avec la clé partagée et utilise le même HttpRouteRegistry que le serveur Ktor LAN, de sorte que les gestionnaires de route (/peer_graphql, /fs, /peer_status) sont écrits une fois et réutilisés pour les deux transports.

Pourquoi des notifications plutôt que des lectures ?

Le protocole ATT BLE limite une lecture d'attribut unique à 512 octets. Une réponse GraphQL ou un morceau de fichier de 16 Ko peut être bien plus volumineux. PlainApp contourne cela en n'utilisant jamais readCharacteristic pour les données réelles — le onCharacteristicReadRequest du serveur renvoie une charge utile vide avec GATT_SUCCESS. À la place, le client écrit sa requête dans la caractéristique, et le serveur répond en envoyant une séquence de notifications découpées que le client réassemble. C'est documenté dans BleDeviceApi.requestAsync, BleServerProtocol.handleWrite et AndroidBleGattServer.sendChunkedResponse.

Identification des pairs : shortId, pas MAC {#peer-identification-shortid-not-mac}

Les paquets d'advertisement BLE sont minuscules (31 octets) et l'adresse MAC BLE est randomisée par Android toutes les ~15 minutes — elle ne peut donc pas servir d'identifiant stable. PlainApp diffuse à la place une charge utile serviceData de 9 octets dans la scan response :

Diagram 3
3

Pourquoi un hachage tronqué plutôt que le clientId complet ?

Un clientId de 13 caractères tiendrait en 13 octets, mais PlainApp opte pour un SHA-256 tronqué de 8 octets pour deux raisons :

  1. Budget d'octets stable. 9 octets au total tiennent confortablement dans la charge utile d'advertisement de 31 octets à côté de l'UUID du service (16 octets), des champs de longueur et de type (~27 octets utilisés, 4 octets de marge).
  2. Confidentialité. Un observateur passif scannant BLE ne peut pas retrouver le clientId à partir du shortId (le préfixe de 8 octets d'un hachage SHA-256 est irréversible en pratique). Il ne peut que reconnaître un pair qu'il a déjà vu diffuser le même shortId — il ne peut pas énumérer les utilisateurs de PlainApp.

Le clientId complet n'est révélé qu'à un pair qui s'est réellement connecté via GATT et a échangé un DDiscoverReply — c.-à-d. un pair avec lequel l'utilisateur a déjà choisi d'interagir.

Découpage à deux couches {#two-layer-chunking-design}

C'est la partie la plus subtile du transport BLE, et il est essentiel de comprendre les deux couches car elles ont des tailles et des objectifs complètement différents :

Diagram 4
4

Pourquoi 380 caractères ?

Le MTU ATT négocié est de 517 octets sur Android (requestMtu(517) — le maximum autorisé par la spécification BLE) et ~185+ sur iOS (auto-négocié par CoreBluetooth). En soustrayant l'en-tête ATT (~3 octets) et l'enveloppe JSON de BleSegmentData ({"d":"...","s":N} ajoute ~12 octets), 380 caractères de charge utile tiennent confortablement dans un seul MTU ATT sur les deux plateformes. La valeur est symétrique (tant les fragments de requête client que les fragments de notification serveur utilisent 380), ce qui garde le code simple.

Pourquoi 16 Kio pour les morceaux de fichier ?

Un morceau de fichier de 16 Kio encode en base64 en ~22 Kio de JSON, ce qui se fragmente en ~58 segments de notification GATT. Chaque aller-retour requestAsync prend des secondes sur BLE, donc moins de morceaux mais plus gros réduit le surcoût par morceau. Aller beaucoup plus gros risquerait de heurter les délais d'attente RPC BLE et produire un mauvais retour de progression (l'utilisateur ne voit la progression se mettre à jour qu'une fois par morceau). 16 Kio est le point d'équilibre empiriquement réglé — assez grand pour le débit, assez petit pour une UI de progression réactive.

La primitive RPC : BleDeviceApi.requestAsync {#the-rpc-primitive-bledeviceapirequestasync}

Chaque message de chat BLE et chaque morceau de fichier est un appel à BleDeviceApi.requestAsync(service, requestData) — une fonction suspend qui renvoie un BleResult. Elle est synchrone du point de vue de l'appelant : une requête → une réponse entièrement réassemblée, pas de pipelining.

Diagram 5
5

Invariants clés

  1. Une requête → une réponse. requestAsync est synchrone du point de vue de l'appelant — elle ne renvoie qu'après que la réponse complète a été réassemblée. Il n'y a pas de pipelining.
  2. Notifications activées par appel. Le client écrit le CCCD au début de chaque requestAsync et le désactive à la fin. C'est coûteux (deux écritures GATT supplémentaires par appel) mais garde le protocole sans état — le serveur n'a pas à suivre quels clients « écoutent ».
  3. Pas de réessai dans un RPC. Si un seul writeCharacteristic dépasse le délai (5 s), tout le RPC avorte. Seul ensureConnected réessaie (3 tentatives en cas d'échec de connexion). Le backoff grossier au niveau transport est fourni par PeerCircuitBreaker, pas par la couche RPC.

Format de l'enveloppe filaire {#wire-envelope-format}

La charge utile à l'intérieur des segments de la couche A est une enveloppe JSON imbriquée. En ôtant la fragmentation, la structure logique est :

Diagram 6
6

Forme de la réponse

La réponse circule dans la direction opposée à travers la même fragmentation de la couche A, mais le JSON interne est un BleHttpResponse avec trois champs : s (code de statut HTTP), h (map des en-têtes de réponse) et b (corps). Le corps est toujours encodé en base64 par BleHttpCall.encodeResponse(), même lorsqu'il est vide — la réponse peut être binaire (octets GraphQL chiffrés, octets de fichier /fs bruts) et le transport BLE est chaîne uniquement, donc la même enveloppe JSON transporte des charges utiles texte et binaires.

Chemin d'envoi de message (de bout en bout) {#message-send-path-end-to-end}

En assemblant tout — ce qui se passe quand un message de chat est envoyé via BLE :

Diagram 7
7

Choix de conception notables

  • Même clé que LAN. La clé partagée ChaCha20 du jumelage est réutilisée pour BLE — il n'y a pas de clé BLE distincte. L'intercepteur crypto OkHttp utilisé par LanTransport et le chaCha20Encrypt/chaCha20Decrypt manuel dans BleTransport sont la même primitive, simplement invoqués différemment.
  • Mêmes gestionnaires de route que LAN. BleHttpRequest est dispatché via HttpRouteRegistry.matchRoute(path), qui est le même registre que celui utilisé par le serveur Ktor LAN. Donc /peer_graphql, /fs, /peer_status etc. sont implémentés exactement une fois et fonctionnent à l'identique sur les deux transports.
  • Pas de réutilisation de connexion. Le bloc finally { scanner.teardownConnection(client) } s'exécute toujours. Chaque message paie le coût complet connect→discoverServices→MTU (~secondes). C'est un compromis délibéré — voir Compromis de conception.

Chemin de téléchargement de fichier (de bout en bout) {#file-download-path-end-to-end}

Les téléchargements via BLE sont en streaming — le fichier est lu par morceaux de 16 Kio et écrit dans un fichier temporaire à mesure qu'il arrive, donc un fichier de 10 Mo n'a pas besoin de 10 Mo de RAM. L'astuce est que le RPC de chaque morceau est un appel requestAsync séparé, et les morceaux sont poussés dans un ByteChannel que le consommateur lit en parallèle.

Diagram 8
8

Pourquoi du streaming plutôt qu'un seul gros RPC ?

Un fichier de 10 Mo envoyé en un seul RPC signifierait ~280 000 segments de notification, tous conservés en mémoire des deux côtés avant même que la réponse ne puisse commencer — et le transfert entier devrait réussir avant qu'aucune progression ne soit rapportée. Pire, une notification perdue au milieu corromprait tout.

La conception par morceaux a trois avantages :

  1. Mémoire constante. Un seul morceau de 16 Kio est en vol à la fois.
  2. Progression en direct.DownloadQueue.notifyProgressUpdate() se déclenche chaque seconde, et l'UI affiche une barre de téléchargement.
  3. Résilience. Un morceau échoué peut être réessayé indépendamment (le DownloadQueue supporte pause/reprise/réessai au niveau de la tâche ; un échec en milieu de flux laisse le fichier temporaire partiel, bien que actuellement le téléchargeur le supprime en cas d'échec — voir les compromis).

Pourquoi onClose annule la tâche de téléchargement

Le callback DownloadedResponse.onClose appelle downloadJob.cancel(). C'est essentiel car la boucle de téléchargement tourne dans une coroutine enfant qui sans cela continuerait à tourner pour toujours si le consommateur abandonnait le canal prématurément (par ex. l'utilisateur a appuyé sur Pause). Le contrat AutoCloseable sur DownloadedResponse signifie que le bloc use { ... } du consommateur invoque automatiquement onClose à la sortie, annulant la coroutine de téléchargement BLE et démontant la connexion GATT dans le bloc finally de la coroutine.

Priorisation : comment le chat bat les fichiers en pratique {#prioritization-how-chat-beats-files-in-practice}

C'est la question la plus importante pour toute application de chat : lorsqu'un téléchargement de fichier BLE lent est en cours, un nouveau message de chat peut-il le devancer ?

La réponse honnête : il n'y a pas de schéma de priorité explicite

Il n'y a ni champ de priorité, ni file de priorité, ni préemption nulle part dans le code BLE ou la file de téléchargement. Je l'ai vérifié par grep exhaustif — les seules occurrences de priority dans shared/src sont des niveaux de priorité de log et des métadonnées EXIF, rien lié à l'ordre messages-vs-téléchargements.

Ce qui existe à la place est un ensemble de séparations architecturales qui produisent le comportement souhaité comme propriété émergente :

Diagram 9
9

Pourquoi ça marche en pratique

La séparation qui fait que le chat « semble priorisé » est structurelle :

  1. Les envois de chat ne passent pas par DownloadQueue. Ils sont émis directement par PeerGraphQLClientPeerTransportRouterBleTransport.send. Donc un message de chat ne se retrouve jamais derrière une file de téléchargements de fichiers.
  2. Chaque appel BleTransport ouvre sa propre connexion GATT. Un téléchargement longue durée tenant une connexion n'empêche pas un envoi de chat d'ouvrir une seconde connexion vers le même pair. Android supporte plusieurs connexions GATT simultanées.
  3. Les RPC de chat sont courts. Un seul message de chat est un aller- retour requestAsync (~1 s après connexion). Même si la radio est occupée avec un téléchargement, l'envoi du chat se termine en quelques secondes.

Où la conception pèche

Les compromis de « pas de priorité explicite » :

  • Latence de connexion. Le chat et le téléchargement paient tous deux le coût connect→discover→MTU (~secondes) à chaque fois, car les connexions ne sont pas réutilisées. Un message de chat arrivant pendant un téléchargement ne peut pas profiter de la connexion existante du téléchargement — il en ouvre une nouvelle.
  • File statique sur Android. La operationQueue à l'échelle du processus dans AndroidBleGattClient sérialise les opérations GATT à travers tous les pairs et toutes les connexions. Donc bien que deux connexions GATT puissent coexister, leurs opérations write/read/notify sont entrelacées au niveau de la file. En pratique c'est correct (chaque opération ~ms) mais c'est un goulet d'étranglement global subtil sous forte concurrence.
  • Pas de préemption. Un téléchargement en cours ne peut pas être mis en pause pour laisser passer un message de chat. L'envoi du chat tourne simplement en parallèle et entre en compétition pour le temps radio.

Une amélioration future pourrait être un Mutex par pair autour de BleTransport.send et downloadFile, plus un champ de priorité sur la file — mais la conception actuelle repose sur le fait que les RPC de chat sont assez courts pour que la contention soit rarement visible par l'utilisateur.

Contrôle de concurrence et la file GATT statique {#concurrency-control--the-static-gatt-queue}

Cela mérite sa propre section car c'est l'aspect le plus subtil de l'implémentation BLE Android.

Diagram 10
10

Pourquoi statique (à l'échelle du processus) ?

La pile BLE Android n'autorise pas les opérations GATT concurrentes sur une même instance BluetoothGatt — appeler writeCharacteristic pendant qu'une autre écriture est en cours renvoie false et abandonne silencieusement la seconde écriture. La solution standard est une file par BluetoothGatt. PlainApp va plus loin et utilise une file à l'échelle du processus (dans le companion object), ce qui est trop conservateur mais correct : cela garantit qu'aucune opération GATT, nulle part dans l'application, ne tourne simultanément.

Le coût est que les opérations write/read/notify d'un long téléchargement de fichier BLE se mettent derrière (et sont mises derrière) celles de tout autre pair. Comme chaque opération individuelle dure ~ms, c'est rarement un goulet visible — mais sous un trafic BLE concurrent lourd vers plusieurs pairs, ça pourrait le devenir.

Pas de verrou par pair à la couche transport

BleDeviceApi.requestAsync est une simple suspend fun sans mutex, sans file, aucune sérialisation par pair. Deux appels concurrents à BleTransport.send pour le même pair ouvriront chacun leur propre connexion GATT et procéderont indépendamment. La sérialisation se fait implicitement au niveau des opérations GATT (via la file statique sur Android, ou via des await séquentiels sur iOS).

Cycle de vie de la connexion et négociation MTU {#connection-lifecycle--mtu-negotiation}

Diagram 11
11

Pourquoi requestMtu(517) ?

Le MTU ATT par défaut est de 23 octets (seulement 20 octets de charge utile après l'en-tête ATT de 3 octets). Avec le MTU par défaut, chaque segment de 380 caractères nécessiterait ~19 écritures GATT au lieu de 1 — un ralentissement de 19×. Demander le MTU maximum autorisé par la spec BLE (517 octets) permet aux segments de 380 caractères de tenir dans une seule opération ATT, améliorant considérablement le débit.

iOS n'expose pas d'API explicite de demande de MTU — CoreBluetooth le négocie automatiquement avec le périphérique lors de la connexion. Les appareils iOS modernes négocient généralement ~185 octets, ce qui tient encore confortablement les segments de 380 caractères (après soustraction de l'en-tête ATT et de l'enveloppe JSON).

Contrôle de flux pour les notifications {#flow-control-for-notifications}

Le serveur envoie les fragments de réponse sous forme de notifications, mais les notifications BLE n'ont aucun contrôle de flux intégré — si le serveur envoie des notifications plus vite que le contrôleur ne peut les transmettre, elles sont silencieusement abandonnées. PlainApp implémente un contrôle de flux explicite basé sur des acks :

Diagram 12
12

Sans ce contrôle de flux, des notifications consécutives seraient silencieusement abandonnées par le contrôleur BLE lorsque sa file d'envoi interne se remplit — un problème BLE Android bien connu documenté dans les commentaires de l'interface BleGattServer. La règle d'un seul envoi en vol par appareil garantit que chaque notification est soit transmise, soit déclenche un délai d'attente (ensuite traité comme une défaillance de transport).

Gestion des erreurs : TransportUnavailable vs échec réel {#error-handling-transportunavailable-vs-real-failure}

TransportUnavailable est le signal qui indique à PeerTransportRouter de passer au transport suivant. Tout autre échec est une défaillance réelle renvoyée à l'appelant.

Diagram 13
13

La subtilité de l'échec de téléchargement

BleTransport.downloadFile renvoie DownloadedResponse(200, channel, onClose) immédiatement — la boucle de téléchargement par morceaux tourne dans une coroutine en arrière-plan qui écrit dans le canal. Si un RPC de morceau échoue en cours de flux, la boucle appelle channel.close(TransportUnavailable(...)), ce qui fait que le consommateur (PeerFileDownloader.downloadAsync) voit l'erreur comme une exception levée depuis channel.readAvailable(buf).

Cela signifie que l'appel PeerTransportRouter.downloadFile lui-même a réussi (renvoyé un DownloadedResponse), donc le disjoncteur n'enregistre pas d'échec pour les erreurs de téléchargement en cours de flux. Seuls les échecs à la connexion et au scan sont interceptés par le routeur. C'est un choix de conception délibéré — un échec en cours de flux ne devrait pas désactiver définitivement BLE pour ce pair (le pair a peut-être juste temporairement quitté la portée).

Référence des constantes clés {#key-constants-reference}

ConstanteValeurRôle
BleDeviceApi.CHUNK_SIZE380Fragmentation des segments GATTTaille de chaque BleSegmentData.data (tient dans le MTU ATT après l'enveloppe JSON)
BleTransport.CHUNK_SIZE16 384 (16 KiB)Plage d'octets de téléchargementTaille de chaque requête de morceau /fs
BleTransport.SCAN_TIMEOUT_MS10 000Scan BLEDélai d'attente pour scanner.findOne
BleDeviceApi.NOTIFY_TIMEOUT_MS15 000Réponse RPCAttente par notification dans requestAsync
AndroidBleGattClient MTU517Configuration de connexionrequestMtu(517) — maximum autorisé par la spec BLE
AndroidBleGattClient délai connexion10 000Configuration de connexionAttente de STATE_CONNECTED
AndroidBleGattClient délai MTU5 000Configuration de connexionAttente de onMtuChanged
AndroidBleGattClient délai écriture5 000Écriture GATTAttente de onCharacteristicWrite
AndroidBleGattClient délai lecture10 000Lecture GATTAttente de onCharacteristicRead (inutilisé pour les données réelles)
AndroidBleGattClient délai état notify5 000Écriture CCCDAttente de l'écriture du descripteur CCCD
ensureConnected réessais3Configuration de connexionJusqu'à 4 tentatives au total (0..3)
AndroidBleGattServer.NOTIFY_ACK_TIMEOUT_MS10 000Contrôle de flux des notificationsAttente de onNotificationSent
AndroidBleGattServer notifyChunkSize380Fragmentation de réponseIdentique à BleDeviceApi.CHUNK_SIZE
IosBleGattServer plafond de réessai10Contrôle de flux des notificationsMax updateValue réessais avant abandon
PeerCircuitBreaker.WINDOW_MS30 000Disjoncteur de transportDurée d'ouverture après seuil
PeerCircuitBreaker.MAX_FAILURES2Disjoncteur de transportÉchecs dans la fenêtre pour ouvrir
DownloadQueue.MAX_CONCURRENT3Pool de workers de téléchargementCoroutines de téléchargement concurrentes
BleServiceData.SHORT_ID_BYTES8Identification des pairsOctets du préfixe SHA256 tronqué
BleServiceData.PAYLOAD_BYTES9Identification des pairs1 octet de drapeaux + 8 octets shortId
BleSegmentData.STATE_START_BIT1Signalisation EOF couche APremier segment d'un message multi-segments
BleSegmentData.STATE_END_BIT2Signalisation EOF couche ADernier segment (ou segment unique)

Récapitulatif des compromis de conception {#design-trade-offs-recap}

Diagram 14
14

Pour aller plus loin

  • Architecture du Chat — comment BleTransport s'intègre dans la chaîne de repli LAN → Aware → BLE et le pipeline plus large d'envoi/réception de chat.
  • Flux de jumelage — comment la clé ChaCha20 partagée utilisée par chaque charge utile BLE est établie, et comment la caractéristique NEARBY est utilisée pour la poignée de main de jumelage.