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?
- Layout del servizio GATT
- Identificazione del peer: shortId, non MAC
- Design del chunking a due livelli
- La primitiva RPC:
BleDeviceApi.requestAsync - Formato della envelope sul wire
- Percorso di invio messaggi (end-to-end)
- Percorso di download file (end-to-end)
- Prioritizzazione: come la chat batte i file in pratica
- Controllo della concorrenza e la coda GATT statica
- Ciclo di vita della connessione e negoziazione MTU
- Flow control per le notifiche
- Gestione degli errori: TransportUnavailable vs vero fallimento
- Riferimento delle costanti chiave
- Riepilogo dei compromessi di design
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.
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:
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
HttpRouteRegistrydel 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:
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:
- 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).
- 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:
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.
Invarianti chiave
- Una richiesta → una risposta.
requestAsyncè sincrona dalla prospettiva del chiamante — ritorna solo dopo che la risposta completa è stata riassemblata. Non c'è pipelining. - Notifiche abilitate per chiamata. Il client scrive il CCCD all'inizio di
ogni
requestAsynce 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». - Nessun retry dentro un RPC. Se una singola
writeCharacteristicva in timeout (5 s), l'intero RPC viene abortito. SoloensureConnectedritenta (3 tentativi su fallimento di connessione). Il backoff coarse a livello di trasporto è fornito daPeerCircuitBreaker, 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 è:
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:
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
LanTransporte lechaCha20Encrypt/chaCha20Decryptmanuali inBleTransportsono la stessa primitiva, solo invocata diversamente. - Stessi handler di route della LAN.
BleHttpRequestè dispatchato attraversoHttpRouteRegistry.matchRoute(path), che è lo stesso registro usato dal server Ktor LAN. Così/peer_graphql,/fs,/peer_statusecc. 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.
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:
- Memoria costante. Solo un chunk da 16 KiB è in flight alla volta.
- Progresso live.
DownloadQueue.notifyProgressUpdate()scatta ogni secondo, e la UI mostra una barra di download. - Resilienza. Un chunk fallito può essere ritentato indipendentemente (la
DownloadQueuesupporta 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:
Perché funziona in pratica
La separazione che fa «sentire prioritizzata» la chat è strutturale:
- Gli invii di chat non passano da
DownloadQueue. Sono emessi direttamente daPeerGraphQLClient→PeerTransportRouter→BleTransport.send. Quindi un messaggio di chat non si siede mai dietro una coda di download di file. - Ogni chiamata
BleTransportapre 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. - 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
operationQueueprocess-wide inAndroidBleGattClientserializza 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.
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}
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:
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.
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}
| Costante | Valore | Dove | Scopo |
|---|---|---|---|
BleDeviceApi.CHUNK_SIZE | 380 | Frammentazione segmenti GATT | Dimensione di ogni BleSegmentData.data (entra nell'ATT MTU dopo l'overhead JSON) |
BleTransport.CHUNK_SIZE | 16 384 (16 KiB) | Byte-range di download file | Dimensione di ogni richiesta chunk /fs |
BleTransport.SCAN_TIMEOUT_MS | 10 000 | Scansione BLE | Timeout per scanner.findOne |
BleDeviceApi.NOTIFY_TIMEOUT_MS | 15 000 | Risposta RPC | Attesa per-notifica in requestAsync |
AndroidBleGattClient MTU | 517 | Setup connessione | requestMtu(517) — massimo consentito dalla specifica BLE |
AndroidBleGattClient connect timeout | 10 000 | Setup connessione | Attesa per STATE_CONNECTED |
AndroidBleGattClient MTU timeout | 5 000 | Setup connessione | Attesa per onMtuChanged |
AndroidBleGattClient write timeout | 5 000 | Scrittura GATT | Attesa per onCharacteristicWrite |
AndroidBleGattClient read timeout | 10 000 | Lettura GATT | Attesa per onCharacteristicRead (inutilizzato per dati reali) |
AndroidBleGattClient notify-state timeout | 5 000 | Scrittura CCCD | Attesa per la scrittura del descrittore CCCD |
ensureConnected retries | 3 | Setup connessione | Fino a 4 tentativi totali (0..3) |
AndroidBleGattServer.NOTIFY_ACK_TIMEOUT_MS | 10 000 | Flow control notifiche | Attesa per onNotificationSent |
AndroidBleGattServer notifyChunkSize | 380 | Frammentazione risposta | Come BleDeviceApi.CHUNK_SIZE |
IosBleGattServer retry cap | 10 | Flow control notifiche | Max retry updateValue prima di arrendersi |
PeerCircuitBreaker.WINDOW_MS | 30 000 | Circuit breaker del trasporto | Durata di apertura dopo soglia |
PeerCircuitBreaker.MAX_FAILURES | 2 | Circuit breaker del trasporto | Fallimenti entro finestra per aprire |
DownloadQueue.MAX_CONCURRENT | 3 | Pool di worker download | Coroutine di download concorrenti |
BleServiceData.SHORT_ID_BYTES | 8 | Identificazione peer | Byte del prefisso SHA256 troncato |
BleServiceData.PAYLOAD_BYTES | 9 | Identificazione peer | 1 byte di flags + 8 byte di shortId |
BleSegmentData.STATE_START_BIT | 1 | Segnalazione EOF Layer A | Primo segmento di un messaggio multi-segmento |
BleSegmentData.STATE_END_BIT | 2 | Segnalazione EOF Layer A | Ultimo segmento (o segmento singolo) |
Riepilogo dei compromessi di design {#design-trade-offs-recap}
Letture aggiuntive
- Chat Architecture — come
BleTransportsi inserisce nella catena di fallbackLAN → Aware → BLEe 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.