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?
- GATT Service Layout
- Peer Identification: shortId, not MAC
- Two-Layer Chunking Design
- The RPC Primitive:
BleDeviceApi.requestAsync - Wire Envelope Format
- Message Send Path (End-to-End)
- File Download Path (End-to-End)
- Prioritization: How Chat Beats Files in Practice
- Concurrency Control & The Static GATT Queue
- Connection Lifecycle & MTU Negotiation
- Flow Control for Notifications
- Error Handling: TransportUnavailable vs Real Failure
- Key Constants Reference
- Design Trade-offs Recap
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.
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:
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
HttpRouteRegistryas 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:
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:
- 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).
- 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:
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.
Key invariants
- One request → one response.
requestAsyncis synchronous from the caller's perspective — it returns only after the full response has been reassembled. There is no pipelining. - Notifications enabled per-call. The client writes the CCCD at the
start of every
requestAsyncand 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". - No retry within an RPC. If any single
writeCharacteristictimes out (5 s), the entire RPC aborts. OnlyensureConnectedretries (3 attempts on connect failure). Coarse transport-level backoff is provided byPeerCircuitBreaker, 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:
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:
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
LanTransportand the manualchaCha20Encrypt/chaCha20DecryptinBleTransportare the same primitive, just invoked differently. - Same route handlers as LAN.
BleHttpRequestis dispatched throughHttpRouteRegistry.matchRoute(path), which is the same registry the Ktor LAN server uses. So/peer_graphql,/fs,/peer_statusetc. 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.
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:
- Constant memory. Only one 16 KiB chunk is in flight at a time.
- Live progress.
DownloadQueue.notifyProgressUpdate()fires every second, and the UI shows a download bar. - Resilience. A failed chunk can be retried independently (the
DownloadQueuesupports 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:
Why it works in practice
The separation that makes chat "feel prioritized" is structural:
- Chat sends don't go through
DownloadQueue. They're issued directly byPeerGraphQLClient→PeerTransportRouter→BleTransport.send. So a chat message never sits behind a queue of file downloads. - Each
BleTransportcall 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. - Chat RPCs are short. A single chat message is one
requestAsyncround 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
operationQueueinAndroidBleGattClientserializes 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.
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
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:
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.
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
| Constant | Value | Where | Purpose |
|---|---|---|---|
BleDeviceApi.CHUNK_SIZE | 380 | GATT segment fragmentation | Size of each BleSegmentData.data (fits within ATT MTU after JSON overhead) |
BleTransport.CHUNK_SIZE | 16 384 (16 KiB) | File-download byte-range | Size of each /fs chunk request |
BleTransport.SCAN_TIMEOUT_MS | 10 000 | BLE scan | Timeout for scanner.findOne |
BleDeviceApi.NOTIFY_TIMEOUT_MS | 15 000 | RPC response | Per-notification wait in requestAsync |
AndroidBleGattClient MTU | 517 | Connection setup | requestMtu(517) — max allowed by BLE spec |
AndroidBleGattClient connect timeout | 10 000 | Connection setup | Wait for STATE_CONNECTED |
AndroidBleGattClient MTU timeout | 5 000 | Connection setup | Wait for onMtuChanged |
AndroidBleGattClient write timeout | 5 000 | GATT write | Wait for onCharacteristicWrite |
AndroidBleGattClient read timeout | 10 000 | GATT read | Wait for onCharacteristicRead (unused for real data) |
AndroidBleGattClient notify-state timeout | 5 000 | CCCD write | Wait for CCCD descriptor write |
ensureConnected retries | 3 | Connection setup | Up to 4 total attempts (0..3) |
AndroidBleGattServer.NOTIFY_ACK_TIMEOUT_MS | 10 000 | Notification flow control | Wait for onNotificationSent |
AndroidBleGattServer notifyChunkSize | 380 | Response fragmentation | Same as BleDeviceApi.CHUNK_SIZE |
IosBleGattServer retry cap | 10 | Notification flow control | Max updateValue retries before giving up |
PeerCircuitBreaker.WINDOW_MS | 30 000 | Transport circuit breaker | Open duration after threshold |
PeerCircuitBreaker.MAX_FAILURES | 2 | Transport circuit breaker | Failures within window to open |
DownloadQueue.MAX_CONCURRENT | 3 | Download worker pool | Concurrent download coroutines |
BleServiceData.SHORT_ID_BYTES | 8 | Peer identification | Truncated SHA256 prefix bytes |
BleServiceData.PAYLOAD_BYTES | 9 | Peer identification | 1 flags byte + 8 shortId bytes |
BleSegmentData.STATE_START_BIT | 1 | Layer A EOF signaling | First segment of a multi-segment message |
BleSegmentData.STATE_END_BIT | 2 | Layer A EOF signaling | Last segment (or single segment) |
Design Trade-offs Recap
Further Reading
- Chat Architecture — how
BleTransportfits into theLAN → Aware → BLEfallback 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.