Zurück zum Blog
Transport15 min read

BLE-Transport-Design — Nachrichten & Datei-Downloads

Dieser Artikel erklärt, wie PlainApp Chat-Nachrichten über Bluetooth Low Energy pusht und Dateien herunterlädt, wenn weder LAN noch Wi-Fi Aware verfügbar sind. BLE ist der garantierte Fallback: langsam, aber er funktioniert ohne jegliche IP-Konnektivität. Der Artikel behandelt das Wire-Format, das zweischichtige Chunking-Design, wie gleichzeitiger Verkehr priorisiert wird (und wie nicht), und warum jede Verbindung nach jeder Anfrage abgebaut wird.

Für die übergeordnete Chat-Architektur, die diesen Transport konsumiert, siehe Chat-Architektur. Wie zwei Geräte den gemeinsamen ChaCha20-Schlüssel erhalten, mit dem jede BLE-Nutzlast verschlüsselt wird, ist unter Pairing-Ablauf beschrieben.

Inhaltsverzeichnis

Warum überhaupt ein BLE-Transport? {#why-a-ble-transport-at-all}

PlainApp ist serverlos und offline-first. Die Transportebene ist eine geordnete Fallback-Kette: LAN → Wi-Fi Aware → BLE. LAN ist der Happy Path (HTTPS über Wi-Fi, ~10 ms Round Trips). Wi-Fi Aware deckt Peers in unterschiedlichen Subnetzen ab (verschiedene SSIDs, Guest vs. IoT-VLANs). Beide erfordern irgendeine Form von IP-Konnektivität. BLE ist der einzige Transport, der funktioniert:

  • Wenn die Geräte überhaupt nicht im selben IP-Netzwerk sind.
  • Wenn Wi-Fi aus ist oder sich im Flugmodus befindet (das BLE-Radio ist separat).
  • Wenn Wi-Fi Aware nicht unterstützt wird (Android < 13, alle iOS-Varianten von PlainApp).

BLE ist langsam — Zehner-KB/s, Sekunden Latenz pro Anfrage — aber es ist garantiert für jeden gepaarten Peer, da es als einziges nur die clientId des Peers benötigt, die immer im BLE-Scan-Response gesendet wird.

Diagram 1
1

GATT-Service-Layout {#gatt-service-layout}

PlainApp bewirbt einen einzigen benutzerdefinierten GATT-Service mit zwei Characteristics. Es gibt keine registrierte 16-Bit-UUID — der Service verwendet eine 128-Bit-UUID, deren niederwertige Bytes ASCII-dekodiert plpai\x01 ergeben:

Diagram 2
2

Warum zwei Characteristics?

Die beiden Protokolle haben völlig unterschiedliche Vertrauensmodelle und Nutzlastformen:

  • NEARBY transportiert Pairing-Nachrichten. Sie kommen an, bevor der Peer gepaart ist (noch kein gemeinsamer Schlüssel), also verwenden sie eigene Ed25519-signierte JSON-Nutzlasten mit eigenem Prefix-Routing. Der Body ist ein einfacher String.
  • HTTP transportiert den gesamten Post-Pairing-Verkehr (Chat, Dateien, Presence). Er ist immer mit dem gemeinsamen Schlüssel ChaCha20-verschlüsselt und verwendet dieselbe HttpRouteRegistry wie der LAN-Ktor-Server, sodass die Route-Handler (/peer_graphql, /fs, /peer_status) einmal geschrieben und für beide Transporte wiederverwendet werden.

Warum Notifications statt Reads?

Das BLE-ATT-Protokoll begrenzt das Lesen eines einzelnen Attributs auf 512 Bytes. Eine GraphQL-Antwort oder ein 16-KB-Datei-Chunk kann deutlich größer sein. PlainApp umgeht das, indem readCharacteristic für echte Daten nie verwendet wird — der onCharacteristicReadRequest des Servers gibt eine leere Nutzlast mit GATT_SUCCESS zurück. Stattdessen schreibt der Client seine Anfrage in die Characteristic, und der Server antwortet mit einer Folge von chunked Notifications, die der Client wieder zusammensetzt. Das ist in BleDeviceApi.requestAsync, BleServerProtocol.handleWrite und AndroidBleGattServer.sendChunkedResponse dokumentiert.

Peer-Identifikation: shortId, nicht MAC {#peer-identification-shortid-not-mac}

BLE-Advertising-Pakete sind winzig (31 Bytes) und die BLE-MAC-Adresse wird von Android etwa alle 15 Minuten randomisiert — sie kann also nicht als stabiler Bezeichner dienen. PlainApp sendet stattdessen eine 9-Byte-serviceData-Nutzlast im Scan-Response:

Diagram 3
3

Warum ein abgeschnittener Hash statt der vollen clientId?

Eine 13-Zeichen-clientId würde in 13 Bytes passen, aber PlainApp entscheidet sich aus zwei Gründen für ein 8-Byte-abgeschnittenes SHA-256:

  1. Stabiles Byte-Budget. 9 Bytes insgesamt passen bequem in die 31-Byte- Advertising-Nutzlast neben der Service-UUID (16 Bytes), Längen- und Typfeldern (~27 Bytes belegt, 4 Bytes Reserve).
  2. Privacy. Ein passiver Beobachter, der BLE scannt, kann die clientId nicht aus dem shortId rekonstruieren (das 8-Byte-Präfix eines SHA-256-Hashes ist in der Praxis irreversibel). Er kann einen Peer, den er bereits angekündigt gesehen hat, nur wiedererkennen — er kann PlainApp-Nutzer nicht aufzählen.

Die volle clientId wird nur einem Peer offenbart, der tatsächlich über GATT verbunden ist und einen DDiscoverReply ausgetauscht hat — also einem Peer, mit dem der Nutzer bereits interagieren möchte.

Zweischichtiges Chunking-Design {#two-layer-chunking-design}

Das ist der subtilste Teil des BLE-Transports, und es ist wichtig, beide Schichten zu verstehen, da sie völlig unterschiedliche Größen und Zwecke haben:

Diagram 4
4

Warum 380 Zeichen?

Die verhandelte ATT-MTU beträgt auf Android 517 Bytes (requestMtu(517) — das Maximum laut BLE-Spezifikation) und ~185+ auf iOS (automatisch von CoreBluetooth verhandelt). Nach Abzug des ATT-Headers (~3 Bytes) und des JSON-Wrapper-Overheads von BleSegmentData ({"d":"...","s":N} addiert ~12 Bytes) passen 380 Zeichen Nutzlast bequem in eine einzelne ATT-MTU auf beiden Plattformen. Der Wert ist symmetrisch (sowohl Client-Anfrage-Fragmente als auch Server-Notification-Fragmente verwenden 380), was den Code einfach hält.

Warum 16 KiB für Datei-Chunks?

Ein 16-KiB-Datei-Chunk wird base64-kodiert zu ~22 KiB JSON, was in ~58 GATT-Notification-Segmente fragmentiert. Jeder requestAsync-Round-Trip dauert über BLE Sekunden, daher verringern weniger-aber-größere Chunks den Overhead pro Chunk. Viel größer würde riskieren, BLE-RPC-Timeouts zu treffen und schlechtes Progress-Feedback zu erzeugen (der Nutzer sieht Progress nur einmal pro Chunk aktualisiert). 16 KiB ist der empirisch ermittelte Sweet Spot — groß genug für Durchsatz, klein genug für reaktionsfreudige Progress-UI.

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

Jede BLE-Chat-Nachricht und jeder Datei-Chunk ist ein Aufruf von BleDeviceApi.requestAsync(service, requestData) — eine Suspend-Funktion, die ein BleResult zurückgibt. Sie ist aus Sicht des Aufrufers synchron: eine Anfrage → eine vollständig zusammengesetzte Antwort, kein Pipelining.

Diagram 5
5

Wichtige Invarianten

  1. Eine Anfrage → eine Antwort. requestAsync ist aus Sicht des Aufrufers synchron — sie kehrt erst zurück, nachdem die vollständige Antwort zusammengesetzt wurde. Es gibt kein Pipelining.
  2. Notifications pro Aufruf aktiviert. Der Client schreibt das CCCD zu Beginn jedes requestAsync und deaktiviert es am Ende. Das ist verschwenderisch (zwei zusätzliche GATT-Writes pro Aufruf), hält das Protokoll aber zustandslos — der Server muss nicht verfolgen, welche Clients „zuhören".
  3. Kein Retry innerhalb eines RPC. Wenn ein einzelnes writeCharacteristic ein Timeout hat (5 s), bricht der gesamte RPC ab. Nur ensureConnected unternimmt Retries (3 Versuche bei Verbindungsfehler). Grober Transport-Level-Backoff wird durch PeerCircuitBreaker bereitgestellt, nicht durch die RPC-Schicht.

Wire-Envelope-Format {#wire-envelope-format}

Die Nutzlast innerhalb der Layer-A-Segmente ist ein verschachtelter JSON-Umschlag. Streicht man die Fragmentierung, ist die logische Struktur:

Diagram 6
6

Antwortform

Die Antwort fließt in die entgegengesetzte Richtung durch dieselbe Layer-A- Fragmentierung, aber das innere JSON ist ein BleHttpResponse mit drei Feldern: s (HTTP-Statuscode), h (Map der Antwort-Header) und b (Body). Der Body wird immer base64-kodiert durch BleHttpCall.encodeResponse(), auch wenn er leer ist — die Antwort könnte binär sein (verschlüsselte GraphQL-Bytes, rohe /fs-Datei-Bytes) und der BLE-Transport ist nur-String, sodass derselbe JSON-Umschlag sowohl Text- als auch Binärnutzlasten transportiert.

Sende-Pfad für Nachrichten (End-to-End) {#message-send-path-end-to-end}

Alles zusammengeführt — was passiert, wenn eine Chat-Nachricht über BLE gesendet wird:

Diagram 7
7

Beachtenswerte Designentscheidungen

  • Derselbe Schlüssel wie LAN. Der gemeinsame ChaCha20-Schlüssel aus dem Pairing wird für BLE wiederverwendet — es gibt keinen separaten BLE-Schlüssel. Der OkHttp-Crypto-Interceptor, der von LanTransport verwendet wird, und das manuelle chaCha20Encrypt/chaCha20Decrypt in BleTransport sind dasselbe Primitiv, nur unterschiedlich aufgerufen.
  • Dieselben Route-Handler wie LAN. BleHttpRequest wird über HttpRouteRegistry.matchRoute(path) dispatched, also dieselbe Registry, die der Ktor-LAN-Server verwendet. So sind /peer_graphql, /fs, /peer_status usw. genau einmal implementiert und arbeiten identisch über beide Transporte.
  • Keine Wiederverwendung von Verbindungen. Der finally { scanner.teardownConnection(client) }-Block läuft immer. Jede Nachricht zahlt die vollen connect→discoverServices→MTU-Kosten (~Sekunden). Das ist ein bewusster Trade-off — siehe Design-Trade-offs.

Download-Pfad für Dateien (End-to-End) {#file-download-path-end-to-end}

Downloads über BLE sind streaming — die Datei wird in 16-KiB-Chunks gelesen und beim Eintreffen in eine Temp-Datei geschrieben, sodass eine 10-MB- Datei nicht 10 MB RAM benötigt. Der Trick ist, dass der RPC jedes Chunks ein separater requestAsync-Aufruf ist und die Chunks in einen ByteChannel geschoben werden, den der Konsument nebenbei liest.

Diagram 8
8

Warum Streaming statt eines großen RPC?

Eine 10-MB-Datei als einzelner RPC würde ~280 000 Notification-Segmente bedeuten, alle auf beiden Seiten im Speicher gehalten, bevor die Antwort überhaupt beginnen könnte — und die gesamte Übertragung müsste erfolgreich sein, bevor irgendein Progress gemeldet wird. Schlimmer noch, eine einzelne verlorene Notification in der Mitte würde das Ganze korrumpieren.

Das Chunking-Design hat drei Vorteile:

  1. Konstanter Speicher. Es ist immer nur ein 16-KiB-Chunk unterwegs.
  2. Live-Progress. DownloadQueue.notifyProgressUpdate() feuert jede Sekunde, und die UI zeigt eine Download-Leiste.
  3. Resilienz. Ein fehlgeschlagener Chunk kann unabhängig retried werden (die DownloadQueue unterstützt Pause/Resume/Retry auf Task-Ebene; ein Mid-Stream-Fehler belässt die partielle Temp-Datei, obwohl der Downloader sie bei Fehler derzeit löscht — siehe Trade-offs).

Warum onClose den Download-Job abbricht

Der DownloadedResponse.onClose-Callback ruft downloadJob.cancel() auf. Das ist essenziell, weil die Download-Schleife in einer Child-Coroutine läuft, die sonst für immer weiterlaufen würde, wenn der Konsument den Channel frühzeitig aufgibt (z.B. Nutzer tippt auf Pause). Der AutoCloseable-Vertrag auf DownloadedResponse bedeutet, dass der use { ... }-Block des Konsumenten beim Verlassen automatisch onClose aufruft, die BLE-Download-Coroutine abbricht und die GATT-Verbindung im finally-Block der Coroutine abbaut.

Priorisierung: Wie Chat Dateien in der Praxis schlägt {#prioritization-how-chat-beats-files-in-practice}

Das ist die wichtigste Frage für jede Chat-Anwendung: Wenn ein langsamer BLE-Datei-Download läuft, kann eine neue Chat-Nachricht vordrängeln?

Die ehrliche Antwort: Es gibt kein explizites Prioritätsschema

Es gibt kein Prioritätsfeld, keine Priority-Queue, keine Preemption irgendwo im BLE-Code oder der Download-Queue. Ich habe das per erschöpfendem Grep verifiziert — die einzigen priority-Treffer in shared/src sind Log-Priority-Level und EXIF-Metadaten, nichts mit Nachricht-vs-Download-Reihenfolge.

Was stattdessen existiert, ist eine Reihe architektonischer Trennungen, die das gewünschte Verhalten als emergente Eigenschaft erzeugen:

Diagram 9
9

Warum es in der Praxis funktioniert

Die Trennung, die Chat „priorisiert" wirken lässt, ist strukturell:

  1. Chat-Sends gehen nicht durch DownloadQueue. Sie werden direkt von PeerGraphQLClient → PeerTransportRouter → BleTransport.send abgesetzt. Daher sitzt eine Chat-Nachricht nie hinter einer Queue von Datei-Downloads.
  2. Jeder BleTransport-Aufruf öffnet seine eigene GATT-Verbindung. Ein lang laufender Download, der eine Verbindung hält, verhindert nicht, dass ein Chat-Send eine zweite Verbindung zum selben Peer öffnet. Android unterstützt mehrere simultane GATT-Verbindungen.
  3. Chat-RPCs sind kurz. Eine einzelne Chat-Nachricht ist ein requestAsync-Round-Trip (~1 s nach Connect). Selbst wenn das Radio mit einem Download beschäftigt ist, schließt der Chat-Send innerhalb weniger Sekunden ab.

Wo das Design Schwächen hat

Die Trade-offs von „keine explizite Priorität":

  • Connect-Latenz. Sowohl Chat als auch Download zahlen die Connect→Discover→MTU-Kosten (~Sekunden) jedes Mal, da Verbindungen nicht wiederverwendet werden. Eine Chat-Nachricht, die während eines Downloads ankommt, kann nicht auf der bestehenden Verbindung des Downloads mitreiten — sie öffnet eine neue.
  • Statische Queue auf Android. Die prozessweite operationQueue im AndroidBleGattClient serialisiert GATT-Ops über alle Peers und alle Verbindungen hinweg. Während also zwei GATT-Verbindungen koexistieren können, werden ihre Write/Read/Notify-Operationen auf Queue-Ebene interleaved. In der Praxis ist das in Ordnung (jede Op ~ms), aber es ist ein subtiler globaler Flaschenhals bei hoher Nebenläufigkeit.
  • Keine Preemption. Ein laufender Download kann nicht pausiert werden, um eine Chat-Nachricht durchzulassen. Der Chat-Send läuft einfach gleichzeitig und konkurriert um Funkzeit.

Eine künftige Verbesserung könnte ein per-Peer-Mutex um BleTransport.send und downloadFile sein, plus ein Prioritätsfeld auf der Queue — aber das aktuelle Design verlässt sich darauf, dass Chat-RPCs kurz genug sind, dass Contention für den Nutzer selten sichtbar wird.

Concurrency-Control & die statische GATT-Queue {#concurrency-control--the-static-gatt-queue}

Das verdient einen eigenen Abschnitt, weil es der subtilste Aspekt der Android-BLE-Implementierung ist.

Diagram 10
10

Warum statisch (prozessweit)?

Der Android-BLE-Stack erlaubt keine gleichzeitigen GATT-Operationen auf einer BluetoothGatt-Instanz — ein writeCharacteristic, während ein anderes Write in flight ist, gibt false zurück und verwirft das zweite Write stillschweigend. Der Standard-Workaround ist eine Queue pro BluetoothGatt. PlainApp geht einen Schritt weiter und verwendet eine prozessweite Queue (im companion object), was überkonservativ, aber korrekt ist: Sie garantiert, dass keine zwei GATT-Operationen irgendwo in der App gleichzeitig laufen.

Der Preis ist, dass die Write/Read/Notify-Operationen eines langen BLE-Datei-Downloads hinter (und werden queued hinter) den GATT-Operationen eines anderen Peers queue. Da jede einzelne Op ~ms dauert, ist das selten ein für den Nutzer sichtbarer Flaschenhals — aber unter schwerem gleichzeitigem BLE-Verkehr zu mehreren Peers könnte es einer werden.

Kein Per-Peer-Lock auf Transportebene

BleDeviceApi.requestAsync ist eine einfache suspend fun ohne Mutex, ohne Queue, ohne Per-Peer-Serialisierung. Zwei gleichzeitige Aufrufe von BleTransport.send für denselben Peer öffnen jeweils ihre eigene GATT-Verbindung und gehen unabhängig voran. Die Serialisierung geschieht implizit auf GATT-Operationsebene (über die statische Queue auf Android bzw. über sequentielle await auf iOS).

Verbindungslebenszyklus & MTU-Verhandlung {#connection-lifecycle--mtu-negotiation}

Diagram 11
11

Warum requestMtu(517)?

Die Standard-ATT-MTU beträgt 23 Bytes (nur 20 Bytes Nutzlast nach dem 3-Byte-ATT-Header). Mit der Standard-MTU würde jedes 380-Zeichen-Segment ~19 GATT-Writes statt 1 benötigen — eine 19-fache Verlangsamung. Das Anfordern der von der BLE-Spec erlaubten Maximal-MTU (517 Bytes) lässt die 380-Zeichen-Segmente in eine einzelne ATT-Operation passen, was den Durchsatz drastisch verbessert.

iOS stellt keine explizite MTU-Request-API bereit — CoreBluetooth verhandelt sie automatisch mit dem Peripheral während der Verbindung. Moderne iOS-Geräte verhandeln typischerweise ~185 Bytes, was die 380-Zeichen-Segmente weiterhin komfortabel aufnimmt (nach Abzug von ATT-Header + JSON-Wrapper-Overhead).

Flow-Control für Notifications {#flow-control-for-notifications}

Der Server sendet Antwort-Fragmente als Notifications, aber BLE-Notifications haben kein eingebautes Flow-Control — wenn der Server Notifications schneller sendet, als der Controller sie übertragen kann, werden sie stillschweigend verworfen. PlainApp implementiert explizites ack-basiertes Flow-Control:

Diagram 12
12

Ohne dieses Flow-Control würden aufeinanderfolgende Notifications vom BLE-Controller stillschweigend verworfen, wenn sich seine interne Send-Queue füllt — ein bekanntes Android-BLE-Problem, das in den Kommentaren des BleGattServer-Interface dokumentiert ist. Die Per-Device-Single-In-Flight-Regel garantiert, dass jede Notification entweder übertragen wird oder ein Timeout auslöst (das dann als Transportfehler behandelt wird).

Fehlerbehandlung: TransportUnavailable vs. echter Fehler {#error-handling-transportunavailable-vs-real-failure}

TransportUnavailable ist das Signal, das PeerTransportRouter sagt, zum nächsten Transport zu fallen. Alles andere ist ein echter Fehler, der an den Aufrufer zurückgegeben wird.

Diagram 13
13

Die Subtilität beim Download-Versagen

BleTransport.downloadFile gibt DownloadedResponse(200, channel, onClose) sofort zurück — die Chunked-Download-Schleife läuft in einer Hintergrund-Coroutine, die auf den Channel schreibt. Schlägt ein Chunk-RPC Mid-Stream fehl, ruft die Schleife channel.close(TransportUnavailable(...)) auf, was bedeutet, dass der Konsument (PeerFileDownloader.downloadAsync) den Fehler als geworfene Exception aus channel.readAvailable(buf) sieht.

Das bedeutet, dass der PeerTransportRouter.downloadFile-Aufruf selbst erfolgreich war (ein DownloadedResponse zurückgegeben hat), sodass der Circuit Breaker für Mid-Stream-Download-Fehler keinen Fehler registriert. Nur Connect-Time- und Scan-Time-Fehler werden vom Router erfasst. Das ist eine bewusste Designentscheidung — ein Mid-Stream-Fehler sollte BLE für diesen Peer nicht dauerhaft deaktivieren (der Peer ist vielleicht nur kurz außer Reichweite geraten).

Referenz der Schlüsselkonstanten {#key-constants-reference}

KonstanteWertWoZweck
BleDeviceApi.CHUNK_SIZE380GATT-Segment-FragmentierungGröße jedes BleSegmentData.data (passt nach JSON-Overhead in ATT-MTU)
BleTransport.CHUNK_SIZE16 384 (16 KiB)Datei-Download-Byte-RangeGröße jeder /fs-Chunk-Anfrage
BleTransport.SCAN_TIMEOUT_MS10 000BLE-ScanTimeout für scanner.findOne
BleDeviceApi.NOTIFY_TIMEOUT_MS15 000RPC-AntwortPer-Notification-Wartezeit in requestAsync
AndroidBleGattClient MTU517VerbindungsaufbaurequestMtu(517) — Max laut BLE-Spec
AndroidBleGattClient Connect-Timeout10 000VerbindungsaufbauWarten auf STATE_CONNECTED
AndroidBleGattClient MTU-Timeout5 000VerbindungsaufbauWarten auf onMtuChanged
AndroidBleGattClient Write-Timeout5 000GATT-WriteWarten auf onCharacteristicWrite
AndroidBleGattClient Read-Timeout10 000GATT-ReadWarten auf onCharacteristicRead (für echte Daten ungenutzt)
AndroidBleGattClient Notify-State-Timeout5 000CCCD-WriteWarten auf CCCD-Deskriptor-Write
ensureConnected-Retries3VerbindungsaufbauBis zu 4 Versuche gesamt (0..3)
AndroidBleGattServer.NOTIFY_ACK_TIMEOUT_MS10 000Notification-Flow-ControlWarten auf onNotificationSent
AndroidBleGattServer notifyChunkSize380Antwort-FragmentierungWie BleDeviceApi.CHUNK_SIZE
IosBleGattServer Retry-Cap10Notification-Flow-ControlMax updateValue-Retries vor Aufgeben
PeerCircuitBreaker.WINDOW_MS30 000Transport-Circuit-BreakerOpen-Dauer nach Schwellwert
PeerCircuitBreaker.MAX_FAILURES2Transport-Circuit-BreakerFehler im Fenster bis Open
DownloadQueue.MAX_CONCURRENT3Download-Worker-PoolGleichzeitige Download-Coroutines
BleServiceData.SHORT_ID_BYTES8Peer-IdentifikationAbgeschnittene SHA256-Präfix-Bytes
BleServiceData.PAYLOAD_BYTES9Peer-Identifikation1 Flags-Byte + 8 shortId-Bytes
BleSegmentData.STATE_START_BIT1Layer-A-EOF-SignalisierungErstes Segment einer Multi-Segment-Nachricht
BleSegmentData.STATE_END_BIT2Layer-A-EOF-SignalisierungLetztes Segment (oder Einzelsegment)

Zusammenfassung der Design-Trade-offs {#design-trade-offs-recap}

Diagram 14
14

Weiterführende Literatur

  • Chat-Architektur — wie BleTransport in die LAN → Aware → BLE-Fallback-Kette und die übergeordnete Chat-Send/Empfangs-Pipeline passt.
  • Pairing-Ablauf — wie der gemeinsame ChaCha20-Schlüssel, den jede BLE-Nutzlast verwendet, etabliert wird, und wie die NEARBY-Characteristic für den Pairing-Handshake verwendet wird.