Back to blog
Transport15 min read

BLE Transport Design — Messages & File Downloads

This article explains how PlainApp pushes chat messages and downloads files over Bluetooth Low Energy when neither LAN nor Wi-Fi Aware is available. BLE is the guaranteed fallback: slow, but it works without any IP connectivity at all. The article covers the wire format, the two-layer chunking design, how concurrent traffic is (and isn't) prioritized, and why every connection is torn down after each request.

For the broader chat architecture that consumes this transport, see Chat Architecture. For how two devices obtain the shared ChaCha20 key used to encrypt every BLE payload, see Pairing Flow.

Table of Contents

Why a BLE Transport at All?

PlainApp is serverless and offline-first. The transport layer is an ordered fallback chain: LAN → Wi-Fi Aware → BLE. LAN is the happy path (HTTPS over Wi-Fi, ~10 ms round trips). Wi-Fi Aware covers cross-subnet peers (different SSIDs, guest vs IoT VLANs). Both require IP connectivity of some kind. BLE is the only transport that works:

  • When the devices are not on the same IP network at all.
  • When Wi-Fi is off or in airplane mode (BLE radio is separate).
  • When Wi-Fi Aware is unsupported (Android < 13, all iOS variants of PlainApp).

BLE is slow — tens of KB/s, seconds of latency per request — but it is guaranteed for any paired peer, because the only thing it needs is the peer's clientId, which is always broadcast in the BLE scan response.

Diagram 1
1

GATT Service Layout

PlainApp advertises a single custom GATT service with two characteristics. There is no registered 16-bit UUID — the service uses a 128-bit UUID whose trailing bytes ASCII-decode to plpai\x01:

Diagram 2
2

Why two characteristics?

The two protocols have completely different trust models and payload shapes:

  • NEARBY carries pairing messages. They arrive before the peer is paired (no shared key yet), so they use their own Ed25519-signed JSON payloads with their own prefix routing. The body is a plain string.
  • HTTP carries all post-pairing traffic (chat, files, presence). It is always ChaCha20-encrypted with the shared key and uses the same HttpRouteRegistry as the LAN Ktor server, so the route handlers (/peer_graphql, /fs, /peer_status) are written once and reused for both transports.

Why notifications instead of reads?

The BLE ATT protocol limits a single attribute read to 512 bytes. A GraphQL response or a 16 KB file chunk can be far larger. PlainApp works around this by never using readCharacteristic for real data — the server's onCharacteristicReadRequest returns an empty payload with GATT_SUCCESS. Instead, the client writes its request to the characteristic, and the server responds by sending a sequence of chunked notifications that the client reassembles. This is documented in BleDeviceApi.requestAsync, BleServerProtocol.handleWrite, and AndroidBleGattServer.sendChunkedResponse.

Peer Identification: shortId, not MAC

BLE advertising packets are tiny (31 bytes) and the BLE MAC address is randomized by Android every ~15 minutes — so it cannot be used as a stable identifier. PlainApp instead broadcasts a 9-byte serviceData payload in the scan response:

Diagram 3
3

Why a truncated hash instead of the full clientId?

A 13-character clientId would fit in 13 bytes, but PlainApp opts for an 8-byte truncated SHA-256 for two reasons:

  1. Stable byte budget. 9 bytes total fits comfortably in the 31-byte advertising payload alongside the service UUID (16 bytes), length, and type fields (~27 bytes used, 4 bytes headroom).
  2. Privacy. A passive observer scanning BLE cannot recover the clientId from the shortId (the 8-byte prefix of a SHA-256 hash is irreversible in practice). They can only recognize a peer they've already seen advertise the same shortId — they cannot enumerate PlainApp users.

The full clientId is only revealed to a peer that has actually connected over GATT and exchanged a DDiscoverReply — i.e. a peer that the user has already chosen to interact with.

Two-Layer Chunking Design

This is the most subtle part of the BLE transport, and it is essential to understand both layers because they have completely different sizes and purposes:

Diagram 4
4

Why 380 characters?

The negotiated ATT MTU is 517 bytes on Android (requestMtu(517) — the maximum allowed by the BLE specification) and ~185+ on iOS (auto-negotiated by CoreBluetooth). Subtracting the ATT header (~3 bytes) and the JSON wrapper overhead of BleSegmentData ({"d":"...","s":N} adds ~12 bytes), 380 chars of payload fits comfortably within a single ATT MTU on both platforms. The value is symmetric (both client request fragments and server notification fragments use 380), which keeps the code simple.

Why 16 KiB for file chunks?

A 16 KiB file chunk base64-encodes to ~22 KiB of JSON, which fragments into ~58 GATT notification segments. Each requestAsync round-trip takes seconds over BLE, so fewer-but-larger chunks reduce per-chunk overhead. Going much larger would risk hitting BLE RPC timeouts and produce poor progress feedback (the user sees progress update only once per chunk). 16 KiB is the empirically tuned sweet spot — large enough for throughput, small enough for responsive progress UI.

The RPC Primitive: BleDeviceApi.requestAsync

Every BLE chat message and every file chunk is one call to BleDeviceApi.requestAsync(service, requestData) — a suspend function that returns a BleResult. It is synchronous from the caller's perspective: one request → one fully reassembled response, no pipelining.

Diagram 5
5

Key invariants

  1. One request → one response. requestAsync is synchronous from the caller's perspective — it returns only after the full response has been reassembled. There is no pipelining.
  2. Notifications enabled per-call. The client writes the CCCD at the start of every requestAsync and disables it at the end. This is wasteful (two extra GATT writes per call) but keeps the protocol stateless — the server doesn't have to track which clients are "listening".
  3. No retry within an RPC. If any single writeCharacteristic times out (5 s), the entire RPC aborts. Only ensureConnected retries (3 attempts on connect failure). Coarse transport-level backoff is provided by PeerCircuitBreaker, not by the RPC layer.

Wire Envelope Format

The payload inside Layer A segments is a nested JSON envelope. Stripping the fragmentation, the logical structure is:

Diagram 6
6

Response shape

The response flows in the opposite direction through the same Layer A fragmentation, but the inner JSON is a BleHttpResponse with three fields: s (HTTP status code), h (response headers map), and b (body). The body is always base64-encoded by BleHttpCall.encodeResponse(), even when empty — the response might be binary (encrypted GraphQL bytes, raw /fs file bytes) and the BLE transport is string-only, so the same JSON envelope carries both text and binary payloads.

Message Send Path (End-to-End)

Putting it all together — what happens when a chat message is sent over BLE:

Diagram 7
7

Notable design choices

  • Same key as LAN. The ChaCha20 shared key from pairing is reused for BLE — there's no separate BLE key. The OkHttp crypto interceptor used by LanTransport and the manual chaCha20Encrypt/chaCha20Decrypt in BleTransport are the same primitive, just invoked differently.
  • Same route handlers as LAN. BleHttpRequest is dispatched through HttpRouteRegistry.matchRoute(path), which is the same registry the Ktor LAN server uses. So /peer_graphql, /fs, /peer_status etc. are implemented exactly once and work identically over both transports.
  • No connection reuse. The finally { scanner.teardownConnection(client) } block always runs. Each message pays the full connect→discoverServices→MTU cost (~seconds). This is a deliberate trade-off — see Design Trade-offs.

File Download Path (End-to-End)

Downloads over BLE are streaming — the file is read in 16 KiB chunks and written to a temp file as it arrives, so a 10 MB file doesn't need 10 MB of RAM. The trick is that each chunk's RPC is a separate requestAsync call, and the chunks are pushed into a ByteChannel that the consumer reads concurrently.

Diagram 8
8

Why streaming instead of one big RPC?

A 10 MB file sent as a single RPC would mean ~280 000 notification segments, all held in memory on both sides before the response could even start — and the entire transfer would have to succeed before any progress is reported. Worse, a single dropped notification in the middle would corrupt the whole thing.

The chunked design has three wins:

  1. Constant memory. Only one 16 KiB chunk is in flight at a time.
  2. Live progress. DownloadQueue.notifyProgressUpdate() fires every second, and the UI shows a download bar.
  3. Resilience. A failed chunk can be retried independently (the DownloadQueue supports pause/resume/retry at the task level; a mid-stream failure leaves the partial temp file, though currently the downloader deletes it on failure — see trade-offs).

Why onClose cancels the download job

The DownloadedResponse.onClose callback calls downloadJob.cancel(). This is essential because the download loop runs in a child coroutine that would otherwise keep running forever if the consumer abandoned the channel early (e.g. user tapped Pause). The AutoCloseable contract on DownloadedResponse means the consumer's use { ... } block automatically invokes onClose on exit, cancelling the BLE download coroutine and tearing down the GATT connection in the coroutine's finally block.

Prioritization: How Chat Beats Files in Practice

This is the most important question for any chat application: when a slow BLE file download is in progress, can a new chat message jump ahead of it?

The honest answer: there is no explicit priority scheme

There is no priority field, no priority queue, no preemption anywhere in the BLE code or the download queue. I verified this by exhaustive grep — the only priority matches in shared/src are log-priority levels and EXIF metadata, nothing related to message-vs-download ordering.

What exists instead is a set of architectural separations that produce the desired behavior as an emergent property:

Diagram 9
9

Why it works in practice

The separation that makes chat "feel prioritized" is structural:

  1. Chat sends don't go through DownloadQueue. They're issued directly by PeerGraphQLClientPeerTransportRouterBleTransport.send. So a chat message never sits behind a queue of file downloads.
  2. Each BleTransport call opens its own GATT connection. A long-running download holding one connection does not prevent a chat send from opening a second connection to the same peer. Android supports multiple simultaneous GATT connections.
  3. Chat RPCs are short. A single chat message is one requestAsync round trip (~1 s after connect). Even if the radio is busy with a download, the chat send completes within a few seconds.

Where the design falls short

The trade-offs of "no explicit priority":

  • Connect latency. Both chat and download pay the connect→discover→MTU cost (~seconds) every time, because connections aren't reused. A chat message arriving during a download can't piggyback on the download's existing connection — it opens a new one.
  • Static queue on Android. The process-wide operationQueue in AndroidBleGattClient serializes GATT ops across all peers and all connections. So while two GATT connections can coexist, their write/read/notify operations are interleaved at the queue level. In practice this is fine (each op is ~ms) but it's a subtle global bottleneck under high concurrency.
  • No preemption. A download in progress cannot be paused to let a chat message through. The chat send simply runs concurrently and competes for radio time.

A future improvement could be a per-peer Mutex around BleTransport.send and downloadFile, plus a priority field on the queue — but the current design relies on the fact that chat RPCs are short enough that contention is rarely user-visible.

Concurrency Control & The Static GATT Queue

This deserves its own section because it's the most subtle aspect of the Android BLE implementation.

Diagram 10
10

Why static (process-wide)?

The Android BLE stack does not allow concurrent GATT operations on a single BluetoothGatt instance — calling writeCharacteristic while another write is in flight returns false and silently drops the second write. The standard workaround is a per-BluetoothGatt queue. PlainApp goes one step further and uses a process-wide queue (in the companion object), which is overly conservative but correct: it guarantees no two GATT operations anywhere in the app run simultaneously.

The cost is that a long BLE file download's write/read/notify operations queue behind (and are queued behind) any other peer's GATT operations. Since each individual op is ~ms, this is rarely a user-visible bottleneck — but under heavy concurrent BLE traffic to multiple peers, it could become one.

No per-peer lock at the transport layer

BleDeviceApi.requestAsync is a plain suspend fun with no mutex, no queue, no per-peer serialization. Two concurrent calls to BleTransport.send for the same peer will each open their own GATT connection and proceed independently. The serialization happens implicitly at the GATT operation level (via the static queue on Android, or via sequential await on iOS).

Connection Lifecycle & MTU Negotiation

Diagram 11
11

Why requestMtu(517)?

The default ATT MTU is 23 bytes (only 20 bytes of payload after the 3-byte ATT header). With the default MTU, every 380-char segment would require ~19 GATT writes instead of 1 — a 19× slowdown. Requesting the maximum MTU allowed by the BLE spec (517 bytes) lets the 380-char segments fit in a single ATT operation, dramatically improving throughput.

iOS doesn't expose an explicit MTU request API — CoreBluetooth negotiates it automatically with the peripheral during connection. Modern iOS devices typically negotiate ~185 bytes, which still comfortably fits the 380-char segments (after subtracting ATT header + JSON wrapper overhead).

Flow Control for Notifications

The server sends response fragments as notifications, but BLE notifications have no built-in flow control — if the server sends notifications faster than the controller can transmit them, they are silently dropped. PlainApp implements explicit ack-based flow control:

Diagram 12
12

Without this flow control, back-to-back notifications would be silently dropped by the BLE controller when its internal send queue fills up — a well-known Android BLE issue documented in the BleGattServer interface comments. The per-device single-in-flight rule guarantees that every notification is either transmitted or triggers a timeout (which is then treated as a transport failure).

Error Handling: TransportUnavailable vs Real Failure

TransportUnavailable is the signal that tells PeerTransportRouter to fall through to the next transport. Anything else is a real failure returned to the caller.

Diagram 13
13

The download failure subtlety

BleTransport.downloadFile returns DownloadedResponse(200, channel, onClose)immediately — the chunked download loop runs in a background coroutine that writes to the channel. If a chunk RPC fails mid-stream, the loop calls channel.close(TransportUnavailable(...)), which means the consumer (PeerFileDownloader.downloadAsync) sees the error as a thrown exception from channel.readAvailable(buf).

This means the PeerTransportRouter.downloadFile call itself succeeded (returned a DownloadedResponse), so the circuit breaker does not record a failure for mid-stream download errors. Only connect-time and scan-time failures are caught by the router. This is a deliberate design choice — a mid-stream failure shouldn't permanently disable BLE for that peer (the peer might just have gone out of range temporarily).

Key Constants Reference

ConstantValueWherePurpose
BleDeviceApi.CHUNK_SIZE380GATT segment fragmentationSize of each BleSegmentData.data (fits within ATT MTU after JSON overhead)
BleTransport.CHUNK_SIZE16 384 (16 KiB)File-download byte-rangeSize of each /fs chunk request
BleTransport.SCAN_TIMEOUT_MS10 000BLE scanTimeout for scanner.findOne
BleDeviceApi.NOTIFY_TIMEOUT_MS15 000RPC responsePer-notification wait in requestAsync
AndroidBleGattClient MTU517Connection setuprequestMtu(517) — max allowed by BLE spec
AndroidBleGattClient connect timeout10 000Connection setupWait for STATE_CONNECTED
AndroidBleGattClient MTU timeout5 000Connection setupWait for onMtuChanged
AndroidBleGattClient write timeout5 000GATT writeWait for onCharacteristicWrite
AndroidBleGattClient read timeout10 000GATT readWait for onCharacteristicRead (unused for real data)
AndroidBleGattClient notify-state timeout5 000CCCD writeWait for CCCD descriptor write
ensureConnected retries3Connection setupUp to 4 total attempts (0..3)
AndroidBleGattServer.NOTIFY_ACK_TIMEOUT_MS10 000Notification flow controlWait for onNotificationSent
AndroidBleGattServer notifyChunkSize380Response fragmentationSame as BleDeviceApi.CHUNK_SIZE
IosBleGattServer retry cap10Notification flow controlMax updateValue retries before giving up
PeerCircuitBreaker.WINDOW_MS30 000Transport circuit breakerOpen duration after threshold
PeerCircuitBreaker.MAX_FAILURES2Transport circuit breakerFailures within window to open
DownloadQueue.MAX_CONCURRENT3Download worker poolConcurrent download coroutines
BleServiceData.SHORT_ID_BYTES8Peer identificationTruncated SHA256 prefix bytes
BleServiceData.PAYLOAD_BYTES9Peer identification1 flags byte + 8 shortId bytes
BleSegmentData.STATE_START_BIT1Layer A EOF signalingFirst segment of a multi-segment message
BleSegmentData.STATE_END_BIT2Layer A EOF signalingLast segment (or single segment)

Design Trade-offs Recap

Diagram 14
14

Further Reading

  • Chat Architecture — how BleTransport fits into the LAN → Aware → BLE fallback chain and the broader chat send/receive pipeline.
  • Pairing Flow — how the shared ChaCha20 key used by every BLE payload is established, and how the NEARBY characteristic is used for the pairing handshake.