Voor de bredere chatarchitectuur die dit transport gebruikt, zie Chatarchitectuur. Voor hoe twee apparaten de gedeelde ChaCha20-sleutel verkrijgen die elke BLE-payload versleutelt, zie het Koppelingsproces.
Inhoudsopgave
- Waarom een BLE-transport?
- GATT-service-indeling
- Peer-identificatie: shortId, niet MAC
- Tweelagige chunking-ontwerp
- De RPC-primitief:
BleDeviceApi.requestAsync - Wire-envelope-formaat
- Verzendpad voor berichten (end-to-end)
- Downloadpad voor bestanden (end-to-end)
- Prioritering: hoe chat bestanden in de praktijk verslaat
- Gelijktijdigheidsbeheer en de statische GATT-wachtrij
- Verbindingslevenscyclus en MTU-onderhandeling
- Flow control voor notificaties
- Foutafhandeling: TransportUnavailable versus echte fout
- Referentie van sleutelconstanten
- Samenvatting van ontwerpafwegingen
Waarom een BLE-transport? {#why-a-ble-transport-at-all}
PlainApp is serverless en offline-first. De transportlaag is een
geordende fallback-chain: LAN → Wi-Fi Aware → BLE. LAN is het
happy-path (HTTPS over Wi-Fi, ~10 ms round trips). Wi-Fi Aware dekt
peers in andere subnetten (verschillende SSID's, gast versus IoT-VLAN's).
Beide vereisen IP-connectiviteit van een of andere soort. BLE is het
enige transport dat werkt:
- Wanneer de apparaten helemaal niet op hetzelfde IP-netwerk zitten.
- Wanneer Wi-Fi uit staat of in vliegtuigmodus staat (de BLE-radio is gescheiden).
- Wanneer Wi-Fi Aware niet wordt ondersteund (Android < 13, alle iOS- varianten van PlainApp).
BLE is traag — tientallen KB/s, seconden latentie per verzoek — maar het
is gegarandeerd voor elke gekoppelde peer, omdat het enige wat het
nodig heeft de clientId van de peer is, die altijd wordt uitgezonden in
de BLE-scanrespons.
GATT-service-indeling {#gatt-service-layout}
PlainApp adverteert een enkele aangepaste GATT-service met twee
characteristics. Er is geen geregistreerde 16-bit UUID — de service
gebruikt een 128-bit UUID waarvan de trailing bytes ASCII-decoderen naar
plpai\x01:
Waarom twee characteristics?
De twee protocollen hebben volledig verschillende vertrouwensmodellen en payload-vormen:
- NEARBY vervoert koppelingsberichten. Ze komen aan voordat de peer is gekoppeld (nog geen gedeelde sleutel), dus gebruiken ze hun eigen Ed25519-ondertekende JSON-payloads met hun eigen prefix-routing. De body is een gewone string.
- HTTP vervoert al het verkeer na koppeling (chat, bestanden,
presence). Het is altijd ChaCha20-versleuteld met de gedeelde sleutel
en gebruikt dezelfde
HttpRouteRegistryals de LAN-Ktor-server, zodat de route-handlers (/peer_graphql,/fs,/peer_status) één keer worden geschreven en voor beide transports worden hergebruikt.
Waarom notificaties in plaats van reads?
Het BLE ATT-protocol beperkt een enkele attribute-read tot 512 bytes.
Een GraphQL-respons of een 16 KB-bestandschunk kan veel groter zijn.
PlainApp omzeilt dit door readCharacteristic nooit voor echte data te
gebruiken — de onCharacteristicReadRequest van de server retourneert
een lege payload met GATT_SUCCESS. In plaats daarvan schrijft de client
zijn verzoek naar de characteristic, en de server reageert door een reeks
gechunkte notificaties te sturen die de client weer in elkaar zet.
Dit is gedocumenteerd in BleDeviceApi.requestAsync,
BleServerProtocol.handleWrite en AndroidBleGattServer.sendChunkedResponse.
Peer-identificatie: shortId, niet MAC {#peer-identification-shortid-not-mac}
BLE-advertisingpakketjes zijn klein (31 bytes) en het BLE MAC-adres wordt
door Android ongeveer elke ~15 minuten gewijzigd — dus het kan niet
als stabiele identifier worden gebruikt. PlainApp zendt in plaats daarvan
een 9-byte serviceData-payload uit in de scanrespons:
Waarom een afgekappe hash in plaats van de volledige clientId?
Een 13-tekens clientId past in 13 bytes, maar PlainApp kiest voor een 8-byte afgekappe SHA-256 om twee redenen:
- Stabiele byte-budget. 9 bytes totaal past comfortabel in de 31-byte advertising-payload naast de service-UUID (16 bytes), lengte- en typevelden (~27 bytes gebruikt, 4 bytes marge).
- Privacy. Een passieve waarnemer die BLE scant kan de clientId niet terugkrijgen uit de shortId (de 8-byte prefix van een SHA-256-hash is in de praktijk onomkeerbaar). Ze kunnen alleen een peer herkennen die ze dezelfde shortId al hebben zien adverteren — ze kunnen geen PlainApp-gebruikers inventariseren.
De volledige clientId wordt alleen onthuld aan een peer die daadwerkelijk
via GATT heeft verbonden en een DDiscoverReply heeft uitgewisseld — d.w.z.
een peer waarmee de gebruiker al heeft gekozen om mee te communiceren.
Tweelagige chunking-ontwerp {#two-layer-chunking-design}
Dit is het meest subtiele deel van het BLE-transport, en het is essentieel om beide lagen te begrijpen omdat ze volledig verschillende grootten en doelen hebben:
Waarom 380 tekens?
De onderhandelde ATT MTU is 517 bytes op Android
(requestMtu(517) — het maximum dat door de BLE-specificatie is
toegestaan) en ~185+ op iOS (auto-onderhandeld door CoreBluetooth). Na
aftrek van de ATT-header (~3 bytes) en de JSON-wrapper-overhead van
BleSegmentData ({"d":"...","s":N} voegt ~12 bytes toe), passen 380
tekens payload comfortabel binnen één ATT MTU op beide platforms. De
waarde is symmetrisch (zowel client-verzoekfragmenten als
server-notificatiefragmenten gebruiken 380), wat de code eenvoudig houdt.
Waarom 16 KiB voor bestandschunks?
Een 16 KiB-bestandschunk base64-encodeert naar ~22 KiB JSON, wat
fragmenteert in ~58 GATT-notificatiesegmenten. Elke requestAsync-
round-trip duurt over BLE seconden, dus minder-maar-grotere chunks
verminderen de per-chunk overhead. Veel groter zou het risico lopen
BLE-RPC-timeouts te raken en slechte voortgangsfeedback te geven (de
gebruiker ziet voortgang slechts één keer per chunk bijgewerkt). 16 KiB
is de empirisch afgestemde sweet spot — groot genoeg voor doorvoer, klein
genoeg voor responsieve voortgangs-UI.
De RPC-primitief: BleDeviceApi.requestAsync {#the-rpc-primitive-bledeviceapirequestasync}
Elk BLE-chatbericht en elke bestandschunk is één aanroep naar
BleDeviceApi.requestAsync(service, requestData) — een suspend-functie
die een BleResult retourneert. Het is synchroon vanuit het perspectief
van de aanroeper: één verzoek → één volledig weer in elkaar gezet
respons, geen pipelining.
Belangrijke invarianten
- Eén verzoek → één respons.
requestAsyncis synchroon vanuit het perspectief van de aanroeper — het retourneert pas nadat de volledige respons is samengesteld. Er is geen pipelining. - Notificaties per aanroep ingeschakeld. De client schrijft de CCCD
aan het begin van elke
requestAsyncen schakelt deze aan het einde uit. Dit is verspillend (twee extra GATT-writes per aanroep) maar houdt het protocol stateless — de server hoeft niet bij te houden welke clients aan het "luisteren" zijn. - Geen hertry binnen een RPC. Als een enkele
writeCharacteristiceen timeout krijgt (5 s), wordt de hele RPC afgebroken. AlleenensureConnecteddoet hertries (3 pogingen bij verbindingsfout). Grove transport-level backoff wordt geboden doorPeerCircuitBreaker, niet door de RPC-laag.
Wire-envelope-formaat {#wire-envelope-format}
De payload binnen Layer A-segmenten is een geneste JSON-envelope. De fragmentatie weghalend, is de logische structuur:
Vorm van de respons
De respons stroomt in de tegenovergestelde richting door dezelfde
Layer A-fragmentatie, maar de binnenste JSON is een BleHttpResponse met
drie velden: s (HTTP-statuscode), h (map van respons-headers) en b
(body). De body wordt altijd base64-geëncodeerd door
BleHttpCall.encodeResponse(), zelfs wanneer leeg — de respons kan
binair zijn (versleutelde GraphQL-bytes, ruwe /fs-bestandsbytes) en het
BLE-transport is uitsluitend string, zodat dezelfde JSON-envelope zowel
tekst- als binaire payloads vervoert.
Verzendpad voor berichten (end-to-end) {#message-send-path-end-to-end}
Alles samenvoegend — wat gebeurt er wanneer een chatbericht via BLE wordt verzonden:
Opmerkelijke ontwerpkeuzes
- Dezelfde sleutel als LAN. De gedeelde ChaCha20-sleutel uit koppeling
wordt hergebruikt voor BLE — er is geen aparte BLE-sleutel. De door
LanTransportgebruikte OkHttp-crypto-interceptor en de handmatigechaCha20Encrypt/chaCha20DecryptinBleTransportzijn dezelfde primitief, alleen verschillend aangeroepen. - Dezelfde route-handlers als LAN.
BleHttpRequestwordt verzonden viaHttpRouteRegistry.matchRoute(path), wat hetzelfde register is dat de Ktor-LAN-server gebruikt. Dus/peer_graphql,/fs,/peer_statusenz. worden exact één keer geïmplementeerd en werken identiek over beide transports. - Geen hergebruik van verbindingen. Het
finally { scanner.teardownConnection(client) }-blok draait altijd. Elk bericht betaalt de volledige connect→discoverServices→MTU-kosten (~seconden). Dit is een bewuste afweging — zie Ontwerpafwegingen.
Downloadpad voor bestanden (end-to-end) {#file-download-path-end-to-end}
Downloads via BLE zijn streaming — het bestand wordt in 16 KiB-chunks
gelezen en bij aankomst naar een temp-bestand geschreven, zodat een
10 MB-bestand geen 10 MB RAM nodig heeft. De truc is dat de RPC van elke
chunk een aparte requestAsync-aanroep is, en de chunks in een
ByteChannel worden geduwd die de consument gelijktijdig uitleest.
Waarom streaming in plaats van één grote RPC?
Een 10 MB-bestand verzonden als één RPC zou ~280 000 notificatiesegmenten betekenen, allemaal in memory gehouden aan beide kanten voordat de respons überhaupt kan beginnen — en de hele overdracht zou moeten slagen voordat enige voortgang wordt gemeld. Erger nog, een enkele verloren notificatie in het midden zou alles corrupt maken.
Het gechunkte ontwerp heeft drie voordelen:
- Constant memory. Er is slechts één 16 KiB-chunk tegelijk onderweg.
- Live voortgang.
DownloadQueue.notifyProgressUpdate()vuurt elke seconde, en de UI toont een downloadbalk. - Veerkracht. Een mislukte chunk kan onafhankelijk worden herprobeerd
(de
DownloadQueueondersteunt pauze/hervat/hertry op taakniveau; een fout midden in de stream laat het gedeeltelijke temp-bestand achter, hoewel de downloader het momenteel bij fouten verwijdert — zie afwegingen).
Waarom onClose de download-job annuleert
De DownloadedResponse.onClose-callback roept downloadJob.cancel() aan.
Dit is essentieel omdat de download-loop draait in een child-coroutine die
anders voor altijd zou blijven draaien als de consument de channel vroegtijdig
losliet (bijv. de gebruiker tikte op Pause). Het AutoCloseable-
contract op DownloadedResponse betekent dat het use { ... }-blok van de
consument automatisch onClose aanroept bij afsluiten, waardoor de
BLE-download-coroutine wordt geannuleerd en de GATT-verbinding wordt
afgebroken in het finally-blok van de coroutine.
Prioritering: hoe chat bestanden in de praktijk verslaat {#prioritization-how-chat-beats-files-in-practice}
Dit is de belangrijkste vraag voor elke chattoepassing: wanneer een trage BLE-bestandsdownload loopt, kan een nieuw chatbericht dan voorrang krijgen?
Het eerlijke antwoord: er is geen expliciet prioriteitsschema
Er is geen prioriteitsveld, geen prioriteitswachtrij, geen preemption
waar dan ook in de BLE-code of de download-wachtrij. Ik verifieerde dit
door uitputtend grep — de enige priority-matches in shared/src zijn
log-prioriteitsniveaus en EXIF-metadata, niets gerelateerd aan
bericht-vs-download-volgorde.
Wat er in plaats daarvan bestaat is een set architecturale scheidingen die het gewenste gedrag produceren als een opkomende eigenschap:
Waarom het in de praktijk werkt
De scheiding die chat "geprioriteerd laat voelen" is structureel:
- Chat-verzendingen gaan niet door
DownloadQueue. Ze worden direct uitgegeven doorPeerGraphQLClient→PeerTransportRouter→BleTransport.send. Dus een chatbericht staat nooit achter een wachtrij van bestandsdownloads. - Elke
BleTransport-aanroep opent zijn eigen GATT-verbinding. Een langlopende download die één verbinding vasthoudt verhindert niet dat een chat-verzending een tweede verbinding met dezelfde peer opent. Android ondersteunt meerdere gelijktijdige GATT-verbindingen. - Chat-RPC's zijn kort. Een enkel chatbericht is één
requestAsync- round-trip (~1 s na connect). Zelfs als de radio bezig is met een download, voltooit de chat-verzending binnen enkele seconden.
Waar het ontwerp tekortschiet
De afwegingen van "geen expliciete prioriteit":
- Verbindingslatentie. Zowel chat als download betalen elke keer de connect→discover→MTU-kosten (~seconden), omdat verbindingen niet worden hergebruikt. Een chatbericht dat tijdens een download aankomt kan niet meerijden op de bestaande verbinding van de download — het opent een nieuwe.
- Statische wachtrij op Android. De procesbrede
operationQueueinAndroidBleGattClientserialiseert GATT-operaties over alle peers en alle verbindingen. Dus terwijl twee GATT-verbindingen naast elkaar kunnen bestaan, worden hun write/read/notify-operaties op wachtrij-niveau interleaved. In de praktijk is dit prima (elke op is ~ms) maar het is een subtiel mondiaal knelpunt bij hoge gelijktijdigheid. - Geen preemption. Een lopende download kan niet worden gepauzeerd om een chatbericht door te laten. De chat-verzending draait gewoon gelijktijdig en concurreert om radiotijd.
Een toekomstige verbetering zou een per-peer Mutex rond
BleTransport.send en downloadFile kunnen zijn, plus een
prioriteitsveld op de wachtrij — maar het huidige ontwerp leunt op het
feit dat chat-RPC's kort genoeg zijn dat contentie zelden zichtbaar is
voor de gebruiker.
Gelijktijdigheidsbeheer en de statische GATT-wachtrij {#concurrency-control--the-static-gatt-queue}
Dit verdient een eigen sectie omdat het het meest subtiele aspect van de Android BLE-implementatie is.
Waarom statisch (procesbreed)?
De Android BLE-stack staat geen gelijktijdige GATT-operaties toe op één
BluetoothGatt-instantie — writeCharacteristic aanroepen terwijl een
andere write in flight is retourneert false en negeert stilzwijgend de
tweede write. De standaardoplossing is een per-BluetoothGatt-wachtrij.
PlainApp gaat een stap verder en gebruikt een procesbrede wachtrij (in
het companion object), wat overdreven conservatief maar correct is: het
garandeert dat nergens in de app twee GATT-operaties gelijktijdig draaien.
De kosten zijn dat de write/read/notify-operaties van een lange BLE-bestandsdownload in de wachtrij staan achter (en achter worden gezet door) die van elke andere peer. Aangezien elke individuele op ~ms is, is dit zelden een voor de gebruiker zichtbaar knelpunt — maar onder zwaar gelijktijdig BLE-verkeer naar meerdere peers zou het er een kunnen worden.
Geen per-peer-lock op transportniveau
BleDeviceApi.requestAsync is een gewone suspend fun zonder mutex,
zonder wachtrij, zonder per-peer-serialisatie. Twee gelijktijdige
aanroepen naar BleTransport.send voor dezelfde peer openen elk hun eigen
GATT-verbinding en gaan onafhankelijk verder. De serialisatie gebeurt
impliciet op GATT-operatieniveau (via de statische wachtrij op Android,
of via sequentiële await op iOS).
Verbindingslevenscyclus en MTU-onderhandeling {#connection-lifecycle--mtu-negotiation}
Waarom requestMtu(517)?
De standaard ATT MTU is 23 bytes (slechts 20 bytes payload na de 3-byte ATT-header). Met de standaard-MTU zou elk 380-tekens-segment ~19 GATT-writes vereisen in plaats van 1 — een 19× vertraging. Het aanvragen van de maximale MTU die door de BLE-specificatie is toegestaan (517 bytes) laat de 380-tekens-segmenten in één ATT-operatie passen, waardoor de doorvoer dramatisch verbetert.
iOS ontsluit geen expliciete MTU-aanvraag-API — CoreBluetooth onderhandelt deze automatisch met de peripheral tijdens de verbinding. Moderne iOS- apparaten onderhandelen doorgaans ~185 bytes, wat nog steeds comfortabel biedt voor de 380-tekens-segmenten (na aftrek van ATT-header + JSON-wrapper-overhead).
Flow control voor notificaties {#flow-control-for-notifications}
De server verzendt responsfragmenten als notificaties, maar BLE- notificaties hebben geen ingebouwde flow control — als de server notificaties sneller verzendt dan de controller kan doorsturen, worden ze stilzwijgend gedropt. PlainApp implementeert expliciete op-ack gebaseerde flow control:
Zonder deze flow control zouden achter elkaar gestuurde notificaties
stilzwijgend worden gedropt door de BLE-controller wanneer diens interne
zendwachtrij vol raakt — een bekend Android-BLE-probleem gedocumenteerd
in de comments van de BleGattServer-interface. De per-apparaat-
single-in-flight-regel garandeert dat elke notificatie of wordt
verzonden of een timeout oplevert (die vervolgens wordt behandeld als
een transportfout).
Foutafhandeling: TransportUnavailable versus echte fout {#error-handling-transportunavailable-vs-real-failure}
TransportUnavailable is het signaal dat PeerTransportRouter vertelt om
door te vallen naar het volgende transport. Al het andere is een echte
fout die wordt teruggegeven aan de aanroeper.
De subtiliteit van download-fouten
BleTransport.downloadFile retourneert
DownloadedResponse(200, channel, onClose) onmiddellijk — de
gechunkte download-loop draait in een achtergrond-coroutine die naar de
channel schrijft. Als een chunk-RPC midden in de stream faalt, roept de
loop channel.close(TransportUnavailable(...)) aan, wat betekent dat de
consument (PeerFileDownloader.downloadAsync) de fout ziet als een
gegooide uitzondering vanuit channel.readAvailable(buf).
Dit betekent dat de PeerTransportRouter.downloadFile-aanroep zelf is
geslaagd (een DownloadedResponse retourneerde), dus de circuit breaker
recordt geen fout voor download-fouten midden in de stream. Alleen
fouten op connect- en scan-tijd worden door de router gevangen. Dit is een
bewuste ontwerpkeuze — een fout midden in de stream mag BLE niet
permanent uitschakelen voor die peer (de peer kan bijvoorbeeld tijdelijk
uit bereik zijn gegaan).
Referentie van sleutelconstanten {#key-constants-reference}
| Constante | Waarde | Waar | Doel |
|---|---|---|---|
BleDeviceApi.CHUNK_SIZE | 380 | GATT-segment-fragmentatie | Grootte van elke BleSegmentData.data (past binnen ATT MTU na JSON-overhead) |
BleTransport.CHUNK_SIZE | 16 384 (16 KiB) | Bestandsdownload-byte-range | Grootte van elke /fs-chunk-aanvraag |
BleTransport.SCAN_TIMEOUT_MS | 10 000 | BLE-scan | Timeout voor scanner.findOne |
BleDeviceApi.NOTIFY_TIMEOUT_MS | 15 000 | RPC-respons | Per-notificatie-wachttijd in requestAsync |
AndroidBleGattClient MTU | 517 | Verbindingssetup | requestMtu(517) — max toegestaan door BLE-spec |
AndroidBleGattClient connect timeout | 10 000 | Verbindingssetup | Wachten op STATE_CONNECTED |
AndroidBleGattClient MTU timeout | 5 000 | Verbindingssetup | Wachten op onMtuChanged |
AndroidBleGattClient write timeout | 5 000 | GATT-write | Wachten op onCharacteristicWrite |
AndroidBleGattClient read timeout | 10 000 | GATT-read | Wachten op onCharacteristicRead (ongebruikt voor echte data) |
AndroidBleGattClient notify-state timeout | 5 000 | CCCD-write | Wachten op CCCD-descriptor-write |
ensureConnected retries | 3 | Verbindingssetup | Tot 4 totale pogingen (0..3) |
AndroidBleGattServer.NOTIFY_ACK_TIMEOUT_MS | 10 000 | Notificatie-flow-control | Wachten op onNotificationSent |
AndroidBleGattServer notifyChunkSize | 380 | Respons-fragmentatie | Hetzelfde als BleDeviceApi.CHUNK_SIZE |
IosBleGattServer retry cap | 10 | Notificatie-flow-control | Max updateValue-hertries voordat wordt opgegeven |
PeerCircuitBreaker.WINDOW_MS | 30 000 | Transport-circuit-breaker | Open-duur na drempel |
PeerCircuitBreaker.MAX_FAILURES | 2 | Transport-circuit-breaker | Fouten binnen venster om te openen |
DownloadQueue.MAX_CONCURRENT | 3 | Download-worker-pool | Gelijktijdige download-coroutines |
BleServiceData.SHORT_ID_BYTES | 8 | Peer-identificatie | Afgekappe SHA256-prefix-bytes |
BleServiceData.PAYLOAD_BYTES | 9 | Peer-identificatie | 1 flags-byte + 8 shortId-bytes |
BleSegmentData.STATE_START_BIT | 1 | Layer A EOF-signalering | Eerste segment van een bericht met meerdere segmenten |
BleSegmentData.STATE_END_BIT | 2 | Layer A EOF-signalering | Laatste segment (of enige segment) |
Samenvatting van ontwerpafwegingen {#design-trade-offs-recap}
Verder lezen
- Chatarchitectuur — hoe
BleTransportpast in deLAN → Aware → BLE-fallback-chain en de bredere chat-verzend/ontvang-pijplijn. - Koppelingsproces — hoe de gedeelde ChaCha20-sleutel die door elke BLE-payload wordt gebruikt, wordt opgebouwd, en hoe de NEARBY-characteristic wordt gebruikt voor de koppelings-handshake.