Torna al blog
Transport15 min read

Design del Trasporto BLE — Messaggi e Download File

Questo articolo spiega come PlainApp invii messaggi di chat e scarichi file via Bluetooth Low Energy quando né LAN né Wi-Fi Aware sono disponibili. BLE è il fallback garantito: lento, ma funziona senza alcuna connettività IP. L'articolo tratta il wire format, il design a due livelli di chunking, come il traffico concorrente viene (o non viene) priorizzato, e perché ogni connessione viene abbattuta dopo ogni richiesta.

Per l'architettura chat più ampia che consuma questo trasporto, si veda Chat Architecture. Per come due dispositivi ottengano la chiave ChaCha20 condivisa usata per cifrare ogni payload BLE, si veda Pairing Flow.

Sommario

Perché un trasporto BLE? {#why-a-ble-transport-at-all}

PlainApp è serverless e offline-first. Il livello di trasporto è una catena di fallback ordinata: LAN → Wi-Fi Aware → BLE. LAN è il percorso felice (HTTPS su Wi-Fi, ~10 ms di round trip). Wi-Fi Aware copre i peer cross-subnet (SSIDs diversi, guest vs IoT VLANs). Entrambi richiedono una qualche connettività IP. BLE è l'unico trasporto che funziona:

  • Quando i dispositivi non sono affatto sulla stessa rete IP.
  • Quando il Wi-Fi è spento o in modalità aereo (la radio BLE è separata).
  • Quando Wi-Fi Aware non è supportato (Android < 13, tutte le varianti iOS di PlainApp).

BLE è lento — decine di KB/s, secondi di latenza per richiesta — ma è garantito per qualsiasi peer paired, perché l'unica cosa di cui ha bisogno è il clientId del peer, che viene sempre trasmesso nella scan response BLE.

Diagram 1
1

Layout del servizio GATT {#gatt-service-layout}

PlainApp pubblicizza un singolo servizio GATT custom con due caratteristiche. Non c'è UUID a 16 bit registrato — il servizio usa un UUID a 128 bit i cui byte finali ASCII-decodificano in plpai\x01:

Diagram 2
2

Perché due caratteristiche?

I due protocolli hanno modelli di fiducia e forme di payload completamente diversi:

  • NEARBY trasporta i messaggi di pairing. Arrivano prima che il peer sia paired (nessuna chiave condivisa ancora), quindi usano i propri payload JSON firmati Ed25519 con il proprio routing prefissato. Il corpo è una stringa semplice.
  • HTTP trasporta tutto il traffico post-pairing (chat, file, presenza). È sempre cifrato ChaCha20 con la chiave condivisa e usa lo stesso HttpRouteRegistry del server Ktor LAN, così gli handler di route (/peer_graphql, /fs, /peer_status) sono scritti una sola volta e riusati per entrambi i trasporti.

Perché notifiche invece di read?

Il protocollo BLE ATT limita una singola lettura di attributo a 512 byte. Una risposta GraphQL o un chunk di file da 16 KB possono essere molto più grandi. PlainApp risolve questo non usando mai readCharacteristic per dati reali — la onCharacteristicReadRequest del server ritorna un payload vuoto con GATT_SUCCESS. Invece, il client scrive la propria richiesta sulla caratteristica, e il server risponde inviando una sequenza di notifiche chunked che il client riassembla. Questo è documentato in BleDeviceApi.requestAsync, BleServerProtocol.handleWrite e AndroidBleGattServer.sendChunkedResponse.

Identificazione del peer: shortId, non MAC {#peer-identification-shortid-not-mac}

I pacchetti BLE advertising sono minuscoli (31 byte) e l'indirizzo MAC BLE è randomizzato da Android circa ogni 15 minuti — quindi non può essere usato come identificatore stabile. PlainApp trasmette invece un payload serviceData di 9 byte nella scan response:

Diagram 3
3

Perché un hash troncato invece del clientId completo?

Un clientId di 13 caratteri entrerebbe in 13 byte, ma PlainApp opta per uno SHA-256 troncato a 8 byte per due ragioni:

  1. Budget di byte stabile. 9 byte totali entrano comodamente nel payload advertising di 31 byte insieme al service UUID (16 byte), ai campi length e type (~27 byte usati, 4 byte di margine).
  2. Privacy. Un osservatore passivo che scansiona BLE non può recuperare il clientId dallo shortId (il prefisso di 8 byte di un hash SHA-256 è irreversibile in pratica). Può solo riconoscere un peer che ha già visto pubblicare lo stesso shortId — non può enumerare gli utenti PlainApp.

Il clientId completo è rivelato solo a un peer che si è effettivamente connesso via GATT e ha scambiato un DDiscoverReply — cioè un peer con cui l'utente ha già scelto di interagire.

Design del chunking a due livelli {#two-layer-chunking-design}

Questa è la parte più sottile del trasporto BLE, ed è essenziale capire entrambi i livelli perché hanno dimensioni e scopi completamente diversi:

Diagram 4
4

Perché 380 caratteri?

L'ATT MTU negoziato è 517 byte su Android (requestMtu(517) — il massimo consentito dalla specifica BLE) e ~185+ su iOS (auto-negotiato da CoreBluetooth). Sottraendo l'header ATT (~3 byte) e l'overhead del wrapper JSON di BleSegmentData ({"d":"...","s":N} aggiunge ~12 byte), 380 caratteri di payload entrano comodamente in un singolo ATT MTU su entrambe le piattaforme. Il valore è simmetrico (sia i frammenti di richiesta del client sia i frammenti di notifica del server usano 380), il che mantiene il codice semplice.

Perché 16 KiB per i chunk di file?

Un chunk di file da 16 KiB base64-encodato diventa ~22 KiB di JSON, che si frammenta in ~58 segmenti di notifica GATT. Ogni round trip di requestAsync richiede secondi via BLE, quindi chunk meno numerosi ma più grandi riducono l'overhead per chunk. Andare molto più grande rischierrebbe di colpire i timeout RPC BLE e produrre un feedback di progresso scadente (l'utente vede il progresso aggiornarsi solo una volta per chunk). 16 KiB è lo sweet spot sperimentalmente tarato — abbastanza grande per il throughput, abbastanza piccolo per una UI di progresso responsiva.

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

Ogni messaggio di chat BLE e ogni chunk di file è una chiamata a BleDeviceApi.requestAsync(service, requestData) — una suspend function che ritorna un BleResult. È sincrona dalla prospettiva del chiamante: una richiesta → una risposta completamente riassemblata, nessun pipelining.

Diagram 5
5

Invarianti chiave

  1. Una richiesta → una risposta. requestAsync è sincrona dalla prospettiva del chiamante — ritorna solo dopo che la risposta completa è stata riassemblata. Non c'è pipelining.
  2. Notifiche abilitate per chiamata. Il client scrive il CCCD all'inizio di ogni requestAsync e lo disabilita alla fine. Questo è dispendioso (due scritture GATT extra per chiamata) ma mantiene il protocollo stateless — il server non deve tracciare quali client stanno «ascoltando».
  3. Nessun retry dentro un RPC. Se una singola writeCharacteristic va in timeout (5 s), l'intero RPC viene abortito. Solo ensureConnected ritenta (3 tentativi su fallimento di connessione). Il backoff coarse a livello di trasporto è fornito da PeerCircuitBreaker, non dallo strato RPC.

Formato della envelope sul wire {#wire-envelope-format}

Il payload dentro i segmenti del Layer A è una envelope JSON annidata. Strippando la frammentazione, la struttura logica è:

Diagram 6
6

Forma della risposta

La risposta fluisce nella direzione opposta attraverso la stessa frammentazione del Layer A, ma il JSON interno è un BleHttpResponse con tre campi: s (codice di stato HTTP), h (mappa degli header di risposta) e b (body). Il body è sempre base64-encodato da BleHttpCall.encodeResponse(), anche quando vuoto — la risposta potrebbe essere binaria (byte GraphQL cifrati, byte grezzi di file /fs) e il trasporto BLE è string-only, così la stessa envelope JSON porta sia payload di testo sia binari.

Percorso di invio messaggi (end-to-end) {#message-send-path-end-to-end}

Mettendo tutto insieme — cosa succede quando un messaggio di chat viene inviato via BLE:

Diagram 7
7

Scelte di design notevoli

  • Stessa chiave della LAN. La chiave condivisa ChaCha20 del pairing è riusata per BLE — non c'è una chiave BLE separata. L'interceptor crypto OkHttp usato da LanTransport e le chaCha20Encrypt/chaCha20Decrypt manuali in BleTransport sono la stessa primitiva, solo invocata diversamente.
  • Stessi handler di route della LAN. BleHttpRequest è dispatchato attraverso HttpRouteRegistry.matchRoute(path), che è lo stesso registro usato dal server Ktor LAN. Così /peer_graphql, /fs, /peer_status ecc. sono implementati esattamente una volta e funzionano identicamente su entrambi i trasporti.
  • Nessun riuso di connessione. Il blocco finally { scanner.teardownConnection(client) } gira sempre. Ogni messaggio paga l'intero costo connect→discoverServices→MTU (~secondi). Questo è un compromesso deliberato — si veda Design Trade-offs.

Percorso di download file (end-to-end) {#file-download-path-end-to-end}

I download via BLE sono streaming — il file è letto in chunk da 16 KiB e scritto in un file temp man mano che arriva, così un file da 10 MB non richiede 10 MB di RAM. Il trucco è che l'RPC di ogni chunk è una chiamata requestAsync separata, e i chunk sono spinti in un ByteChannel che il consumer legge concorrentemente.

Diagram 8
8

Perché streaming invece di un unico grande RPC?

Un file da 10 MB inviato come singolo RPC significherebbe ~280 000 segmenti di notifica, tutti trattenuti in memoria su entrambi i lati prima che la risposta potesse anche solo iniziare — e l'intero trasferimento dovrebbe avere successo prima che venga riportato un progresso. Peggio, una singola notifica abbandonata a metà corromperebbe l'intero trasferimento.

Il design chunked ha tre vantaggi:

  1. Memoria costante. Solo un chunk da 16 KiB è in flight alla volta.
  2. Progresso live. DownloadQueue.notifyProgressUpdate() scatta ogni secondo, e la UI mostra una barra di download.
  3. Resilienza. Un chunk fallito può essere ritentato indipendentemente (la DownloadQueue supporta pause/riprendi/retry a livello di task; un fallimento a metà stream lascia il file temp parziale, anche se attualmente il downloader lo elimina su fallimento — si veda trade-offs).

Perché onClose cancella il job di download

La callback DownloadedResponse.onClose chiama downloadJob.cancel(). Questo è essenziale perché il loop di download gira in una child coroutine che altrimenti continuerebbe a girare per sempre se il consumer abbandonasse il canale in anticipo (ad es. l'utente ha toccato Pause). Il contratto AutoCloseable su DownloadedResponse significa che il blocco use { ... } del consumer invoca automaticamente onClose all'uscita, cancellando la coroutine di download BLE e abbattendo la connessione GATT nel blocco finally della coroutine.

Prioritizzazione: come la chat batte i file in pratica {#prioritization-how-chat-beats-files-in-practice}

Questa è la domanda più importante per qualsiasi applicazione di chat: quando un lento download di file via BLE è in corso, un nuovo messaggio di chat può passargli avanti?

La risposta onesta: non c'è schema di priorità esplicito

Non c'è campo priority, non c'è coda di priorità, non c'è preemption da nessuna parte nel codice BLE o nella coda di download. L'ho verificato con grep esaustivo — le uniche corrispondenze di priority in shared/src sono livelli di priorità di log e metadati EXIF, nulla relativo all'ordinamento messaggi-vs-download.

Quello che esiste invece è un insieme di separazioni architetturali che producono il comportamento desiderato come proprietà emergente:

Diagram 9
9

Perché funziona in pratica

La separazione che fa «sentire prioritizzata» la chat è strutturale:

  1. Gli invii di chat non passano da DownloadQueue. Sono emessi direttamente da PeerGraphQLClientPeerTransportRouterBleTransport.send. Quindi un messaggio di chat non si siede mai dietro una coda di download di file.
  2. Ogni chiamata BleTransport apre la propria connessione GATT. Un download di lunga durata che trattiene una connessione non impedisce a un invio di chat di aprire una seconda connessione allo stesso peer. Android supporta multiple connessioni GATT simultanee.
  3. Gli RPC di chat sono brevi. Un singolo messaggio di chat è un round trip di requestAsync (~1 s dopo la connessione). Anche se la radio è occupata con un download, l'invio della chat si completa entro pochi secondi.

Dove il design cede

I compromessi del «nessuna priorità esplicita»:

  • Latenza di connessione. Sia la chat sia il download pagano il costo connect→discover→MTU (~secondi) ogni volta, perché le connessioni non sono riusate. Un messaggio di chat che arriva durante un download non può fare piggyback sulla connessione esistente del download — ne apre una nuova.
  • Coda statica su Android. La operationQueue process-wide in AndroidBleGattClient serializza le operazioni GATT attraverso tutti i peer e tutte le connessioni. Quindi, mentre due connessioni GATT possono coesistere, le loro operazioni di write/read/notify sono intrecciate a livello di coda. In pratica va bene (ogni op è ~ms) ma è un sottile collo di bottiglia globale sotto alta concorrenza.
  • Nessuna preemption. Un download in corso non può essere messo in pausa per far passare un messaggio di chat. L'invio della chat gira semplicemente in concorrenza e compete per il tempo radio.

Un miglioramento futuro potrebbe essere un Mutex per-peer attorno a BleTransport.send e downloadFile, più un campo priority sulla coda — ma il design attuale si affida al fatto che gli RPC di chat sono abbastanza brevi che la contesa è raramente visibile all'utente.

Controllo della concorrenza e la coda GATT statica {#concurrency-control--the-static-gatt-queue}

Questa sezione merita un capitolo a sé perché è l'aspetto più sottile dell'implementazione BLE su Android.

Diagram 10
10

Perché statica (process-wide)?

Lo stack BLE di Android non consente operazioni GATT concorrenti su una singola istanza BluetoothGatt — chiamare writeCharacteristic mentre un'altra scrittura è in flight ritorna false e silenziosamente scarta la seconda scrittura. La workaround standard è una coda per-BluetoothGatt. PlainApp va un passo oltre e usa una coda process-wide (nel companion object), che è eccessivamente conservativa ma corretta: garantisce che nessuna operazione GATT in qualsiasi punto dell'app giri simultaneamente.

Il costo è che le operazioni write/read/notify di un lungo download file BLE vanno in coda dietro (e sono messe in coda dietro) le operazioni GATT di qualsiasi altro peer. Poiché ogni singola op è ~ms, questo è raramente un collo di bottiglia visibile all'utente — ma sotto traffico BLE concorrente pesante verso peer multipli, potrebbe diventarlo.

Nessun lock per-peer al livello di trasporto

BleDeviceApi.requestAsync è una plain suspend fun con nessun mutex, nessuna coda, nessuna serializzazione per-peer. Due chiamate concorrenti a BleTransport.send per lo stesso peer apriranno ciascuna la propria connessione GATT e procederanno indipendentemente. La serializzazione avviene implicitamente a livello di operazione GATT (tramite la coda statica su Android, o tramite await sequenziali su iOS).

Ciclo di vita della connessione e negoziazione MTU {#connection-lifecycle--mtu-negotiation}

Diagram 11
11

Perché requestMtu(517)?

L'ATT MTU di default è 23 byte (solo 20 byte di payload dopo l'header ATT di 3 byte). Con l'MTU di default, ogni segmento di 380 caratteri richiederebbe ~19 scritture GATT invece di 1 — un rallentamento di 19×. Richiedere l'MTU massimo consentito dalla specifica BLE (517 byte) permette ai segmenti di 380 caratteri di entrare in una singola operazione ATT, migliorando drasticamente il throughput.

iOS non espone un'API esplicita di richiesta MTU — CoreBluetooth la negozia automaticamente con il peripheral durante la connessione. I dispositivi iOS moderni tipicamente negoziano ~185 byte, che ancora contengono comodamente i segmenti di 380 caratteri (dopo aver sottratto l'header ATT + l'overhead del wrapper JSON).

Flow control per le notifiche {#flow-control-for-notifications}

Il server invia i frammenti di risposta come notifiche, ma le notifiche BLE non hanno flow control integrato — se il server invia notifiche più velocemente di quanto il controller possa trasmetterle, vengono silenziosamente scartate. PlainApp implementa un flow control esplicito basato su ack:

Diagram 12
12

Senza questo flow control, le notifiche back-to-back verrebbero silenziosamente scartate dal controller BLE quando la sua coda interna di invio si riempie — un problema noto di Android BLE documentato nei commenti dell'interfaccia BleGattServer. La regola per-device single-in-flight garantisce che ogni notifica sia o trasmessa o faccia scattare un timeout (che viene poi trattato come fallimento di trasporto).

Gestione degli errori: TransportUnavailable vs vero fallimento {#error-handling-transportunavailable-vs-real-failure}

TransportUnavailable è il segnale che dice al PeerTransportRouter di cadere sul trasporto successivo. Qualsiasi altra cosa è un vero fallimento ritornato al chiamante.

Diagram 13
13

La sottigliezza del fallimento del download

BleTransport.downloadFile ritorna DownloadedResponse(200, channel, onClose) immediatamente — il loop di download chunked gira in una coroutine in background che scrive sul canale. Se un RPC di un chunk fallisce a metà stream, il loop chiama channel.close(TransportUnavailable(...)), il che significa che il consumer (PeerFileDownloader.downloadAsync) vede l'errore come un'eccezione sollevata da channel.readAvailable(buf).

Questo significa che la chiamata PeerTransportRouter.downloadFile stessa è riuscita (ha ritornato un DownloadedResponse), quindi il circuit breaker non registra un fallimento per gli errori di download a metà stream. Solo i fallimenti a livello di connect e di scan sono catturati dal router. Questa è una scelta di design deliberata — un fallimento a metà stream non dovrebbe disabilitare permanentemente BLE per quel peer (il peer potrebbe essere solo uscito temporaneamente dal raggio d'azione).

Riferimento delle costanti chiave {#key-constants-reference}

CostanteValoreDoveScopo
BleDeviceApi.CHUNK_SIZE380Frammentazione segmenti GATTDimensione di ogni BleSegmentData.data (entra nell'ATT MTU dopo l'overhead JSON)
BleTransport.CHUNK_SIZE16 384 (16 KiB)Byte-range di download fileDimensione di ogni richiesta chunk /fs
BleTransport.SCAN_TIMEOUT_MS10 000Scansione BLETimeout per scanner.findOne
BleDeviceApi.NOTIFY_TIMEOUT_MS15 000Risposta RPCAttesa per-notifica in requestAsync
AndroidBleGattClient MTU517Setup connessionerequestMtu(517) — massimo consentito dalla specifica BLE
AndroidBleGattClient connect timeout10 000Setup connessioneAttesa per STATE_CONNECTED
AndroidBleGattClient MTU timeout5 000Setup connessioneAttesa per onMtuChanged
AndroidBleGattClient write timeout5 000Scrittura GATTAttesa per onCharacteristicWrite
AndroidBleGattClient read timeout10 000Lettura GATTAttesa per onCharacteristicRead (inutilizzato per dati reali)
AndroidBleGattClient notify-state timeout5 000Scrittura CCCDAttesa per la scrittura del descrittore CCCD
ensureConnected retries3Setup connessioneFino a 4 tentativi totali (0..3)
AndroidBleGattServer.NOTIFY_ACK_TIMEOUT_MS10 000Flow control notificheAttesa per onNotificationSent
AndroidBleGattServer notifyChunkSize380Frammentazione rispostaCome BleDeviceApi.CHUNK_SIZE
IosBleGattServer retry cap10Flow control notificheMax retry updateValue prima di arrendersi
PeerCircuitBreaker.WINDOW_MS30 000Circuit breaker del trasportoDurata di apertura dopo soglia
PeerCircuitBreaker.MAX_FAILURES2Circuit breaker del trasportoFallimenti entro finestra per aprire
DownloadQueue.MAX_CONCURRENT3Pool di worker downloadCoroutine di download concorrenti
BleServiceData.SHORT_ID_BYTES8Identificazione peerByte del prefisso SHA256 troncato
BleServiceData.PAYLOAD_BYTES9Identificazione peer1 byte di flags + 8 byte di shortId
BleSegmentData.STATE_START_BIT1Segnalazione EOF Layer APrimo segmento di un messaggio multi-segmento
BleSegmentData.STATE_END_BIT2Segnalazione EOF Layer AUltimo segmento (o segmento singolo)

Riepilogo dei compromessi di design {#design-trade-offs-recap}

Diagram 14
14

Letture aggiuntive

  • Chat Architecture — come BleTransport si inserisce nella catena di fallback LAN → Aware → BLE e nella pipeline più ampia di send/receive della chat.
  • Pairing Flow — come viene stabilita la chiave ChaCha20 condivisa usata da ogni payload BLE, e come la caratteristica NEARBY è usata per l'handshake di pairing.