[{"data":1,"prerenderedAt":1507},["ShallowReactive",2],{"i-ph:moon-bold":3,"i-ph:globe-simple":8,"i-ph:caret-down":10,"i-ph:list":12,"i-ph:heart-fill":14,"i-ph:discord-logo-bold":16,"i-ph:github-logo-bold":18,"i-ph:cookie-bold":20,"blog-article-en-ble-transport":22,"i-ph:arrow-left":1505},{"left":4,"top":4,"width":5,"height":5,"rotate":4,"vFlip":6,"hFlip":6,"body":7},0,256,false,"\u003Cpath fill=\"currentColor\" d=\"M236.37 139.4a12 12 0 0 0-12-3A84.07 84.07 0 0 1 119.6 31.59a12 12 0 0 0-15-15a108.86 108.86 0 0 0-54.91 38.48A108 108 0 0 0 136 228a107.1 107.1 0 0 0 64.93-21.69a108.86 108.86 0 0 0 38.44-54.94a12 12 0 0 0-3-11.97m-49.88 47.74A84 84 0 0 1 68.86 69.51a84.9 84.9 0 0 1 23.41-21.22Q92 52.13 92 56a108.12 108.12 0 0 0 108 108q3.87 0 7.71-.27a84.8 84.8 0 0 1-21.22 23.41\"\u002F>",{"left":4,"top":4,"width":5,"height":5,"rotate":4,"vFlip":6,"hFlip":6,"body":9},"\u003Cpath fill=\"currentColor\" d=\"M128 24a104 104 0 1 0 104 104A104.12 104.12 0 0 0 128 24m87.62 96h-39.83c-1.79-36.51-15.85-62.33-27.38-77.6a88.19 88.19 0 0 1 67.22 77.6ZM96.23 136h63.54c-2.31 41.61-22.23 67.11-31.77 77c-9.55-9.9-29.46-35.4-31.77-77m0-16c2.31-41.61 22.23-67.11 31.77-77c9.55 9.93 29.46 35.43 31.77 77Zm11.36-77.6C96.06 57.67 82 83.49 80.21 120H40.37a88.19 88.19 0 0 1 67.22-77.6M40.37 136h39.84c1.82 36.51 15.85 62.33 27.38 77.6A88.19 88.19 0 0 1 40.37 136m108 77.6c11.53-15.27 25.56-41.09 27.38-77.6h39.84a88.19 88.19 0 0 1-67.18 77.6Z\"\u002F>",{"left":4,"top":4,"width":5,"height":5,"rotate":4,"vFlip":6,"hFlip":6,"body":11},"\u003Cpath fill=\"currentColor\" d=\"m213.66 101.66l-80 80a8 8 0 0 1-11.32 0l-80-80a8 8 0 0 1 11.32-11.32L128 164.69l74.34-74.35a8 8 0 0 1 11.32 11.32\"\u002F>",{"left":4,"top":4,"width":5,"height":5,"rotate":4,"vFlip":6,"hFlip":6,"body":13},"\u003Cpath fill=\"currentColor\" d=\"M224 128a8 8 0 0 1-8 8H40a8 8 0 0 1 0-16h176a8 8 0 0 1 8 8M40 72h176a8 8 0 0 0 0-16H40a8 8 0 0 0 0 16m176 112H40a8 8 0 0 0 0 16h176a8 8 0 0 0 0-16\"\u002F>",{"left":4,"top":4,"width":5,"height":5,"rotate":4,"vFlip":6,"hFlip":6,"body":15},"\u003Cpath fill=\"currentColor\" d=\"M240 102c0 70-103.79 126.66-108.21 129a8 8 0 0 1-7.58 0C119.79 228.66 16 172 16 102a62.07 62.07 0 0 1 62-62c20.65 0 38.73 8.88 50 23.89C139.27 48.88 157.35 40 178 40a62.07 62.07 0 0 1 62 62\"\u002F>",{"left":4,"top":4,"width":5,"height":5,"rotate":4,"vFlip":6,"hFlip":6,"body":17},"\u003Cpath fill=\"currentColor\" d=\"M108 136a16 16 0 1 1-16-16a16 16 0 0 1 16 16m56-16a16 16 0 1 0 16 16a16 16 0 0 0-16-16m76.07 76.56l-67 29.71A20.15 20.15 0 0 1 146 214.9l-8.54-23.13c-3.13.14-6.27.24-9.45.24s-6.32-.1-9.45-.24L110 214.9a20.19 20.19 0 0 1-27.08 11.37l-67-29.71a19.93 19.93 0 0 1-11.3-23.15L34.15 57a20 20 0 0 1 16.22-14.81l36.06-5.93a20.26 20.26 0 0 1 22.79 14.84l4.41 17.41c4.74-.33 9.52-.51 14.37-.51s9.63.18 14.37.51l4.41-17.41a20.25 20.25 0 0 1 22.79-14.84l36.06 5.93A20 20 0 0 1 221.85 57l29.53 116.38a19.93 19.93 0 0 1-11.31 23.18M227.28 176L199.23 65.46l-30.07-4.94l-2.84 11.17c2.9.58 5.78 1.2 8.61 1.92a12 12 0 1 1-5.86 23.27A168.4 168.4 0 0 0 128 92a168.4 168.4 0 0 0-41.07 4.88a12 12 0 0 1-5.86-23.27c2.83-.72 5.71-1.34 8.61-1.92l-2.83-11.17l-30.08 4.94L28.72 176l60.22 26.7l5-13.57c-4.37-.76-8.67-1.65-12.88-2.71a12 12 0 0 1 5.86-23.28A168.4 168.4 0 0 0 128 168a168.4 168.4 0 0 0 41.07-4.88a12 12 0 0 1 5.86 23.28c-4.21 1.06-8.51 1.95-12.88 2.71l5 13.57Z\"\u002F>",{"left":4,"top":4,"width":5,"height":5,"rotate":4,"vFlip":6,"hFlip":6,"body":19},"\u003Cpath fill=\"currentColor\" d=\"M212.62 75.17A63.7 63.7 0 0 0 206.39 26A12 12 0 0 0 196 20a63.71 63.71 0 0 0-50 24h-20a63.71 63.71 0 0 0-50-24a12 12 0 0 0-10.39 6a63.7 63.7 0 0 0-6.23 49.17A61.5 61.5 0 0 0 52 104v8a60.1 60.1 0 0 0 45.76 58.28A43.66 43.66 0 0 0 92 192v4H76a20 20 0 0 1-20-20a44.05 44.05 0 0 0-44-44a12 12 0 0 0 0 24a20 20 0 0 1 20 20a44.05 44.05 0 0 0 44 44h16v12a12 12 0 0 0 24 0v-40a20 20 0 0 1 40 0v40a12 12 0 0 0 24 0v-40a43.66 43.66 0 0 0-5.76-21.72A60.1 60.1 0 0 0 220 112v-8a61.5 61.5 0 0 0-7.38-28.83M196 112a36 36 0 0 1-36 36h-48a36 36 0 0 1-36-36v-8a37.87 37.87 0 0 1 6.13-20.12a11.65 11.65 0 0 0 1.58-11.49a39.9 39.9 0 0 1-.4-27.72a39.87 39.87 0 0 1 26.41 17.8a12 12 0 0 0 10.1 5.53h32.35a12 12 0 0 0 10.11-5.53a39.84 39.84 0 0 1 26.41-17.8a39.9 39.9 0 0 1-.4 27.72a12 12 0 0 0 1.61 11.53A37.85 37.85 0 0 1 196 104Z\"\u002F>",{"left":4,"top":4,"width":5,"height":5,"rotate":4,"vFlip":6,"hFlip":6,"body":21},"\u003Cpath fill=\"currentColor\" d=\"M167.31 160.69a16 16 0 1 1-22.62 0a16 16 0 0 1 22.62 0m-86.62-8a16 16 0 1 0 22.62 0a16 16 0 0 0-22.62 0m14.62-33.38a16 16 0 1 0-22.62 0a16 16 0 0 0 22.62 0m48-6.62a16 16 0 1 0 0 22.62a16 16 0 0 0 0-22.62M236 128A108 108 0 1 1 128 20a12 12 0 0 1 12 12a36 36 0 0 0 36 36a12 12 0 0 1 12 12a36 36 0 0 0 36 36a12 12 0 0 1 12 12m-24.67 10.65A60.17 60.17 0 0 1 165 91a60.17 60.17 0 0 1-47.66-46.32a84 84 0 1 0 94 94Z\"\u002F>",{"id":23,"title":24,"body":25,"category":1492,"date":1493,"description":1494,"extension":1495,"meta":1496,"navigation":1497,"path":1498,"readingTime":1499,"seo":1500,"seoDescription":1501,"seoTitle":1502,"stem":1503,"__hash__":1504},"blog_en\u002Fblog\u002Fble-transport.md","BLE Transport Design — Messages & File Downloads",{"type":26,"value":27,"toc":1439},"minimark",[28,43,48,146,149,165,176,187,194,197,208,214,219,225,258,262,299,302,313,319,323,330,350,360,363,366,372,376,398,402,409,414,425,431,435,473,476,479,485,489,519,522,525,531,535,592,595,612,618,622,625,628,655,663,697,700,706,710,725,732,738,742,745,784,788,791,822,836,839,842,848,852,881,884,888,908,911,917,923,934,937,940,947,953,960,963,976,982,986,1010,1024,1027,1407,1410,1416,1420],[29,30,31,32,37,38,42],"p",{},"For the broader chat architecture that consumes this transport, see\n",[33,34,36],"a",{"href":35},"\u002Fblog\u002Fchat-architecture","Chat Architecture",". For how two devices obtain the\nshared ChaCha20 key used to encrypt every BLE payload, see\n",[33,39,41],{"href":40},"\u002Fblog\u002Fpairing-flow","Pairing Flow",".",[44,45,47],"h2",{"id":46},"table-of-contents","Table of Contents",[49,50,51,58,64,70,76,86,92,98,104,110,116,122,128,134,140],"ul",{},[52,53,54],"li",{},[33,55,57],{"href":56},"#why-a-ble-transport-at-all","Why a BLE Transport at All?",[52,59,60],{},[33,61,63],{"href":62},"#gatt-service-layout","GATT Service Layout",[52,65,66],{},[33,67,69],{"href":68},"#peer-identification-shortid-not-mac","Peer Identification: shortId, not MAC",[52,71,72],{},[33,73,75],{"href":74},"#two-layer-chunking-design","Two-Layer Chunking Design",[52,77,78],{},[33,79,81,82],{"href":80},"#the-rpc-primitive-bledeviceapirequestasync","The RPC Primitive: ",[83,84,85],"code",{},"BleDeviceApi.requestAsync",[52,87,88],{},[33,89,91],{"href":90},"#wire-envelope-format","Wire Envelope Format",[52,93,94],{},[33,95,97],{"href":96},"#message-send-path-end-to-end","Message Send Path (End-to-End)",[52,99,100],{},[33,101,103],{"href":102},"#file-download-path-end-to-end","File Download Path (End-to-End)",[52,105,106],{},[33,107,109],{"href":108},"#prioritization-how-chat-beats-files-in-practice","Prioritization: How Chat Beats Files in Practice",[52,111,112],{},[33,113,115],{"href":114},"#concurrency-control--the-static-gatt-queue","Concurrency Control & The Static GATT Queue",[52,117,118],{},[33,119,121],{"href":120},"#connection-lifecycle--mtu-negotiation","Connection Lifecycle & MTU Negotiation",[52,123,124],{},[33,125,127],{"href":126},"#flow-control-for-notifications","Flow Control for Notifications",[52,129,130],{},[33,131,133],{"href":132},"#error-handling-transportunavailable-vs-real-failure","Error Handling: TransportUnavailable vs Real Failure",[52,135,136],{},[33,137,139],{"href":138},"#key-constants-reference","Key Constants Reference",[52,141,142],{},[33,143,145],{"href":144},"#design-trade-offs-recap","Design Trade-offs Recap",[44,147,57],{"id":148},"why-a-ble-transport-at-all",[29,150,151,152,156,157,160,161,164],{},"PlainApp is ",[153,154,155],"strong",{},"serverless and offline-first",". The transport layer is an ordered\nfallback chain: ",[83,158,159],{},"LAN → Wi-Fi Aware → BLE",". LAN is the happy path (HTTPS over\nWi-Fi, ~10 ms round trips). Wi-Fi Aware\ncovers cross-subnet peers (different SSIDs, guest vs IoT VLANs). Both require\n",[153,162,163],{},"IP connectivity"," of some kind. BLE is the only transport that works:",[49,166,167,170,173],{},[52,168,169],{},"When the devices are not on the same IP network at all.",[52,171,172],{},"When Wi-Fi is off or in airplane mode (BLE radio is separate).",[52,174,175],{},"When Wi-Fi Aware is unsupported (Android \u003C 13, all iOS variants of PlainApp).",[29,177,178,179,182,183,186],{},"BLE is slow — tens of KB\u002Fs, seconds of latency per request — but it is\n",[153,180,181],{},"guaranteed"," for any paired peer, because the only thing it needs is the\npeer's ",[83,184,185],{},"clientId",", which is always broadcast in the BLE scan response.",[29,188,189],{},[190,191],"img",{"alt":192,"src":193},"Diagram 1","\u002Fblog\u002Fble-transport\u002Fdiagram-01.svg",[44,195,63],{"id":196},"gatt-service-layout",[29,198,199,200,203,204,207],{},"PlainApp advertises a ",[153,201,202],{},"single custom GATT service"," with two characteristics.\nThere is no registered 16-bit UUID — the service uses a 128-bit UUID whose\ntrailing bytes ASCII-decode to ",[83,205,206],{},"plpai\\x01",":",[29,209,210],{},[190,211],{"alt":212,"src":213},"Diagram 2","\u002Fblog\u002Fble-transport\u002Fdiagram-02.svg",[215,216,218],"h3",{"id":217},"why-two-characteristics","Why two characteristics?",[29,220,221,222,207],{},"The two protocols have ",[153,223,224],{},"completely different trust models and payload\nshapes",[49,226,227,237],{},[52,228,229,232,233,236],{},[153,230,231],{},"NEARBY"," carries pairing messages. They arrive ",[153,234,235],{},"before"," the peer is\npaired (no shared key yet), so they use their own Ed25519-signed JSON\npayloads with their own prefix routing. The body is a plain string.",[52,238,239,242,243,246,247,250,251,250,254,257],{},[153,240,241],{},"HTTP"," carries all post-pairing traffic (chat, files, presence). It is\nalways ChaCha20-encrypted with the shared key and uses the same\n",[83,244,245],{},"HttpRouteRegistry"," as the LAN Ktor server, so the route handlers\n(",[83,248,249],{},"\u002Fpeer_graphql",", ",[83,252,253],{},"\u002Ffs",[83,255,256],{},"\u002Fpeer_status",") are written once and reused for\nboth transports.",[215,259,261],{"id":260},"why-notifications-instead-of-reads","Why notifications instead of reads?",[29,263,264,265,268,269,276,277,280,281,284,285,288,289,291,292,295,296,42],{},"The BLE ATT protocol limits a single attribute read to ",[153,266,267],{},"512 bytes",". A\nGraphQL response or a 16 KB file chunk can be far larger. PlainApp works around\nthis by ",[153,270,271,272,275],{},"never using ",[83,273,274],{},"readCharacteristic"," for real data"," — the server's\n",[83,278,279],{},"onCharacteristicReadRequest"," returns an empty payload with ",[83,282,283],{},"GATT_SUCCESS",".\nInstead, the client writes its request to the characteristic, and the server\nresponds by sending a sequence of ",[153,286,287],{},"chunked notifications"," that the client\nreassembles. This is documented in ",[83,290,85],{},",\n",[83,293,294],{},"BleServerProtocol.handleWrite",", and ",[83,297,298],{},"AndroidBleGattServer.sendChunkedResponse",[44,300,69],{"id":301},"peer-identification-shortid-not-mac",[29,303,304,305,308,309,312],{},"BLE advertising packets are tiny (31 bytes) and the BLE MAC address is\n",[153,306,307],{},"randomized by Android every ~15 minutes"," — so it cannot be used as a\nstable identifier. PlainApp instead broadcasts a 9-byte ",[83,310,311],{},"serviceData"," payload\nin the scan response:",[29,314,315],{},[190,316],{"alt":317,"src":318},"Diagram 3","\u002Fblog\u002Fble-transport\u002Fdiagram-03.svg",[215,320,322],{"id":321},"why-a-truncated-hash-instead-of-the-full-clientid","Why a truncated hash instead of the full clientId?",[29,324,325,326,329],{},"A 13-character clientId would fit in 13 bytes, but PlainApp opts for an\n8-byte ",[153,327,328],{},"truncated SHA-256"," for two reasons:",[331,332,333,339],"ol",{},[52,334,335,338],{},[153,336,337],{},"Stable byte budget."," 9 bytes total fits comfortably in the 31-byte\nadvertising payload alongside the service UUID (16 bytes), length, and\ntype fields (~27 bytes used, 4 bytes headroom).",[52,340,341,344,345,349],{},[153,342,343],{},"Privacy."," A passive observer scanning BLE cannot recover the clientId\nfrom the shortId (the 8-byte prefix of a SHA-256 hash is irreversible\nin practice). They can only ",[346,347,348],"em",{},"recognize"," a peer they've already seen\nadvertise the same shortId — they cannot enumerate PlainApp users.",[29,351,352,353,355,356,359],{},"The full ",[83,354,185],{}," is only revealed to a peer that has actually connected\nover GATT and exchanged a ",[83,357,358],{},"DDiscoverReply"," — i.e. a peer that the user has\nalready chosen to interact with.",[44,361,75],{"id":362},"two-layer-chunking-design",[29,364,365],{},"This is the most subtle part of the BLE transport, and it is essential to\nunderstand both layers because they have completely different sizes and\npurposes:",[29,367,368],{},[190,369],{"alt":370,"src":371},"Diagram 4","\u002Fblog\u002Fble-transport\u002Fdiagram-04.svg",[215,373,375],{"id":374},"why-380-characters","Why 380 characters?",[29,377,378,379,382,383,386,387,382,390,393,394,397],{},"The negotiated ATT MTU is ",[153,380,381],{},"517 bytes on Android"," (",[83,384,385],{},"requestMtu(517)"," — the\nmaximum allowed by the BLE specification) and ~185+ on iOS (auto-negotiated by\nCoreBluetooth). Subtracting the ATT header (~3 bytes) and the JSON wrapper\noverhead of ",[83,388,389],{},"BleSegmentData",[83,391,392],{},"{\"d\":\"...\",\"s\":N}"," adds ~12 bytes), 380 chars\nof payload fits comfortably within a single ATT MTU on both platforms. The\nvalue is ",[153,395,396],{},"symmetric"," (both client request fragments and server notification\nfragments use 380), which keeps the code simple.",[215,399,401],{"id":400},"why-16-kib-for-file-chunks","Why 16 KiB for file chunks?",[29,403,404,405,408],{},"A 16 KiB file chunk base64-encodes to ~22 KiB of JSON, which fragments into\n~58 GATT notification segments. Each ",[83,406,407],{},"requestAsync"," round-trip takes seconds\nover BLE, so fewer-but-larger chunks reduce per-chunk overhead. Going much\nlarger would risk hitting BLE RPC timeouts and produce poor progress feedback\n(the user sees progress update only once per chunk). 16 KiB is the empirically\ntuned sweet spot — large enough for throughput, small enough for responsive\nprogress UI.",[44,410,81,412],{"id":411},"the-rpc-primitive-bledeviceapirequestasync",[83,413,85],{},[29,415,416,417,420,421,424],{},"Every BLE chat message and every file chunk is one call to\n",[83,418,419],{},"BleDeviceApi.requestAsync(service, requestData)"," — a suspend function that\nreturns a ",[83,422,423],{},"BleResult",". It is synchronous from the caller's perspective:\none request → one fully reassembled response, no pipelining.",[29,426,427],{},[190,428],{"alt":429,"src":430},"Diagram 5","\u002Fblog\u002Fble-transport\u002Fdiagram-05.svg",[215,432,434],{"id":433},"key-invariants","Key invariants",[331,436,437,446,455],{},[52,438,439,442,443,445],{},[153,440,441],{},"One request → one response."," ",[83,444,407],{}," is synchronous from the\ncaller's perspective — it returns only after the full response has been\nreassembled. There is no pipelining.",[52,447,448,451,452,454],{},[153,449,450],{},"Notifications enabled per-call."," The client writes the CCCD at the\nstart of every ",[83,453,407],{}," and disables it at the end. This is\nwasteful (two extra GATT writes per call) but keeps the protocol\nstateless — the server doesn't have to track which clients are\n\"listening\".",[52,456,457,460,461,464,465,468,469,472],{},[153,458,459],{},"No retry within an RPC."," If any single ",[83,462,463],{},"writeCharacteristic"," times out\n(5 s), the entire RPC aborts. Only ",[83,466,467],{},"ensureConnected"," retries (3 attempts\non connect failure). Coarse transport-level backoff is provided by\n",[83,470,471],{},"PeerCircuitBreaker",", not by the RPC layer.",[44,474,91],{"id":475},"wire-envelope-format",[29,477,478],{},"The payload inside Layer A segments is a nested JSON envelope. Stripping the\nfragmentation, the logical structure is:",[29,480,481],{},[190,482],{"alt":483,"src":484},"Diagram 6","\u002Fblog\u002Fble-transport\u002Fdiagram-06.svg",[215,486,488],{"id":487},"response-shape","Response shape",[29,490,491,492,495,496,499,500,503,504,507,508,511,512,515,516,518],{},"The response flows in the opposite direction through the same Layer A\nfragmentation, but the inner JSON is a ",[83,493,494],{},"BleHttpResponse"," with three fields:\n",[83,497,498],{},"s"," (HTTP status code), ",[83,501,502],{},"h"," (response headers map), and ",[83,505,506],{},"b"," (body). The body\nis ",[153,509,510],{},"always base64-encoded"," by ",[83,513,514],{},"BleHttpCall.encodeResponse()",", even when\nempty — the response might be binary (encrypted GraphQL bytes, raw ",[83,517,253],{},"\nfile bytes) and the BLE transport is string-only, so the same JSON envelope\ncarries both text and binary payloads.",[44,520,97],{"id":521},"message-send-path-end-to-end",[29,523,524],{},"Putting it all together — what happens when a chat message is sent over BLE:",[29,526,527],{},[190,528],{"alt":529,"src":530},"Diagram 7","\u002Fblog\u002Fble-transport\u002Fdiagram-07.svg",[215,532,534],{"id":533},"notable-design-choices","Notable design choices",[49,536,537,559,579],{},[52,538,539,542,543,546,547,550,551,554,555,558],{},[153,540,541],{},"Same key as LAN."," The ChaCha20 shared key from pairing is reused for\nBLE — there's no separate BLE key. The OkHttp crypto interceptor used by\n",[83,544,545],{},"LanTransport"," and the manual ",[83,548,549],{},"chaCha20Encrypt","\u002F",[83,552,553],{},"chaCha20Decrypt"," in\n",[83,556,557],{},"BleTransport"," are the same primitive, just invoked differently.",[52,560,561,442,564,567,568,571,572,250,574,250,576,578],{},[153,562,563],{},"Same route handlers as LAN.",[83,565,566],{},"BleHttpRequest"," is dispatched through\n",[83,569,570],{},"HttpRouteRegistry.matchRoute(path)",", which is the same registry the Ktor\nLAN server uses. So ",[83,573,249],{},[83,575,253],{},[83,577,256],{}," etc. are\nimplemented exactly once and work identically over both transports.",[52,580,581,584,585,588,589,42],{},[153,582,583],{},"No connection reuse."," The ",[83,586,587],{},"finally { scanner.teardownConnection(client) }","\nblock always runs. Each message pays the full connect→discoverServices→MTU\ncost (~seconds). This is a deliberate trade-off — see\n",[33,590,591],{"href":144},"Design Trade-offs",[44,593,103],{"id":594},"file-download-path-end-to-end",[29,596,597,598,601,602,442,605,607,608,611],{},"Downloads over BLE are ",[153,599,600],{},"streaming"," — the file is read in 16 KiB chunks and\nwritten to a temp file as it arrives, so a 10 MB file doesn't need 10 MB of\nRAM. The trick is that each chunk's RPC is a ",[346,603,604],{},"separate",[83,606,407],{}," call,\nand the chunks are pushed into a ",[83,609,610],{},"ByteChannel"," that the consumer reads\nconcurrently.",[29,613,614],{},[190,615],{"alt":616,"src":617},"Diagram 8","\u002Fblog\u002Fble-transport\u002Fdiagram-08.svg",[215,619,621],{"id":620},"why-streaming-instead-of-one-big-rpc","Why streaming instead of one big RPC?",[29,623,624],{},"A 10 MB file sent as a single RPC would mean ~280 000 notification segments,\nall held in memory on both sides before the response could even start — and\nthe entire transfer would have to succeed before any progress is reported.\nWorse, a single dropped notification in the middle would corrupt the whole\nthing.",[29,626,627],{},"The chunked design has three wins:",[331,629,630,636,645],{},[52,631,632,635],{},[153,633,634],{},"Constant memory."," Only one 16 KiB chunk is in flight at a time.",[52,637,638,442,641,644],{},[153,639,640],{},"Live progress.",[83,642,643],{},"DownloadQueue.notifyProgressUpdate()"," fires every\nsecond, and the UI shows a download bar.",[52,646,647,650,651,654],{},[153,648,649],{},"Resilience."," A failed chunk can be retried independently (the\n",[83,652,653],{},"DownloadQueue"," supports pause\u002Fresume\u002Fretry at the task level; a\nmid-stream failure leaves the partial temp file, though currently the\ndownloader deletes it on failure — see trade-offs).",[215,656,658,659,662],{"id":657},"why-onclose-cancels-the-download-job","Why ",[83,660,661],{},"onClose"," cancels the download job",[29,664,665,666,669,670,673,674,677,678,681,682,685,686,689,690,692,693,696],{},"The ",[83,667,668],{},"DownloadedResponse.onClose"," callback calls ",[83,671,672],{},"downloadJob.cancel()",". This\nis essential because the download loop runs in a child coroutine that would\notherwise keep running forever if the consumer abandoned the channel early\n(e.g. user tapped ",[153,675,676],{},"Pause","). The ",[83,679,680],{},"AutoCloseable"," contract on\n",[83,683,684],{},"DownloadedResponse"," means the consumer's ",[83,687,688],{},"use { ... }"," block automatically\ninvokes ",[83,691,661],{}," on exit, cancelling the BLE download coroutine and tearing\ndown the GATT connection in the coroutine's ",[83,694,695],{},"finally"," block.",[44,698,109],{"id":699},"prioritization-how-chat-beats-files-in-practice",[29,701,702,703],{},"This is the most important question for any chat application: ",[153,704,705],{},"when a slow\nBLE file download is in progress, can a new chat message jump ahead of it?",[215,707,709],{"id":708},"the-honest-answer-there-is-no-explicit-priority-scheme","The honest answer: there is no explicit priority scheme",[29,711,712,713,716,717,720,721,724],{},"There is ",[153,714,715],{},"no priority field, no priority queue, no preemption"," anywhere in\nthe BLE code or the download queue. I verified this by exhaustive grep — the\nonly ",[83,718,719],{},"priority"," matches in ",[83,722,723],{},"shared\u002Fsrc"," are log-priority levels and EXIF\nmetadata, nothing related to message-vs-download ordering.",[29,726,727,728,731],{},"What exists instead is a set of ",[153,729,730],{},"architectural separations"," that produce\nthe desired behavior as an emergent property:",[29,733,734],{},[190,735],{"alt":736,"src":737},"Diagram 9","\u002Fblog\u002Fble-transport\u002Fdiagram-09.svg",[215,739,741],{"id":740},"why-it-works-in-practice","Why it works in practice",[29,743,744],{},"The separation that makes chat \"feel prioritized\" is structural:",[331,746,747,766,775],{},[52,748,749,754,755,758,759,758,762,765],{},[153,750,751,752,42],{},"Chat sends don't go through ",[83,753,653],{}," They're issued directly\nby ",[83,756,757],{},"PeerGraphQLClient"," → ",[83,760,761],{},"PeerTransportRouter",[83,763,764],{},"BleTransport.send",". So\na chat message never sits behind a queue of file downloads.",[52,767,768,774],{},[153,769,770,771,773],{},"Each ",[83,772,557],{}," call opens its own GATT connection."," A\nlong-running download holding one connection does not prevent a chat\nsend from opening a second connection to the same peer. Android\nsupports multiple simultaneous GATT connections.",[52,776,777,780,781,783],{},[153,778,779],{},"Chat RPCs are short."," A single chat message is one ",[83,782,407],{},"\nround trip (~1 s after connect). Even if the radio is busy with a\ndownload, the chat send completes within a few seconds.",[215,785,787],{"id":786},"where-the-design-falls-short","Where the design falls short",[29,789,790],{},"The trade-offs of \"no explicit priority\":",[49,792,793,799,816],{},[52,794,795,798],{},[153,796,797],{},"Connect latency."," Both chat and download pay the connect→discover→MTU\ncost (~seconds) every time, because connections aren't reused. A chat\nmessage arriving during a download can't piggyback on the download's\nexisting connection — it opens a new one.",[52,800,801,804,805,554,808,811,812,815],{},[153,802,803],{},"Static queue on Android."," The process-wide ",[83,806,807],{},"operationQueue",[83,809,810],{},"AndroidBleGattClient"," serializes GATT ops across all peers and all\nconnections. So while two GATT connections can coexist, their\nwrite\u002Fread\u002Fnotify ",[346,813,814],{},"operations"," are interleaved at the queue level. In\npractice this is fine (each op is ~ms) but it's a subtle global\nbottleneck under high concurrency.",[52,817,818,821],{},[153,819,820],{},"No preemption."," A download in progress cannot be paused to let a\nchat message through. The chat send simply runs concurrently and\ncompetes for radio time.",[29,823,824,825,828,829,831,832,835],{},"A future improvement could be a per-peer ",[83,826,827],{},"Mutex"," around ",[83,830,764],{},"\nand ",[83,833,834],{},"downloadFile",", plus a priority field on the queue — but the current\ndesign relies on the fact that chat RPCs are short enough that contention\nis rarely user-visible.",[44,837,115],{"id":838},"concurrency-control-the-static-gatt-queue",[29,840,841],{},"This deserves its own section because it's the most subtle aspect of the\nAndroid BLE implementation.",[29,843,844],{},[190,845],{"alt":846,"src":847},"Diagram 10","\u002Fblog\u002Fble-transport\u002Fdiagram-10.svg",[215,849,851],{"id":850},"why-static-process-wide","Why static (process-wide)?",[29,853,854,855,862,863,865,866,869,870,872,873,876,877,880],{},"The Android BLE stack ",[153,856,857,858,861],{},"does not allow concurrent GATT operations on a single\n",[83,859,860],{},"BluetoothGatt"," instance"," — calling ",[83,864,463],{}," while another\nwrite is in flight returns ",[83,867,868],{},"false"," and silently drops the second write. The\nstandard workaround is a per-",[83,871,860],{}," queue. PlainApp goes one step\nfurther and uses a ",[153,874,875],{},"process-wide"," queue (in the ",[83,878,879],{},"companion object","), which\nis overly conservative but correct: it guarantees no two GATT operations\nanywhere in the app run simultaneously.",[29,882,883],{},"The cost is that a long BLE file download's write\u002Fread\u002Fnotify operations\nqueue behind (and are queued behind) any other peer's GATT operations.\nSince each individual op is ~ms, this is rarely a user-visible bottleneck —\nbut under heavy concurrent BLE traffic to multiple peers, it could become\none.",[215,885,887],{"id":886},"no-per-peer-lock-at-the-transport-layer","No per-peer lock at the transport layer",[29,889,890,892,893,896,897,900,901,903,904,907],{},[83,891,85],{}," is a plain ",[83,894,895],{},"suspend fun"," with ",[153,898,899],{},"no mutex, no\nqueue, no per-peer serialization",". Two concurrent calls to\n",[83,902,764],{}," for the same peer will each open their own GATT\nconnection and proceed independently. The serialization happens implicitly\nat the GATT operation level (via the static queue on Android, or via\nsequential ",[83,905,906],{},"await"," on iOS).",[44,909,121],{"id":910},"connection-lifecycle-mtu-negotiation",[29,912,913],{},[190,914],{"alt":915,"src":916},"Diagram 11","\u002Fblog\u002Fble-transport\u002Fdiagram-11.svg",[215,918,658,920,922],{"id":919},"why-requestmtu517",[83,921,385],{},"?",[29,924,925,926,929,930,933],{},"The default ATT MTU is ",[153,927,928],{},"23 bytes"," (only ",[153,931,932],{},"20 bytes"," of payload after the\n3-byte ATT header). With the default MTU, every 380-char segment would require\n~19 GATT writes instead of 1 — a 19× slowdown. Requesting the maximum MTU\nallowed by the BLE spec (517 bytes) lets the 380-char segments fit in a\nsingle ATT operation, dramatically improving throughput.",[29,935,936],{},"iOS doesn't expose an explicit MTU request API — CoreBluetooth negotiates it\nautomatically with the peripheral during connection. Modern iOS devices\ntypically negotiate ~185 bytes, which still comfortably fits the 380-char\nsegments (after subtracting ATT header + JSON wrapper overhead).",[44,938,127],{"id":939},"flow-control-for-notifications",[29,941,942,943,946],{},"The server sends response fragments as notifications, but BLE notifications\nhave ",[153,944,945],{},"no built-in flow control"," — if the server sends notifications faster\nthan the controller can transmit them, they are silently dropped. PlainApp\nimplements explicit ack-based flow control:",[29,948,949],{},[190,950],{"alt":951,"src":952},"Diagram 12","\u002Fblog\u002Fble-transport\u002Fdiagram-12.svg",[29,954,955,956,959],{},"Without this flow control, back-to-back notifications would be silently\ndropped by the BLE controller when its internal send queue fills up — a\nwell-known Android BLE issue documented in the ",[83,957,958],{},"BleGattServer"," interface\ncomments. The per-device single-in-flight rule guarantees that every\nnotification is either transmitted or triggers a timeout (which is then\ntreated as a transport failure).",[44,961,133],{"id":962},"error-handling-transportunavailable-vs-real-failure",[29,964,965,968,969,971,972,975],{},[83,966,967],{},"TransportUnavailable"," is the signal that tells ",[83,970,761],{}," to\n",[153,973,974],{},"fall through to the next transport",". Anything else is a real failure\nreturned to the caller.",[29,977,978],{},[190,979],{"alt":980,"src":981},"Diagram 13","\u002Fblog\u002Fble-transport\u002Fdiagram-13.svg",[215,983,985],{"id":984},"the-download-failure-subtlety","The download failure subtlety",[29,987,988,991,992,995,998,999,1002,1003,1006,1007,42],{},[83,989,990],{},"BleTransport.downloadFile"," returns ",[83,993,994],{},"DownloadedResponse(200, channel, onClose)",[153,996,997],{},"immediately"," — the chunked download loop runs in a background coroutine\nthat writes to the channel. If a chunk RPC fails mid-stream, the loop calls\n",[83,1000,1001],{},"channel.close(TransportUnavailable(...))",", which means the consumer\n(",[83,1004,1005],{},"PeerFileDownloader.downloadAsync",") sees the error as a thrown exception\nfrom ",[83,1008,1009],{},"channel.readAvailable(buf)",[29,1011,1012,1013,1016,1017,1019,1020,1023],{},"This means the ",[83,1014,1015],{},"PeerTransportRouter.downloadFile"," call itself succeeded\n(returned a ",[83,1018,684],{},"), so the circuit breaker ",[153,1021,1022],{},"does not"," record\na failure for mid-stream download errors. Only connect-time and scan-time\nfailures are caught by the router. This is a deliberate design choice — a\nmid-stream failure shouldn't permanently disable BLE for that peer (the\npeer might just have gone out of range temporarily).",[44,1025,139],{"id":1026},"key-constants-reference",[1028,1029,1030,1049],"table",{},[1031,1032,1033],"thead",{},[1034,1035,1036,1040,1043,1046],"tr",{},[1037,1038,1039],"th",{},"Constant",[1037,1041,1042],{},"Value",[1037,1044,1045],{},"Where",[1037,1047,1048],{},"Purpose",[1050,1051,1052,1073,1091,1110,1128,1146,1163,1180,1197,1215,1230,1245,1262,1280,1300,1316,1331,1346,1362,1377,1393],"tbody",{},[1034,1053,1054,1060,1063,1066],{},[1055,1056,1057],"td",{},[83,1058,1059],{},"BleDeviceApi.CHUNK_SIZE",[1055,1061,1062],{},"380",[1055,1064,1065],{},"GATT segment fragmentation",[1055,1067,1068,1069,1072],{},"Size of each ",[83,1070,1071],{},"BleSegmentData.data"," (fits within ATT MTU after JSON overhead)",[1034,1074,1075,1080,1083,1086],{},[1055,1076,1077],{},[83,1078,1079],{},"BleTransport.CHUNK_SIZE",[1055,1081,1082],{},"16 384 (16 KiB)",[1055,1084,1085],{},"File-download byte-range",[1055,1087,1068,1088,1090],{},[83,1089,253],{}," chunk request",[1034,1092,1093,1098,1101,1104],{},[1055,1094,1095],{},[83,1096,1097],{},"BleTransport.SCAN_TIMEOUT_MS",[1055,1099,1100],{},"10 000",[1055,1102,1103],{},"BLE scan",[1055,1105,1106,1107],{},"Timeout for ",[83,1108,1109],{},"scanner.findOne",[1034,1111,1112,1117,1120,1123],{},[1055,1113,1114],{},[83,1115,1116],{},"BleDeviceApi.NOTIFY_TIMEOUT_MS",[1055,1118,1119],{},"15 000",[1055,1121,1122],{},"RPC response",[1055,1124,1125,1126],{},"Per-notification wait in ",[83,1127,407],{},[1034,1129,1130,1135,1138,1141],{},[1055,1131,1132,1134],{},[83,1133,810],{}," MTU",[1055,1136,1137],{},"517",[1055,1139,1140],{},"Connection setup",[1055,1142,1143,1145],{},[83,1144,385],{}," — max allowed by BLE spec",[1034,1147,1148,1153,1155,1157],{},[1055,1149,1150,1152],{},[83,1151,810],{}," connect timeout",[1055,1154,1100],{},[1055,1156,1140],{},[1055,1158,1159,1160],{},"Wait for ",[83,1161,1162],{},"STATE_CONNECTED",[1034,1164,1165,1170,1173,1175],{},[1055,1166,1167,1169],{},[83,1168,810],{}," MTU timeout",[1055,1171,1172],{},"5 000",[1055,1174,1140],{},[1055,1176,1159,1177],{},[83,1178,1179],{},"onMtuChanged",[1034,1181,1182,1187,1189,1192],{},[1055,1183,1184,1186],{},[83,1185,810],{}," write timeout",[1055,1188,1172],{},[1055,1190,1191],{},"GATT write",[1055,1193,1159,1194],{},[83,1195,1196],{},"onCharacteristicWrite",[1034,1198,1199,1204,1206,1209],{},[1055,1200,1201,1203],{},[83,1202,810],{}," read timeout",[1055,1205,1100],{},[1055,1207,1208],{},"GATT read",[1055,1210,1159,1211,1214],{},[83,1212,1213],{},"onCharacteristicRead"," (unused for real data)",[1034,1216,1217,1222,1224,1227],{},[1055,1218,1219,1221],{},[83,1220,810],{}," notify-state timeout",[1055,1223,1172],{},[1055,1225,1226],{},"CCCD write",[1055,1228,1229],{},"Wait for CCCD descriptor write",[1034,1231,1232,1237,1240,1242],{},[1055,1233,1234,1236],{},[83,1235,467],{}," retries",[1055,1238,1239],{},"3",[1055,1241,1140],{},[1055,1243,1244],{},"Up to 4 total attempts (0..3)",[1034,1246,1247,1252,1254,1257],{},[1055,1248,1249],{},[83,1250,1251],{},"AndroidBleGattServer.NOTIFY_ACK_TIMEOUT_MS",[1055,1253,1100],{},[1055,1255,1256],{},"Notification flow control",[1055,1258,1159,1259],{},[83,1260,1261],{},"onNotificationSent",[1034,1263,1264,1270,1272,1275],{},[1055,1265,1266,1269],{},[83,1267,1268],{},"AndroidBleGattServer"," notifyChunkSize",[1055,1271,1062],{},[1055,1273,1274],{},"Response fragmentation",[1055,1276,1277,1278],{},"Same as ",[83,1279,1059],{},[1034,1281,1282,1288,1291,1293],{},[1055,1283,1284,1287],{},[83,1285,1286],{},"IosBleGattServer"," retry cap",[1055,1289,1290],{},"10",[1055,1292,1256],{},[1055,1294,1295,1296,1299],{},"Max ",[83,1297,1298],{},"updateValue"," retries before giving up",[1034,1301,1302,1307,1310,1313],{},[1055,1303,1304],{},[83,1305,1306],{},"PeerCircuitBreaker.WINDOW_MS",[1055,1308,1309],{},"30 000",[1055,1311,1312],{},"Transport circuit breaker",[1055,1314,1315],{},"Open duration after threshold",[1034,1317,1318,1323,1326,1328],{},[1055,1319,1320],{},[83,1321,1322],{},"PeerCircuitBreaker.MAX_FAILURES",[1055,1324,1325],{},"2",[1055,1327,1312],{},[1055,1329,1330],{},"Failures within window to open",[1034,1332,1333,1338,1340,1343],{},[1055,1334,1335],{},[83,1336,1337],{},"DownloadQueue.MAX_CONCURRENT",[1055,1339,1239],{},[1055,1341,1342],{},"Download worker pool",[1055,1344,1345],{},"Concurrent download coroutines",[1034,1347,1348,1353,1356,1359],{},[1055,1349,1350],{},[83,1351,1352],{},"BleServiceData.SHORT_ID_BYTES",[1055,1354,1355],{},"8",[1055,1357,1358],{},"Peer identification",[1055,1360,1361],{},"Truncated SHA256 prefix bytes",[1034,1363,1364,1369,1372,1374],{},[1055,1365,1366],{},[83,1367,1368],{},"BleServiceData.PAYLOAD_BYTES",[1055,1370,1371],{},"9",[1055,1373,1358],{},[1055,1375,1376],{},"1 flags byte + 8 shortId bytes",[1034,1378,1379,1384,1387,1390],{},[1055,1380,1381],{},[83,1382,1383],{},"BleSegmentData.STATE_START_BIT",[1055,1385,1386],{},"1",[1055,1388,1389],{},"Layer A EOF signaling",[1055,1391,1392],{},"First segment of a multi-segment message",[1034,1394,1395,1400,1402,1404],{},[1055,1396,1397],{},[83,1398,1399],{},"BleSegmentData.STATE_END_BIT",[1055,1401,1325],{},[1055,1403,1389],{},[1055,1405,1406],{},"Last segment (or single segment)",[44,1408,145],{"id":1409},"design-trade-offs-recap",[29,1411,1412],{},[190,1413],{"alt":1414,"src":1415},"Diagram 14","\u002Fblog\u002Fble-transport\u002Fdiagram-14.svg",[44,1417,1419],{"id":1418},"further-reading","Further Reading",[49,1421,1422,1434],{},[52,1423,1424,1426,1427,1429,1430,1433],{},[33,1425,36],{"href":35}," — how ",[83,1428,557],{}," fits into\nthe ",[83,1431,1432],{},"LAN → Aware → BLE"," fallback chain and the broader chat send\u002Freceive\npipeline.",[52,1435,1436,1438],{},[33,1437,41],{"href":40}," — how the shared ChaCha20 key used by\nevery BLE payload is established, and how the NEARBY characteristic is\nused for the pairing handshake.",{"title":1440,"searchDepth":1441,"depth":1441,"links":1442},"",3,[1443,1445,1446,1450,1453,1457,1461,1464,1467,1472,1477,1481,1485,1486,1489,1490,1491],{"id":46,"depth":1444,"text":47},2,{"id":148,"depth":1444,"text":57},{"id":196,"depth":1444,"text":63,"children":1447},[1448,1449],{"id":217,"depth":1441,"text":218},{"id":260,"depth":1441,"text":261},{"id":301,"depth":1444,"text":69,"children":1451},[1452],{"id":321,"depth":1441,"text":322},{"id":362,"depth":1444,"text":75,"children":1454},[1455,1456],{"id":374,"depth":1441,"text":375},{"id":400,"depth":1441,"text":401},{"id":411,"depth":1444,"text":1458,"children":1459},"The RPC Primitive: BleDeviceApi.requestAsync",[1460],{"id":433,"depth":1441,"text":434},{"id":475,"depth":1444,"text":91,"children":1462},[1463],{"id":487,"depth":1441,"text":488},{"id":521,"depth":1444,"text":97,"children":1465},[1466],{"id":533,"depth":1441,"text":534},{"id":594,"depth":1444,"text":103,"children":1468},[1469,1470],{"id":620,"depth":1441,"text":621},{"id":657,"depth":1441,"text":1471},"Why onClose cancels the download job",{"id":699,"depth":1444,"text":109,"children":1473},[1474,1475,1476],{"id":708,"depth":1441,"text":709},{"id":740,"depth":1441,"text":741},{"id":786,"depth":1441,"text":787},{"id":838,"depth":1444,"text":115,"children":1478},[1479,1480],{"id":850,"depth":1441,"text":851},{"id":886,"depth":1441,"text":887},{"id":910,"depth":1444,"text":121,"children":1482},[1483],{"id":919,"depth":1441,"text":1484},"Why requestMtu(517)?",{"id":939,"depth":1444,"text":127},{"id":962,"depth":1444,"text":133,"children":1487},[1488],{"id":984,"depth":1441,"text":985},{"id":1026,"depth":1444,"text":139},{"id":1409,"depth":1444,"text":145},{"id":1418,"depth":1444,"text":1419},"Transport","2025-01-25","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.","md",{},true,"\u002Fblog\u002Fble-transport","15 min read",{"title":24,"description":1494},"Need to send files and chat over Bluetooth Low Energy? See how PlainApp's BLE transport handles wire format, chunking, and connection lifecycle.","How to Send Files & Messages Over Bluetooth Low Energy (BLE)","blog\u002Fble-transport","c4x-_S6J3UbNNVXfXpejn_YF4Ki9Hdb6HpYTWkVbCXM",{"left":4,"top":4,"width":5,"height":5,"rotate":4,"vFlip":6,"hFlip":6,"body":1506},"\u003Cpath fill=\"currentColor\" d=\"M224 128a8 8 0 0 1-8 8H59.31l58.35 58.34a8 8 0 0 1-11.32 11.32l-72-72a8 8 0 0 1 0-11.32l72-72a8 8 0 0 1 11.32 11.32L59.31 120H216a8 8 0 0 1 8 8\"\u002F>",1788009009920]