### Apple Receive Throughput # v10 transfers ran at 43 mbps on a 900 mbps link (RESOLVED, 2026-07-25) **Cause: the Rust sender was built in debug mode.** `snow`'s ChaCha20-Poly1305 runs at ~13.8 MB/s at `opt-level = 0` versus ~291 MB/s in release, and `EncryptedStream::poll_write` encrypts *inside* the write call — so the sender was CPU-bound at ~5 MB/s while appearing, to its own instrumentation, to be "blocked on the socket". A release build moved the same transfer from **43 mbps to 907.8 mbps**, matching iperf3's 907 mbps ceiling for this link. Nothing on the Apple side was at fault. The receiver was idle 95% of every transfer. **Fix on the Rust side:** - Build release for any throughput testing, and - `[profile.dev.package."*"] opt-level = 3` in the workspace `Cargo.toml`, so dev builds optimize dependencies while keeping app code debuggable. - Fix the sender's `Diag:` line before deleting it: it reports time in `write_all` as "socket", but that call encrypts too. **That single mislabel sent this entire investigation to the wrong machine.** ## What this cost, and the lesson Three confident theories about the Swift receiver were each disproven by the next measurement: | theory | disproved by | |---|---| | Too many async round trips per 64KB record | Cutting receives per MB 8x moved throughput 0.7% | | The TCP window collapses between on-demand reads | `NWConnection` hit 892 mbps with 45KB reads, 2400/sec | | 64KB Noise records are too small | Prefetching whole 5MB chunks (153 receives -> 2) moved it 0% | The receiver was never measured directly until late; every number came from the sender, whose timer conflated CPU with I/O. **Measure the machine you suspect, and distrust any timer wrapping a call that does both compute and I/O.** ## Symptom (as originally reported) Windows (Rust sender) → macOS (Swift receiver), shared network mode, 4.58 GB file: **~43 mbps (5.35 MB/s)**, where SMB moved the same file, from the same disk, over the same network, to the same MacBook at ~600 mbps. Later measured directly: a single plain TCP stream over that link does **907 mbps**. ## What the measurements say A temporary diagnostic in the Rust sender (`core/src/sending.rs`) splits the send loop's wall time into disk-wait and socket-wait, reported every 5s: ``` Diag: 43.2mbps over last 5s — 27 chunks: disk 0.2ms/chunk, socket 185.0ms/chunk (1MB chunks) Diag: 42.8mbps over last 6s — 6 chunks: disk 1.0ms/chunk, socket 933.5ms/chunk (5MB chunks) ``` - **The sender is blocked on the socket ~100% of the time.** It is not the network and not the sender: TCP flow control is throttling it because the Mac drains at 5.35 MB/s. - **The disk is irrelevant** (0.2-1.0 ms/chunk; the file is in the page cache). - **The limit is per byte, not per chunk.** 5x the chunk size gave 5x the time per chunk and *identical* throughput. So chunk size is not the lever. ## Ruled out, with the evidence | Hypothesis | Verdict | |---|---| | ChaCha20-Poly1305 slower than v9's AES-GCM | **This was the right neighbourhood and was dismissed too fast.** The benchmark below was run with `cargo test --release`, so it never exercised the debug build the app actually ran. `core/tests/throughput.rs` measures 968 MB/s encrypt, 1633 MB/s decrypt, and 769 MB/s through the whole Rust `EncryptedStream` over loopback TCP — 150x the observed rate. | | Nagle's algorithm on the sender | No. The send loop writes a small length prefix then immediately queues the body, so Nagle coalesces rather than stalling; it needs a write-write-*read* pattern. `set_nodelay(true)` was added anyway (correct for this protocol) and changed nothing. | | Chunk size | No. See above — 1 MB and 5 MB give the same throughput. | | Sender's spinning disk | No. 0.2 ms per 1 MB read. | | macOS app built in Debug | No. A Release build measured the same, which also rules out Swift-level compute cost and points at per-call latency. **Note the irony: the *receiver's* build mode was checked and cleared, the *sender's* was never asked about. That was the answer.** | ## Mechanism — WRONG, kept as a record of the wrong turn *Everything in this section was disproven. The reasoning is plausible and the code reading is accurate; the conclusion is not. Reducing these round trips 8x changed throughput by 0.7%.* ### The (incorrect) claim: a v10 regression introduced with Noise v9 read a whole chunk in one call, so Network.framework could deliver up to 5 MB per callback: ```swift // v9, Apple/shared/Receive.swift (commit 6996b38~1) receiveNBytes(n: chunkSize) // -> receive(minimumIncompleteLength: 0, maximumLength: 5_000_000) ``` v10 routes the same read through `NoiseConnection`, which pulls **one 64 KB Noise record at a time**, and every record costs two async receives — `Apple/shared/Noise.swift:333`: ```swift private func readNoiseFrame(_ tcp: any TCPConnectionProtocol) async throws -> Data { let lenBytes = try await tcp.receiveNBytes(n: 2) // asks Network.framework for 2 bytes let len = (Int(b[0]) << 8) | Int(b[1]) return try await tcp.receiveNBytes(n: len) // capped at 65535 } ``` Two things got worse at once: 1. **Max delivery per callback fell from 5,000,000 bytes to 65,535.** 2. **A mandatory 2-byte round trip was added per record.** A 4.58 GB file is ~70,000 records, so at minimum ~140,000 async receives where v9 needed a few thousand. It is worse than that in practice because `receiveUpToNBytes` (as it then was, in `Apple/shared/Network.swift`) passed `minimumIncompleteLength: 0`, which let each call return as little as a single TCP segment — 3M+ receives for this file. This is per byte (records are 64 KB regardless of chunk size), which is exactly what the measurements show. The Rust receiver does structurally similar small reads (8 KiB per `poll_read` in `EncryptedStream`) but they are ordinary syscalls at ~1-2 µs, not dispatch hops with checked continuations — hence Rust→Rust is unaffected. ## What was changed on the Apple side (kept, but not the fix) Local to Swift, no wire change, no KAT impact, no coordination with the Rust or Kotlin ports. `NoiseConnection` already buffered *plaintext* above the framing; what was missing was the equivalent buffering of *ciphertext* underneath it. 1. **`BufferedCiphertextReader`** (`Apple/shared/Noise.swift`) sits between `NWConnection` and the Noise framing. It pulls `NOISE_SOCKET_BLOCK` (256 KB) at a time and serves the 2-byte lengths and 64 KB record bodies out of memory, so ~4 records cost one socket read instead of 8. `noiseHandshake` wraps the connection once and hands that same instance to the `NoiseConnection` it returns — a handshake read may pull transport bytes in with it, and they would be stranded if the transport then read from the bare connection. 2. **`minimumIncompleteLength` is now the caller's real minimum**, not 0 (`TCPConnectionProtocol.receiveSome` in `Apple/shared/Network.swift`, replacing `receiveUpToNBytes`). Every read here is of a protocol field whose length is already known, so the transport is asked for the whole remainder and delivers it in one callback instead of one per TCP segment. The minimum is never more than what the peer already owes — this protocol has points where the peer is waiting on our reply, so over-asking would deadlock. - `receiveSome` became a protocol *requirement* rather than an extension-only method. The wrappers (`RecordingTCPConnection`, `BufferedCiphertextReader`, `NoiseConnection`) have to serve reads from their own state; as an extension method it dispatched statically and a caller holding a wrapper would read straight off the socket, around the buffer or the preamble transcript. 3. **No O(remaining) drains.** The ciphertext buffer uses an offset that compacts once per refill. Above the framing, `NoiseConnection.receiveSome` accumulates decrypted records directly into the buffer it returns and keeps at most one record's tail, so a 5 MB chunk read no longer copies 5 MB out of a staging buffer and then memmoves the remainder. Also on the send side: `NoiseConnection.write` coalesces records into 256 KB batches, so a 5 MB chunk costs 20 awaited sends instead of 77. Same framing on the wire — only the number of `NWConnection.send` calls changes. **This did not fix the bug** (43.2 → 43.5 mbps on hardware). It is kept because it is correct on its own terms and measures 1.24x on loopback, and because it makes the receive count a variable we can now rule out. `NoiseTransportBufferingTests` covers it: transparency across delivery sizes, unaligned reads spanning record boundaries, unchanged send framing, and ~4 socket receives per megabyte instead of 32+. ## The receiver instrumentation (since removed) While this was open, `Apple/shared/Receive.swift` emitted the counterpart to the Rust sender's line every 5s. It has been removed now that the bug is closed; the patch is recoverable from the session scratchpad (`instrumentation.patch`) if it is ever wanted again: ``` Diag: 43.5mbps over last 5s — 6 chunks: socket 900.0ms, decrypt 8.0ms, disk 1.4ms, other 2.0ms per chunk; 20 socket reads averaging 256KB ``` `socket` is time blocked inside `NWConnection.receive`; `decrypt` is time inside `ChaChaPoly.open`; `disk` is `seekToEnd` + `write`. The averaging figure is how much each receive actually returned. ## What the receiver actually reports (2026-07-25, 4.58 GB Windows → macOS) ``` Diag: 43.6mbps over last 6s — 6 chunks: socket 874.8ms, decrypt 33.9ms, disk 2.3ms, other 7.0ms per chunk; 918 socket reads averaging 32KB Diag: 43.2mbps over last 6s — 6 chunks: socket 884.9ms, decrypt 30.8ms, disk 3.5ms, other 7.6ms per chunk; 913 socket reads averaging 32KB ``` **The Mac is idle 95% of the time.** Decrypt 34 ms, disk 2-3 ms, our own copying 7 ms — 44 ms of work per 5 MB chunk against 875 ms of waiting. Every candidate inside this app is now measured and none of them is the bottleneck. The shape of the waiting: **153 reads per chunk, 5.7 ms blocked each, 32 KB returned each**, where 256 KB was asked for. Blocked-then-32KB means the kernel buffer was *empty* when we asked — the receiver is starved, not busy. 32 KB per 5.7 ms is 5.6 MB/s, which is the entire deficit, and it is the signature of a window-over-RTT limit. Both ends are now known to be blocked: the sender in `write_all`, the receiver in `receive`. That puts the bottleneck between them — the path, or TCP flow control over it — not in either program's compute. The link is not the suspect: SMB moves 600 mbps between these two machines over the same path, and the slowness is not specific to shared network mode (it was chosen for testing precisely because it is the *faster* path and gives SMB as a control). ## What v9 actually did differently From the v9 source (`Apple/shared/Receive.swift` at commit d5c018d): ```swift chunkBytes = try await tcp.receiveNBytes(n: chunkSize) // one request for the whole 5MB ... // nothing but append() in the loop return try AES.GCM.open(sealedBox, using: key) // one decrypt of all 5MB ``` Two things changed in v10, and the measurements say only the first one matters: 1. **The largest outstanding receive fell from 5,000,000 bytes to 65,535.** v9 asked for a whole chunk (`maximumLength` = the full remainder) and did nothing but append between calls. v10 routes the read through the Noise framing, which asks for a 2-byte length and then a ≤64 KB body. Since `NWConnection` drains the kernel socket only while a receive is outstanding, v10 leaves it unattended between every record. **This is the regression.** 2. **Decrypts per chunk went from 1 to 77** (5 MB AES-GCM → 64 KB ChaChaPoly records). Real, but it costs 34 ms of a 920 ms chunk. Making decryption *free* would take 43 mbps to ~46 mbps. Neither the record granularity nor the AES-GCM → ChaCha20 change is the bug. ### The fix `BufferedCiphertextReader.prefetch(atLeast:)`, called by `NoiseConnection.receiveSome` before it reads a chunk's worth of records. The receiver knows the chunk length before the chunk arrives, so it can safely block on one large receive for the whole thing — v9's shape, restored under v10's framing, with no wire change. The bound passed down is a deliberate underestimate of the ciphertext still coming (`n + 18 × floor(n / 65519)`, since each record costs a 2-byte length and a 16-byte tag); asking for more than the peer owes would block forever. ### Result: it worked, and the throughput did not move ``` Diag: 43.3mbps over last 6s — 6 chunks: socket 907.1ms, decrypt 13.8ms, disk 1.1ms, other 1.4ms per chunk; 15 socket reads averaging 1958KB Diag: 43.7mbps over last 5s — 6 chunks: socket 895.5ms, decrypt 14.6ms, disk 1.8ms, other 3.6ms per chunk; 12 socket reads averaging 2442KB ``` Reads per chunk fell from **153 averaging 32 KB** to **2.5 averaging 2 MB**, exactly as intended. Throughput: **43.3 mbps, unchanged.** The Mac now asks for a whole chunk in essentially one receive and still waits 907 ms for it. **This exonerates the receiver.** Bytes arrive at 5.5 MB/s regardless of how they are asked for, so no change to the receive path can matter. Keep the prefetch anyway — it more than halved the per-chunk CPU overhead (decrypt 34→14 ms, other 7→1.4 ms) and restores v9's shape — but it is not the fix for this bug either. **It also retires the read-ahead pump.** Processing is now 17 ms of a 920 ms chunk, so keeping a receive posted during it is worth ≤2%. Not worth ~100 lines of concurrency; the parked copy should be deleted rather than kept. ## Where the bug is not Everything on the receiving Mac is now measured and cleared: | | measured | needed to explain 43 mbps | |---|---|---| | crypto + framing + `NWConnection` (loopback) | 690 MB/s | 5.4 MB/s | | disk write pattern | 3570 MB/s | 5.4 MB/s | | decrypt, in situ | 14 ms per 5 MB chunk | 920 ms | | disk, in situ | 1.1 ms per chunk | 920 ms | | read pattern | 2 MB per receive | — | | Wi-Fi link (802.11ax, 6 GHz, 160 MHz, −60 dBm) | 1441 mbps PHY | 43 mbps | Both ends are blocked at once — the sender in `write_all`, the receiver in `receive` — with a healthy radio on this end. That is a path or sender-side limit. ## The network is fine, and so is Network.framework | receiver | result | |---|---| | iperf3 (BSD sockets) | **907 mbps** | | `NWConnection`, with this app's TCP options, 45 KB average reads | **892 mbps** | | Flying Carpet | 43 mbps | Same Windows sender, same link, same direction, minutes apart. Note the middle row's read size: 45 KB per receive, 2400 receives a second, and it still saturates the link. Read size and read pattern were never the problem — 32 KB reads were a *symptom* of nothing arriving. (The `nwrecv` probe used for this is in the session scratchpad, not the repo.) ## Where it actually points: the sender encrypts inside `write_all` `EncryptedStream::poll_write` (`core/src/noise.rs`) encrypts on the write call: ```rust let take = buf.len().min(MAX_PLAINTEXT); let n = this.noise.write_message(&buf[..take], &mut msg)?; // ChaCha20-Poly1305, here ``` So the sender's `Diag:` line, which reports time in `write_all` as **"socket"**, is really reporting *encrypt + socket*. "Blocked in write_all ~100% of the time" never meant blocked on the network. **That mislabel is what pointed this entire investigation at the Apple side.** `core/Cargo.toml` has `snow = "0.10"` with default features — the pure-Rust `chacha20poly1305` backend — and no `[profile.*]` overrides anywhere in the workspace, so a debug build runs it at `opt-level = 0`. Measured standalone on an M-series Mac (`scratchpad/snowbench`): | cipher | debug | release | |---|---|---| | snow ChaCha20-Poly1305 (v10) | **13.8 MB/s** | 291 MB/s | | AES-256-GCM (v9's cipher) | **6.5 MB/s** | 213 MB/s | The transfer moves 5.4 MB/s. That is the debug column's order of magnitude and ~50x below the release column. Note also that hardware AES does *not* survive a debug build — v9's cipher measures slower than v10's at `opt-level = 0` — so the v9→v10 regression is not explained by the cipher change. If v9 was measured against a shipped release binary and v10 against a dev build, the build profile explains it by itself. The clincher is inside a single v10 transfer: **the Mac decrypts at 357 MB/s** (14 ms per 5 MB chunk, via CryptoKit, always optimized) **while the sender encrypts at 5.4 MB/s.** Same algorithm, same bytes, 66x apart. ### What to check, and the fix 1. **How is the Windows app built?** `npm run tauri dev` / `cargo run` without `--release` builds the core in debug. Re-run the transfer from a release build; if it jumps, done. 2. **Durable fix either way** — in the workspace `Cargo.toml`, optimize dependencies even in dev builds, keeping the app's own code debuggable: ```toml [profile.dev.package."*"] opt-level = 3 ``` Crypto crates are exactly the case this profile setting exists for. 3. **Fix the sender's `Diag:` label** before removing it: split time in `write_all` into encrypt and socket, or rename it. As written it attributes CPU time to the network. The earlier throughput benchmark that "ruled out" the cipher at 968 MB/s was run with `cargo test --release`, so it never exercised the build the app actually ships in dev. ## Superseded: is a single TCP flow between these machines worth more than 43 mbps? Answered above — yes, 907 mbps. Kept for the method. The SMB comparison has a hole worth checking: **SMB multichannel opens several TCP connections**, so 600 mbps aggregate is consistent with a per-flow limit near 43-75 mbps. If a single plain TCP stream also lands at ~43 mbps, no application change on either side will help, and the answer is in the network (loss, retries, the Windows adapter or its TCP settings) — not in this repo or the Rust core. Zero-install test, Mac as receiver (its address was 192.168.86.226 when this was written): ``` # on the Mac nc -l 5001 > /dev/null ``` ```powershell # on Windows: 500 MB over one TCP connection, no Flying Carpet, no crypto $c = New-Object System.Net.Sockets.TcpClient('192.168.86.226', 5001) $s = $c.GetStream(); $buf = New-Object byte[] 1048576 $sw = [Diagnostics.Stopwatch]::StartNew() for ($i = 0; $i -lt 500; $i++) { $s.Write($buf, 0, $buf.Length) } $s.Close(); $c.Close(); $sw.Stop() "{0:N1} mbps" -f (500 * 8 / $sw.Elapsed.TotalSeconds) ``` Or with iperf3 (already installed on the Mac), which also answers the multichannel question directly — `iperf3 -s` on the Mac, then on Windows: ``` iperf3 -c 192.168.86.226 -t 20 # one stream: compare against 43 mbps iperf3 -c 192.168.86.226 -t 20 -P 8 # eight streams: compare against SMB's 600 mbps ``` - **One stream ≈ 43 mbps** → the app is not involved; it is the path or the Windows host. Eight streams ≈ 600 would confirm SMB's number came from aggregation. - **One stream ≈ 600 mbps** → the network is fine for a single flow, and the remaining suspect is the Rust sender's socket behavior. The Apple-side instrumentation has been removed. **The sender's `Diag:` line still needs fixing or removing** — see the header. ### Still to check iOS, and the reverse direction (macOS → Windows), which share this code. Both are sanity runs now rather than investigations. ## Related pending changes FlyingCarpet (Rust/Android): - `core/src/lib.rs` — `set_nodelay(true)`; `chunksize()` reading `FC_CHUNKSIZE` (temporary, for the chunk-size experiment above). - `core/src/sending.rs` — the temporary `Diag:` instrumentation. **Remove before release.** - `core/tests/throughput.rs` — ignored benchmarks (cipher, stream, socket write pattern). Run with `cargo test --release --test throughput -- --ignored --nocapture`. - `Android/.../MainViewModel.kt` — `client.tcpNoDelay = true`. --- ### Ble Bond Asymmetries # Audit: bonded vs. unbonded BLE behavior on every platform > **Superseded in part by `docs/bluetooth-field-guide.md`** (2026-07-25), which consolidates > this audit with the session's findings and carries the current per-platform matrix. Two > corrections to what follows: the Windows↔Linux hang turned out to be **bearer selection**, > not GATT caching (see the field guide §3, bug 4); and the "stop removing the GATT service > between transfers" recommendation in §Recommendation below is **withdrawn** — it would have > required registering the service even while acting as central, and it was never the cause. > The cache findings about Android and Apple below still stand and are still unfixed. Date: 2026-07-25. Prompted by three Windows↔Linux failures in a row, all of which turned out to be the same shape: **code that works against an unbonded peer and behaves differently, or not at all, against a bonded one.** This audit asks where else that shape exists. Read `docs/windows-ble-gatt-0x8000ffff.md` first for the three failures that motivated this. ## Why this class of bug is systemic here Two facts combine badly: 1. **Every platform's BLE peripheral removes its GATT service when a transfer ends.** iOS calls `removeService()`, Linux drops the GATT `app_handle`, Windows releases the `GattServiceProvider`, Android closes and reopens its GATT server. The "Lifecycle status" matrix in the release test plan tracks this as a *feature* — and for advertising and connection teardown it is. 2. **Every platform's BLE central caches the peer's GATT database, keyed to the bond.** That is what a bond is partly *for*: skip rediscovery next time. So the peer's GATT database legitimately changes between transfers, and the central is legitimately holding a cached copy of the old one. The GATT spec's answer is the *Service Changed* indication. **Two of our three central implementations receive that notification and throw it away.** Until now this was masked: Linux deleted its bond after every transfer, so there was rarely a cached database to go stale. Fixing that (`6039d53`) makes the bonded path the normal path on the pairing that gets tested most. --- ## Findings ### 1. Android never invalidates its GATT cache — highest risk `Android/…/Bluetooth.kt`: ```kotlin override fun onServiceChanged(gatt: BluetoothGatt) { super.onServiceChanged(gatt) … outputText("Services changed") // TODO: should this be enabled? does it cause problems? https://developer.android.com/… // gatt.discoverServices() } ``` The callback that exists precisely to handle "the peer's database changed" logs a line and does nothing. Its one meaningful action is commented out behind an unanswered question. Android caches the GATT database for **bonded** devices specifically — `discoverServices()` returns the cache rather than re-reading from the peer — and there is no public API to clear it (the usual workaround is the hidden `BluetoothGatt.refresh()` via reflection, which appears nowhere in this codebase). **Predicted symptom:** Android as central, second or later transfer with a bonded peer whose service was removed and re-added — `getService(SERVICE_UUID)` returns null, or returns a service whose characteristic handles are stale and whose reads fail. **Status: predicted from code, not observed.** Android↔Windows and Android↔Linux hotspot rows are still unchecked in the test plan, and they are exactly the ones that would show this. Worth running deliberately: transfer, then transfer again without restarting either app, with Android receiving both times. ### 2. Android fails silently when the service or characteristics are missing Same function, and this is what makes finding 1 dangerous rather than merely annoying: ```kotlin val service = gatt.getService(SERVICE_UUID) if (service == null) { outputText("Did not find service") return // no bluetoothFailed(), no retry } osCharacteristic = service.getCharacteristic(OS_CHARACTERISTIC_UUID) ?: return ssidCharacteristic = service.getCharacteristic(SSID_CHARACTERISTIC_UUID) ?: return passwordCharacteristic = service.getCharacteristic(PASSWORD_CHARACTERISTIC_UUID) ?: return ``` Four exits that leave the transfer waiting forever for a credential exchange that will never happen. Three of them print nothing at all. There is no timeout behind them. Compare the other three centrals, all of which fail loudly: | Platform | Missing service → | |---|---| | Linux | `ServicesUnresolved` error after a bounded retry loop | | Windows | retry ladder, then a named error naming the likely cause | | iOS / macOS | `cleanUpTransfer()` with a user-facing message | | **Android** | **silent return; hangs** | This is the same silent-hang class as the Linux `scan()` bug fixed in `ed921ac`, and it is the most likely way finding 1 would present to a user: "it just hangs." **This is the highest-value fix in this document** — it is small, it is independent of whether finding 1 is real, and it converts an unbounded hang into a diagnosable error. ### 3. Apple discards the same notification `iOS/FlyingCarpet/ViewController.swift`: ```swift func peripheral(_ peripheral: CBPeripheral, didModifyServices invalidatedServices: [CBService]) { print("invalidatedServices: \(invalidatedServices)") } ``` Implemented, prints, does not re-discover. **macOS does not implement it at all** — so the two Apple targets differ from each other, which is its own small inconsistency worth closing regardless. CoreBluetooth caches services per `CBPeripheral` and persists them for bonded peers, so the same staleness applies. `Bluetooth.swift`'s `scan()` also short-circuits through `retrieveConnectedPeripherals(withServices:)`, which returns a peripheral object that may already carry a populated (and stale) `services` array. **Severity is lower than Android's** for one reason: both Apple targets now handle read failures in `didUpdateValueFor`, reporting the error and calling `cleanUpTransfer()`. A stale handle surfaces as a failed read with a message, not a hang. That error handling is doing load-bearing work it wasn't written for. Minor, same file: `didDiscoverPeripheral` force-unwraps `discoveredPeripheral!` — a different variable than the `peripheral` parameter it was handed. It is set by the ViewControllers immediately before, so it works, but it is a crash waiting for a reordering. ### 4. Windows — checked, and clean on the things I suspected Recorded so this isn't re-audited later: - **`peer_device` latch resets correctly.** The scan callback bails with "we've already initiated pairing" if `peer_device` is already set, which would be a stale-state hazard across transfers — but `BluetoothCentral::new()` runs per transfer and `rescan()` clears it. Not a bug. - **Cache mode is explicit and correct.** `GetGattServicesWithCacheModeAsync(Uncached)` — the one central that asks for a fresh read. Since this session it also checks `GattDeviceServicesResult::Status()` before trusting the list. - Windows is therefore the only central of the four that both bypasses the cache *and* validates the result. What remains on Windows is the still-unexplained `0x8000FFFF` against a bonded iPhone, and the re-pair that fails permanently afterward — both already documented, neither reproduced since. ### 5. Bond provenance changes cached state — generalize the Linux lesson The Linux hang in `ed921ac` came down to *how* the bond was created: bonding while acting as central left a device record containing our service UUID; bonding while acting as peripheral left one without it, and the scan dismissed it forever. The general statement — worth holding while testing any platform — is that **a bond formed while you were the peripheral leaves you with a different cached view of the peer than one formed while you were the central**, because the record was built from an incoming connection rather than from an advertisement you filtered for. Flying Carpet reverses these roles between transfers by design (sender = peripheral, receiver = central), so both provenances occur routinely, and each platform's discovery code was written and tested against the central-first one. That is why the test plan now runs Windows↔Linux from unpaired in **both** starting orders. The same doubling is worth doing for Android↔Windows and Android↔Linux. --- ## Recommendation The individual fixes are worth doing, but there is a root-cause fix worth considering first. ### Root cause: stop removing the GATT service between transfers Every finding above exists because the peripheral's GATT database changes between transfers. If the service were registered once for the app's lifetime and only **advertising** were started and stopped, no central would ever hold a stale database, and the invalidation gaps on Android and Apple would stop mattering. Windows already separates these — `012d00b` added an explicit `stop_advertising()` while the service provider lives until drop. The other platforms tear down both together. **The catch, and it is a real one:** the SSID and password characteristics would remain readable between transfers. They are encryption-gated (bonded peers only), but a bonded peer could read a previous transfer's credentials. Any move in this direction must clear those characteristic values at teardown rather than leaving the last transfer's in place. That is a small change, but it is a security-relevant one and needs stating explicitly in review — the hotspot password is the Noise PSK. I'd treat this as post-v10: it touches the BLE lifecycle on four platforms, and the release test plan's entire lifecycle tier was written against the current teardown behavior. ### For v10 1. **Make Android's four silent returns loud** (finding 2). Call `bluetoothFailed()` with a message naming the missing service or characteristic. Small, self-contained, and it converts the most likely field failure from an unbounded hang into a bug report that says what happened. Do this one regardless of everything else. 2. **Run the two Android hotspot pairs twice each, without restarting** (findings 1 and 5) — Android↔Windows and Android↔Linux, Android receiving both legs. These rows are already unchecked in the test plan; this audit just says what to watch for. 3. **Implement `didModifyServices` on macOS**, matching iOS, even if both only log for now. Costs nothing and removes a gratuitous difference between two targets that share `Bluetooth.swift`. ### After v10 4. Decide the root-cause question above: keep services registered and clear their values, or implement invalidation on Android (`onServiceChanged` → `discoverServices()`) and Apple (`didModifyServices` → re-discover). Keeping the service registered is less code in more places; the invalidation route is more conservative and testable per platform. 5. Fix the `discoveredPeripheral!` force-unwrap in `didDiscoverPeripheral`. --- ### Bluetooth Field Guide # Bluetooth field guide Hard-won knowledge from the 2026-07-25 Windows↔Linux debugging session, plus a cross-platform audit of where each of the five platforms stands. **Read this before touching any BLE code.** Companion docs: `docs/windows-ble-gatt-0x8000ffff.md` (the running investigation log, in chronological order) and `docs/ble-bond-asymmetries.md` (the audit that predicted several of these). `ARCHITECTURE.md` covers why Bluetooth is hotspot-only. --- ## 1. The mental model Flying Carpet's BLE usage has **four independent axes**. Almost every bug in this session came from conflating two of them, or from assuming a platform behaves the same on one axis as it does on another. | Axis | Question | Who controls it | |---|---|---| | **Advertising** | can anyone *discover* me? | the peripheral (sender) | | **Scanning** | how do I recognize the peer? | the central (receiver) | | **Bonding** | do we have shared keys, and *over which transport*? | both, at pairing time | | **GATT service** | is the service registered, and what does the peer's cache think? | the peripheral registers; the central caches | The roles reverse between transfers by design — **sender = BLE peripheral, receiver = BLE central** — so every device plays both sides, and state created in one role is read back in the other. That is the source of the whole bug class. ### The two axes that actually bite **Bearer selection (LE vs. BR/EDR).** A dual-mode peer can be reached over classic Bluetooth or over LE. Flying Carpet's GATT service exists **only over LE**. Any stack that picks the bearer for you can pick wrong, and the failure looks like "connected but no services". **GATT caching.** Bonding exists partly so the central can skip rediscovery. That means the central holds a snapshot of the peer's service database — and Flying Carpet's peripherals *remove* their service when a transfer ends, so that snapshot goes stale by design. --- ## 2. The laws Learned the hard way. Violate these and you get an intermittent hang weeks later. 1. **Never let the stack choose the bearer.** Always demand LE explicitly. Linux needs an L2CAP LE socket to force it; Android needs `TRANSPORT_LE`, never `TRANSPORT_AUTO`. 2. **A bond's *provenance* determines its transport.** A bond created while you were the central (you dialed LE) is LE-only. A bond created while you were the peripheral (the peer dialed in) is **dual-transport**, because cross-transport key derivation mints BR/EDR keys from the LE pairing. Same peer, same bond record, opposite behavior on the next connect. 3. **Filter on advertisement data, never on resolved-service data.** BlueZ's `Device1.UUIDs` is "the available remote services" — a cached GATT snapshot — while `ServiceData` is the advertisement. They are different sources with confusingly similar names. Windows, Android and Apple all filter on advertisement data and were never vulnerable; Linux filtered on the cache and hung on every bonded peer. 4. **Never unpair unilaterally.** You cannot tell the peer, and Apple platforms cannot clear their half programmatically at all. A one-sided bond is *worse* than a bad bond: the peer still believes it can skip pairing, and encrypts with a key you threw away. Symptoms are CBError 14 on macOS and an empty GATT service list on Windows. 5. **Bonded and unbonded are different code paths on every stack.** Every platform's discovery logic was written and tested against the unbonded path, because that's what a first run exercises. The bonded path is what users actually hit from the second transfer onward. 6. **Read the error string's prefix.** `br-connection-canceled` names its own cause. Three commits were built on the wrong theory with that string already in the log. 7. **Check the Linux Bluetooth history before theorizing.** `967ed6b` had worked out the bearer tiebreak, CTKD dual bonds, and the L2CAP-socket cure a month before this session re-derived them from scratch against a different peer. 8. **"Connected" is not a bearer, and it is not a GATT database.** BlueZ's `Device1.Connected` is `bredr_state.connected || le_state.connected` — a peer reachable only over classic reads as connected. Never let it stand in for "we have a usable LE link"; `ServicesResolved` is the property that means that. Every guard of the form `if !is_connected() { force LE }` is this bug waiting to happen, because it skips the fix in the one case that needs it. 9. **Hang up when you're done.** A link left open outlives the transfer and is inherited by the next one *in the opposite role*, where it silently satisfies every "are we connected?" check while being the wrong bearer, the wrong direction, or attached to a service that has since been removed. Dropping the GATT service without dropping the link also strands the peer's cache: it holds a snapshot of a database that no longer exists, with no Service Changed coming. Disconnect ≠ unpair — law 4 is about the *bond*, and this is about the *link*. --- ## 3. What actually happened this session Four distinct bugs, each masking the next. Symptoms in order of appearance: ### Bug 1 — Linux deleted the peer's bond after every transfer **Symptom:** Windows→Linux, then Linux→Windows: Windows reports `AlreadyPaired`, then `GetGattServicesWithCacheModeAsync` returns **success with an empty service list**. Retries don't help. The recovery ladder unpairs, re-pairs, and then fails permanently. **Cause:** `keep_bond = info.0 == "mac"` — Linux removed the bond for any non-macOS peer, on the premise that "Windows and Android re-pair per transfer." Both halves of that premise were false: Windows short-circuits on `AlreadyPaired`, and Android has no `removeBond` call anywhere. Windows kept its half and encrypted with an LTK Linux had discarded; the link never encrypted, so there was no GATT database, which WinRT reports as success-with-nothing. **Fix:** `6039d53` — never remove bonds on cleanup. macOS was never special; it was the only platform whose error message (CBError 14, "Peer removed pairing information") named the problem out loud. ### Bug 2 — Windows conflated "no service" with "no connection" **Symptom:** the misleading message above — "Flying Carpet service not found in peer's service list" — which sent the recovery ladder after the peer's GATT database instead of the link. **Cause:** `get_services_and_characteristics` called `.Services()` without checking `GattDeviceServicesResult::Status()`. On anything but `Success` the collection is empty, which is indistinguishable from "connected fine, service genuinely absent." **Fix:** `6039d53` — check `Status()` first, report an empty-but-successful list as its own condition, and name a one-sided bond as the likely cause in both messages. ### Bug 3 — Linux's scan filtered on a property that can never change **Symptom:** Windows advertises; Linux sits at "Started Bluetooth scan, waiting for sending device..." forever. The diagnostic dump shows the peer present at rssi −50, bonded, with a UUID list that never gains the Flying Carpet service. **Cause, two layers.** First, `AdapterEvent::DeviceAdded` fires only *once* per device, and for a bonded peer that once is the pre-seeded cache entry — BlueZ resolves the peer's rotating private address back to the existing record via the stored IRK. Second and more fundamental, the property being checked (`Device1.UUIDs`) is the resolved-GATT-services list, not advertisement data, so it reflects a cached snapshot taken when the peer had no Flying Carpet service registered. **Fixes:** `573fb87` (use `discover_devices_with_changes`, which re-emits on property change) and `6270ac4` (when a bonded peer's cached UUIDs lack the service, connect and ask for the resolved database instead of trusting the cache). ### Bug 4 — the one that mattered: `Connect()` was dialing classic Bluetooth **Symptom:** the probe from bug 3 failing with `Could not probe E8:48:…: Bluetooth operation failed: br-connection-canceled`. **Cause:** `br-` is BR/EDR. BlueZ's `select_conn_bearer` prefers the bonded bearer when exactly one is bonded, otherwise the most recently seen, with BR/EDR winning ties. A bond created while Linux was the peripheral is dual-transport (CTKD), so both bearers are bonded, the tiebreak falls through, and classic wins — against a peer that serves GATT only over LE. The existing LE-bonding socket was gated on `address_type() == LePublic`, with the comment "random-address peers (Windows/Android/iOS) always connect over LE anyway." Windows advertises from `E8:48:…` (`0xE8` = `0b11101000`, a **static random** address), so it was excluded. That assumption is true only while a peer is *unbonded*. **Fix:** `386f654` — `ensure_le_link()` raises the LE ACL link with an L2CAP LE socket before `Connect()` for any already-paired peer, regardless of address type. **This is the fix that made it work.** ### Why the ordering confused everything The two bond provenances produce opposite outcomes, so the same pair of machines passed or hung depending on which direction you transferred *first*: | First transfer | Linux's bond with the peer | Second transfer | |---|---|---| | Windows→Linux (Linux central) | LE-only, made by the L2CAP socket | works | | Linux→Windows (Linux peripheral) | dual-transport, made by CTKD | `br-connection-canceled` | --- ## 3a. The Android sequel — same axis, one layer further in (2026-07-25) `386f654` fixed `Connect()` picking BR/EDR. It did not fix the case where **something else already established the link**, which is what Linux↔Android surfaced. **Symptom:** Linux→Android (pairing along the way), then Android→Linux. Android advertises happily. Linux logs, twice: ``` Found device Peer is running Flying Carpet, connecting over Bluetooth... Already connected to peer over Bluetooth Bluetooth connection failed; retrying... ``` and never exchanges credentials. **Cause, three defects stacked in the order they fire:** 1. **Linux never disconnected, in either role.** `negotiate_bluetooth` dropped the advertisement and the GATT application at the end of a transfer but left the ACL up. There was no `disconnect()` anywhere outside `scan()`'s probe-failure path. So the reverse transfer began with a live link left over from the previous one. 2. **`Device1.Connected` is bearer-agnostic (law 8), so the leftover link satisfied every check.** `find_characteristics` took its "already connected" arm, which skipped `Connect()` *and* `ensure_le_link()`. The bond in this direction is the dual-transport CTKD kind — the previous transfer had Linux as the peripheral — so the inherited link can be the bearer that serves no GATT. 3. **`ensure_le_link()` would have been a no-op anyway.** Its first statement was `if is_connected() { return }` — the exact property that cannot distinguish the two bearers. The function written to force LE disabled itself in precisely the situation it existed for. Then `device.services()` sat in bluer's `wait_for_services_resolved` (120 s `TIMEOUT`) and returned `ServicesUnresolved`. The retry rung re-ran an identical attempt, because nothing had torn the link down — hence the log repeating verbatim. **Fixes:** `ensure_le_link()` short-circuits on `ServicesResolved` instead of `Connected` and is now called for every paired peer regardless of connection state (`central.rs`); the retry rung disconnects first; and both roles hang up when the exchange is done (`bluetooth.rs`). This was already recorded as a symptom in a `lib.rs` TODO — *"linux can't receive from windows or android if already paired/connected, service not found. but then it disconnects and next transfer works"* — including the cure. Law 7 again. ### Why Android makes this easy to hit Android is the only platform whose Flying Carpet GATT service is registered **permanently**, not per transfer. `Bluetooth.stop()` calls `initializePeripheral()`, which closes the old server and immediately opens a new one *and re-adds the service*; `MainActivity` does the same whenever Bluetooth is switched on. So BlueZ's cached `Device1.UUIDs` for an Android peer always contains our service, and `scan()` returns it from cache instantly — without waiting for a live advertisement, and without ever taking the bonded-peer probe path that would have re-resolved the database. Fast, and wrong-bearer failures surface immediately rather than after a scan. --- ## 4. Where all five platforms stand Verified by reading the code on 2026-07-25. ✅ correct · ⚠️ works but fragile · ❌ known gap. ### Advertising (peripheral / sender) | | Mechanism | Stops between transfers | Address type | |---|---|---|---| | **Windows** | `GattServiceProvider.StartAdvertising` | ✅ explicit `stop_advertising()` (`012d00b`), plus a `Drop` guard for error/cancel paths — WinRT holds its own reference, so dropping ours is not documented to stop it | static random | | **Linux** | bluer `Advertisement` | ✅ `drop(adv_handle)` after the OS exchange | adapter (usually public) | | **Android** | `bluetoothLeAdvertiser` | ✅ `stopAdvertising` + GATT server closed | random | | **iOS** | `CBPeripheralManager` | ✅ `stopAdvertising` + `removeService` | random | | **macOS** | `CBPeripheralManager` | ✅ same | ⚠️ **public + dual-mode flags**, unavoidable — the original macOS↔Linux bug | ### Scanning (central / receiver) — what the peer is matched on | | Filter source | Verdict | |---|---|---| | **Windows** | `advertisement.ServiceUuids()` | ✅ advertisement data | | **Android** | `ScanFilter.setServiceUuid` | ✅ advertisement data | | **iOS/macOS** | `scanForPeripherals(withServices:)` | ✅ advertisement data | | **Linux** | `DiscoveryFilter` + `Device1.UUIDs` | ⚠️ fixed, but `UUIDs` is *resolved services*; the probe path now compensates | ### Bearer selection — the axis that caused the outage | | Choice | Verdict | |---|---|---| | **Windows** | n/a — `BluetoothLEDevice` is LE by definition | ✅ immune | | **iOS/macOS** | n/a — CoreBluetooth is LE-only | ✅ immune | | **Linux** | BlueZ `select_conn_bearer` | ✅ forced LE via L2CAP socket, unbonded *and* bonded, and now on inherited links too (§3a) | | **Android** | `TRANSPORT_LE` on both paths | ✅ post-bond path fixed in `6b29695` — see §5.1 | ### Bond retention | | After a successful transfer | On failure recovery | |---|---|---| | **Windows** | ✅ keeps (unpairing deliberately disabled) | ✅ one unpair, last resort, warns the user | | **Linux** | ✅ keeps (`6039d53`) | ✅ retry first; `remove_device` last resort, warns the user | | **Android** | ✅ keeps (no `removeBond` anywhere) | ✅ none | | **iOS/macOS** | ✅ keeps (no API to remove) | ✅ none possible | All five agree on the happy path, and the two failure paths that could still strand a peer now try everything else first and tell the user what to do on the other device. ### GATT service registration and cache invalidation | | Service lifetime | Central-side cache handling | |---|---|---| | **Windows** | registered per transfer | ✅ `Uncached` + `Status()` checked | | **Linux** | per transfer (`drop(app_handle)`) | ✅ connects to re-resolve when cached UUIDs look wrong | | **Android** | ⚠️ **permanent** — `stop()` calls `initializePeripheral()`, which reopens the server *and re-adds the service* | ✅ `onServiceChanged` → `discoverServices()`, gated on `exchangeComplete` | | **iOS** | per transfer (`removeService`) | ✅ shared `didModifyServices` re-discovers (`FlyingCarpetApple` `4c59af6`, pending an Xcode build) | | **macOS** | per transfer | ✅ same shared helper, same caveat | Every stack that does re-discover depends on the peer sending a Service Changed indication. Where one isn't sent, no stack learns its cache is stale — see §5.4. --- ## 5. Outstanding, ranked ### 1. ~~Android uses `TRANSPORT_AUTO` immediately after bonding~~ — fixed in `6b29695` `Bluetooth.kt`'s `ACTION_BOND_STATE_CHANGED` receiver passed `TRANSPORT_AUTO` to the post-bond `connectGatt` while the scan path five hundred lines earlier correctly passed `TRANSPORT_LE`. The post-bond call fires the moment bonding completes — precisely when CTKD has just created a dual-transport bond, the exact condition that made BlueZ pick BR/EDR — so against a dual-mode peer (any desktop, and macOS especially) Android could connect over classic, which serves no GATT. The direct analogue of the bug that cost the 2026-07-25 session; both paths now pass `TRANSPORT_LE`. The Android↔Windows and Android↔Linux hotspot rows in the test plan are the ones that exercise it. ### 2. ~~Android fails silently when the service or characteristics are missing~~ — fixed Four exits from `onServicesDiscovered` — three printing nothing, none calling `bluetoothFailed()`, no timeout behind them. Linux errors, Windows retries then errors, Apple calls `cleanUpTransfer()`; Android hangs. Any of the bugs above, hit on Android, presents as "it just hangs" with nothing in the log. Fixed in `6b29695`; all four exits now report and call `bluetoothFailed()`. **But that left the callback *before* it silent, which is worse.** A GATT connect that never succeeds never reaches `onServicesDiscovered` at all, and `onConnectionStateChange`'s disconnect branch was a bare `Log.i` that ignored `status` — so a failed connect showed up as the UI going quiet immediately after "Stopped scanning", with the one number that names the cause thrown away. Observed 2026-07-25 on Linux→Android. It also never called `close()`. Android leaks the underlying client if you don't close it after a disconnect, `connectGatt()` starts returning status 133 once enough have leaked, and `Bluetooth.stop()` only closes `bluetoothGatt` — which a *failed* connect never assigns. So each failed attempt leaked a client and each retry leaked another, which is the mechanism that turns one transient failure into a device that stays broken until the app restarts. Both fixed; the disconnect branch now closes, reports `status`, and distinguishes the three benign disconnects (post-exchange, mid-bonding, and a stale overlapping connection) from a real one. **Diagnostic value:** if Android goes quiet right after "Stopped scanning", it is failing to connect, not failing to find the service — those are different callbacks with different causes. That diagnostic immediately paid for itself, and against the previous entry: on the next run the connect *succeeded* and the hang did not reproduce. The failure had moved one stage later, which is the next item. ### 2a. ~~A joining Android never set `exchangeComplete`, so the peer's teardown read as a failure~~ — fixed 2026-07-25 Android→Linux succeeded, then Linux→Android reached `Joining flyingCarpet_79e9` — the exchange was **complete**, the password was in hand — and two seconds later printed "Did not find the Flying Carpet service on the peer" and aborted. The peer had done nothing wrong. Linux, having handed over its credentials, sleeps one second, drops its GATT service and disconnects, exactly as `core/src/linux/bluetooth.rs` intends. Android's central saw the Service Changed indication, re-ran `discoverServices()`, got 3 services instead of 4, and took the missing-service exit that `6b29695` had just wired to `bluetoothFailed()`. The re-discovery in `onServiceChanged` (added in `b84911b`) is guarded on `exchangeComplete` precisely to prevent this. The guard never fired, because **`exchangeComplete` was only ever set on the hosting path** (`connectToPeer()`), and this device was joining. Android is central when receiving and joins whenever the peer is Linux or Windows — so "Android receives from a PC", one of the most ordinary configurations there is, ran every transfer with all three `exchangeComplete` guards disarmed, including the post-exchange guard added in `3d2545a` one commit earlier. Two of the three would have aborted this transfer; the service-changed one simply got there first. Fixed at the flag, in two places, rather than at either call site: - `gotPassword()` sets it. That is the joiner's last BLE step, and both joiner roles pass through it — the central by reading the characteristic, the peripheral by having it written — so one assignment covers both. Not on an empty password: that means the peer's hotspot isn't up yet, and the replay this flag suppresses is the retry that recovers. - `bluetoothFailed()` returns early when it is set. The teardown arrives as a Service Changed, then a disconnect, then whatever a read or write already in flight returns, and those reach three different call sites out of about ten. Once the transfer is on Wi-Fi, no BLE event should be able to kill it — gate the teardown once instead of auditing every caller. - `Bluetooth.stop()` clears it, not just `scan()`. `scan()` runs for the central role only, so a peripheral transfer following a completed one would otherwise inherit `true` and ignore real failures for its whole duration. This is the bug the fix above would have introduced. **The lesson is the flag's name.** `exchangeComplete` was set where the *host* finishes, and read in three places that all meant "is BLE done with this transfer". Any predicate consumed by guards that fail open needs to be true for every role that reaches them, and the roles here are four independent axes (§1) — "hosting" is not "central" is not "sending". ### 2b. ~~Every GATT request Android issues could be dropped without a trace~~ — fixed 2026-07-25 Visible in the same log, and benign only by luck: the first `onServiceChanged` (Linux *adding* its service) arrived while the `discoverServices()` from `onConnectionStateChange` was still in flight, so two discoveries completed 13 ms apart and both called `read(OS)`. The second `readCharacteristic()` returned false — queue busy — and `read()` ignored the return. One chain died silently. It didn't matter because the two chains were identical, but two concurrent walks of read-OS → write-OS → connectToPeer is not a state this code reasons about, and a silently dropped GATT request is this project's signature failure mode. `read()` had **three** ways to do nothing at all, none printing anything: | | why it was silent | |---|---| | `bluetoothGatt` null | `bluetoothGatt?.readCharacteristic(...)` — the `?.` swallowed the whole call | | characteristic null | unknown UUID fell through a `when` with no `else` | | `readCharacteristic()` false | return value discarded | `write()` had the same three, plus a `characteristic!!` on the Tiramisu path that would have thrown rather than reported. And `discoverServices()` reports busy identically — false return, no callback, nothing logged. All of them now print. **Reported, not fatal**, deliberately: a false return is legitimately transient here, because the two GATT connections after bonding are *meant* to coexist (§2) and can each be walking the exchange, so one finding the queue busy is not grounds for killing a transfer the other is about to finish. The overlap itself is gone too — both discovery call sites go through one `startDiscovery()` that refuses to start a second while one is outstanding, and clears the flag on connect, on disconnect, and in `onServicesDiscovered` before its permission gate so it can never latch on. **The general rule for this platform:** every Android GATT call is asynchronous *and* fallible synchronously. The boolean or `BluetoothStatusCodes` return is the only notice you get that the callback you are about to wait for will never arrive. Discarding it is how a transfer comes to hang with an empty log — the same shape as §2, one layer down. ### 2c. ~~A completed transfer flipped the Bluetooth switch off~~ — fixed 2026-07-25 Observed running the release test plan's iOS → Android repeat-transfer row: both transfers worked, and between them the Bluetooth switch turned itself off — the reliable `bluetoothFailed()` indicator (§6), firing after a transfer that had finished cleanly. The logcat trace made the mechanism unambiguous: 1. Fresh pairing mid-transfer means **two** GATT clients by design (§2): the pre-bond connect from the scan and the post-bond connect from the bond receiver. `bluetoothGatt` tracks only the most recently connected one. 2. `Bluetooth.stop()` closed only `bluetoothGatt` — one `close()`/`unregisterApp()` in the log, two clients in existence. The pre-bond client stayed registered and kept the ACL up (the GATT server logged "Device connected" again *immediately after* stop()). Law 9, Android edition. 3. iOS's own teardown (`removeService`) then delivered a Service Changed indication to the leftover client — *after* stop() had cleared `exchangeComplete`, so the §2a guard was disarmed. Re-discovery found 9 services instead of 10, took the missing-service exit, and `bluetoothFailed()` turned the switch off. The stranded client was still alive a full transfer later: during leg 2, *two* clients logged the peer's status-19 disconnect. Fixed twice over, because the two halves cover different windows: - **Every `connectGatt()` return is tracked** (`openConnections`) and `stop()` closes them all — a closed client delivers no callbacks, which is what makes stop() final. - **`tearingDown`** is true from `stop()` until the next `scan()`/`advertise()` and gates `bluetoothFailed()` the same way `exchangeComplete` does, but for the between-transfers window that `exchangeComplete` cannot cover *because stop() clears it*. It also short- circuits `onServiceChanged` and the reconnect-rediscovery path, so a late peer teardown is a log line ("Ignoring service change after teardown"), not a discovery of a database with no Flying Carpet service in it. **The lesson generalizes §2a's:** a guard cleared at teardown protects nothing that happens after teardown. Every event the peer can still send after `stop()` — Service Changed, disconnect, an in-flight read completing — needs a gate whose lifetime matches the gap between transfers, not the transfer. ### 3. ~~The two unilateral-unpair paths violate law 4~~ — fixed 2026-07-25 Windows had **eight** `central.unpair()` sites, not one: enumeration failure (both branches), plus every characteristic read and write. Six of those fired *after* enumeration had already succeeded, where the bond is demonstrably fine and the link is up — a failing read is a timing or peer-side problem that dropping the bond cannot fix, and the peer is left holding a key we discarded. Those six are gone. The one that remains has positive evidence the bond is at fault (a *reused* bond that failed every enumeration attempt) and now tells the user to remove the pairing on the other device too. Linux's poisoned-bond `remove_device` was written for the bearer problem that `ensure_le_link()` now solves without touching the bond, so it was demoted from first rung to last: retry once with the bond intact, and only then remove and re-pair, with the same warning. ### 4. ~~Android and Apple never invalidate their GATT cache~~ — Android fixed 2026-07-25, Apple fixed in `4c59af6` Android's `onServiceChanged` now calls `discoverServices()`, gated on `exchangeComplete` — the TODO asked whether enabling it causes problems, and it does if ungated, because `onServicesDiscovered` restarts the credential exchange. That's the same re-entrancy hazard `onConnectionStateChange` already guards the same way. Apple was once written up as fixed when it was not (verified stub at `328dfc8`); the real fix is `FlyingCarpetApple` `4c59af6` (2026-07-25): a shared `didModifyServices` in `Apple/shared/Bluetooth.swift` re-discovers when our service is among the invalidated ones, and both targets' delegate methods now call it (macOS previously did not implement the method at all). **Caveat: written on Windows, not yet compiled — verify with an Xcode build on both targets before treating this as closed.** **Remaining limitation, by design:** this only helps when the peer *sends* a Service Changed indication. If it doesn't, neither stack learns its cache is stale. Android's only other lever is the hidden `BluetoothGatt.refresh()` via reflection — non-public API, breaks across versions, discouraged by Play policy — so the gap is documented rather than papered over. ### 5. Still open: nothing blocking The `0x8000FFFF` enumeration failure against a bonded iPhone (`§3`, the original subject of `docs/windows-ble-gatt-0x8000ffff.md`) remains unexplained and unreproduced since 2026-07-24. The retry rung covers it; the unpair rung behind it is now the only unpair left in the codebase. --- ## 6. Debugging playbook | Symptom | Most likely cause | First thing to check | |---|---|---| | Connected, but **zero** services | link never encrypted — one-sided bond | is the peer still bonded? `bluetoothctl paired-devices` | | Connected, services present, **ours missing** | stale GATT cache, or peer hadn't registered it yet | did the peer register the service *before* you connected? | | Error containing **`br-`** | classic bearer chosen for an LE-only service | force LE; check bond provenance | | Scan never finds an advertising peer | filtering on cached rather than advertised data | does the filter read advertisement data? | | Works on first pairing, fails on reuse | bonded-vs-unbonded divergence | test both bond provenances (see below) | | **"Already connected"**, then a ~120 s stall and a retry that repeats verbatim | a link inherited from the previous transfer, on the wrong bearer (§3a) | did either side `disconnect()` last time? is the guard checking `Connected` where it means `ServicesResolved`? | | Android goes quiet right after **"Stopped scanning"** | the GATT *connect* failed — not service discovery, which reports every exit | `adb logcat -s Bluetooth` for the status in `onConnectionStateChange`; 133 after repeated attempts means leaked clients, so restart the app | | A BLE error **seconds after** the credentials were exchanged — "services changed" then a missing service, or a disconnect | the peer's deliberate teardown, not a failure; a post-exchange guard that isn't set for this device's role (§2a) | is `exchangeComplete` set on *this* role's path? does the peer remove its service and disconnect after handing over credentials? | | Bluetooth switch flips itself off and the peer-OS chooser reappears | `bluetoothFailed()` ran — that is `enableBluetoothUi(false)`, and nothing else in the app does it | a reliable "a BLE callback failed the transfer" indicator. Since §2c it can only fire *during* a transfer — a flip after a finished one is a §2c regression (a leftover client or a disarmed `tearingDown` gate) | | macOS: **CBError 14** | peer deleted its half of the bond | law 4 | | Windows: `0x8000FFFF` on enumeration | unresolved; retry ladder | `docs/windows-ble-gatt-0x8000ffff.md` | **Always test both bond provenances.** From fully unpaired, run A→B then B→A; then unpair both sides and run B→A then A→B. These are *different code paths* and only one of them was passing for the entire v10 cycle. **Useful instruments:** `sudo btmon` (raw HCI — the only way to see which bearer and what's actually in the advertisement), `bluetoothctl info ` (what BlueZ believes), `adb logcat -s FlyingCarpet` (Android), `cargo tauri dev` stdout on both desktops. UI logs alone were insufficient for every bug in this session. --- ### Hardcoded Delays Audit # Audit: hardcoded delays across both repos Date: 2026-07-25. Scope: every `sleep`, `delay`, `Task.sleep`, `Thread.sleep`, `asyncAfter`, `postDelayed`, and bounded `timeout` in `FlyingCarpet` (Rust, Kotlin, JS) and `Apple/` (Swift). 45 sites found, plus 4 commented-out ones. ## Verdict **The situation is better than it looks, and the problem is concentrated, not spread out.** Of 45 delay sites, **31 are legitimate** — retry backoff, bounded timeouts on genuinely asynchronous OS state, and protocol-defined announce intervals. **2 are test-only.** **12 are fixed settling delays**: "wait N seconds and hope the other side is ready." Those 12 are not scattered. **Nine of them are in the BLE credential exchange**, in all three languages, and they exist because that exchange has one structural gap: *it is a sequence of GATT reads and writes with no completion signal*, so every platform independently bolted a timer onto it. The other three are the tail of the file-transfer confirmation handshake, which has the same shape of gap. So this is not "hacky fixes all over the place." It is **two protocol gaps, papered over independently six times.** That's a much more tractable thing to fix — and also a reason not to fix it by deleting sleeps one at a time. Nothing here is a v10 blocker. Recommendations are split accordingly at the end. --- ## The 12 fixed settling delays Ranked by how confident I am that they're papering over something, and what they cost. ### 1. The BLE credential exchange — 9 sites, ~4–6 seconds per transfer | File | Delay | Stated reason | |---|---|---| | `core/src/linux/central.rs` `find_characteristics` | 2 s | none — bare `sleep` before bonding | | `core/src/linux/central.rs` `exchange_info` ×4 | 1 s each | none — one after each characteristic read/write | | `core/src/linux/bluetooth.rs` `negotiate_bluetooth` | 1 s | "Removing GATT service" — hold it open a moment longer | | `core/src/windows/bluetooth.rs` `negotiate_bluetooth` | 1 s | "keep everything in scope until peer has had a chance to read the password" | | `Android/…/Bluetooth.kt` `onCharacteristicRead` | 1 s | peer's SSID came back empty; sleep and re-read | | `Android/…/Bluetooth.kt` `onConnectionStateChange` | 1.6 s | before `discoverServices()` | This is the delay observed in testing on 2026-07-25: a Linux central spends **4 s (joining) to 6 s (hosting)** in pure sleeps before the hotspot is even created, with nothing on screen. Three distinct things are going on: **(a) "Empty SSID means not ready yet" is a polling protocol.** The peripheral publishes an empty string until its hotspot exists; the central reads, sees `""`, waits, and reads again. Android sleeps 1 s and re-reads. iOS and macOS schedule a re-read (2 s / 1 s). All three are working around the same thing: the SSID characteristic never notifies. A GATT notify/indicate on that characteristic would delete this entire category, on every platform at once — the central would be told, rather than asked to guess. That is the single highest-value change in this document. **(b) Post-operation settling on Linux.** The four 1 s sleeps in `exchange_info` and the 2 s before bonding have **no comment and no stated cause**. The adjacent comment explains something else (that `WriteOp::Request` is used because iOS ignored unconfirmed writes), which suggests these were added while chasing the same iOS problem and then never revisited after the real fix landed. They are the most likely to be pure superstition — and also the riskiest to remove blind, because if any of them is load-bearing it will only show up against Apple peers. **(c) Hold-open-before-teardown.** Windows and Linux both sleep 1 s before dropping the GATT service so the peer can finish reading. But both already wait for an event that says the read happened (`PeerReadPassword` / `BluetoothMessage::Password`) immediately before sleeping. Worth checking whether the event already guarantees what the sleep is buying; if so these are free to delete. **Android's 1.6 s before `discoverServices()` — this entry was wrong, corrected 2026-07-27.** The original claim here was that its own comment admitted the delay wasn't the fix: > `// this was the reason android couldn't connect to macOS? no, was the setLegacy(false).` …and that it was therefore a sleep whose author recorded, in the code, that it didn't solve the problem it was added for. That reading was mistaken, and the comment invited it. 1600 ms is **Nordic's Android-BLE-Library constant**: after `onConnectionStateChange` on a *bonded* device their guidance is to wait ~1.6 s before `discoverServices()`, so the Service Changed indication and key exchange complete first — discover earlier and you can enumerate a stale GATT database. The value matches exactly and is near-certainly copied from there. The macOS note was answering a *different* question that happened to be asked on the same line; `setLegacy(false)` was the macOS fix and has nothing to do with the delay. So the delay has a real, documented rationale, and it is **not** superstition. It is still arguably removable — Nordic report the problem was primarily Android 6 and unnecessary by 7-8, while `minSdk` here is 29 — but that is a deliberate change needing its own device test (a first-time pairing against a bonded peer, the case the delay protects), not a cleanup. Flying Carpet bonds, and nothing in `Bluetooth.kt` invalidates the GATT cache, so the stale- database failure mode has no other mitigation. **Left in place**; the code comment now records all of this. Lesson worth keeping: a confusing comment adjacent to a magic number is enough to get the number itself convicted. Check whether a constant matches a known upstream value before calling it superstition. ### 2. Android sleeps on the Bluetooth callback thread — a real bug, not just a delay `Android/…/Bluetooth.kt`, in `onCharacteristicRead`: ```kotlin outputText("Could not read peer's WiFi characteristic. trying again...") Thread.sleep(1000) read(SSID_CHARACTERISTIC_UUID) ``` This blocks the BLE callback thread for a full second. Every other GATT callback queued behind it — including ones for an unrelated in-flight operation — is stalled. The Apple side hit this exact problem and documented the fix, in both `ViewController.swift` files: > `// scheduled rather than slept: this callback runs on the bluetooth queue, and sleeping` > `// here stalls every other callback behind it` …and uses `queue.asyncAfter` instead. **Android should do the same** — post a delayed runnable rather than sleeping. Same bug class, one platform learned it and the knowledge didn't cross over. `Thread.sleep(1600)` before `discoverServices()` is in the same callback context and has the same problem. This is the one item in the audit I'd call an outright defect rather than a smell. **Status: fixed in `5cec37c`** ("get off the binder thread"), which replaced both sleeps with `handler.postDelayed`. Note the defect was *sleeping on the binder thread*, not the existence of the 1.6 s wait — see the correction in section 1. The scheduled version keeps the wait and stops blocking other callbacks, which is the intended end state, not a compromise. One thing the move to `postDelayed` introduced: a check made when the runnable is *scheduled* is stale by the time it *fires*. `exchangeComplete` was tested before the 1600 ms delay but not inside it, so a discovery could still run against a peer that had torn its service down during the wait, producing a spurious "try unpairing" message in the transfer output. Fixed by re-checking inside the runnable. Worth remembering for any other sleep converted this way. ### 3. The end-of-transfer confirmation — 3 sites, and the code says so `core/src/receiving.rs`, twice, verbatim: ```rust // TODO: ugly hack to get around lifetime issue? sending end didn't receive this last // reply when calculating hash of large file. sleep(time::Duration::from_secs(1)).await; ``` The author's own note, question mark included. The surrounding protocol is a "double confirmation" at the end of each file: the sender writes a u64, the receiver replies, the sender replies again. It has no clean termination condition, so all three platforms bolt a timer on: - Rust receiver, end of `receive_file`: `timeout(2 s)` on the final read, then prints "Didn't receive confirmation" and carries on - Swift receiver, end of `receiveFile`: a detached task that sleeps 2 s and calls `killIt()` - Rust receiver, in `check_for_file`: the two 1 s sleeps above, after replying "I don't have this file" — held open because the sender missed that reply while it was busy hashing a large file None of these is dangerous — the file data is already written and verified by then, and the timeout path is handled — but three independent timers around one handshake is the signature of a protocol that doesn't say when it's finished. Cheapest honest fix is to define an explicit end-of-transfer message rather than inferring completion from silence. That is a **wire-format change**, so it is v11 work, not v10. ### 4. iOS `joinHotspot()` — unbounded retry loop `iOS/FlyingCarpet/ViewController.swift`: ```swift while true { try await NEHotspotConfigurationManager.shared.apply(config) try? await Task.sleep(nanoseconds: 3_000_000_000) if Task.isCancelled { throw TransferError.UserCancelled } if await isConnected() { break } NEHotspotConfigurationManager.shared.removeConfiguration(forSSID: self.transfer.ssid) } ``` The 3 s sleep is fine — association genuinely continues after `apply()` returns. The **loop has no attempt limit**: it exits only on success or user cancellation. Compare `Transfer.swift`'s peer-IP wait, which bounds itself at 120 iterations and throws `CouldNotJoinNetwork`. Cancellation does provide an escape, so this is a consistency and user-experience issue rather than a hang, but it should count attempts like its neighbour does. --- ## The 31 legitimate ones Recorded so a future audit doesn't re-litigate them. **Retry backoff after a failed operation** (7): TCP connect retries in `core/src/lib.rs`, `MainViewModel.kt`, and `Transfer.swift` (2 s each — three platforms, consistent); `nmcli con up` retry in `core/src/linux/network.rs` (1 s); hotspot join retry in `core/src/windows/network.rs` (2 s) and `Transfer.swift` (2 s); GATT enumeration retry in `core/src/windows/bluetooth.rs` (1 s, matching Microsoft's published guidance — see `docs/windows-ble-gatt-0x8000ffff.md`). **Polling OS state that fires no event** (6): gateway discovery after joining a hotspot (200 ms on both Windows and Linux); characteristic-discovery retry on Linux (2 s); transfer-cancellation wait in the Tauri command (100 ms); discovery-cancellation poll in `core/src/discovery.rs` (100 ms); peer-IP wait in `Transfer.swift` (1 s, **bounded at 120**). **Protocol-defined announce intervals** (6): `DISCOVERY_INTERVAL_MS` in `discovery.rs`, `Discovery.kt`, and `Discovery.swift`. These are the wire protocol, identical across platforms by design — not delays in the sense this audit is about. **Bounded timeouts on external events** (8): LE bonding socket, 60 s (waits on a human confirming a PIN); final-confirmation read, 2 s; discovery socket receive, 100 ms; firewall rule verification, 10 × 500 ms (netsh runs in a separate elevated process, so the result is genuinely asynchronous — and it's a bounded poll with a warning on failure, which is the right shape); iOS local-network permission probe, 3 s; `Network.swift` connection-waiting cancel, 30 s (with a comment explaining precisely why the bound exists); `Network.swift` generic connect timeout; Swift discovery poll, 50 ms. **Correctly scheduled rather than slept** (2): the iOS and macOS SSID re-reads via `queue.asyncAfter`. These are the pattern Android should copy. --- ## Test hygiene finding (unrelated to delays, found on the way) `core/src/linux/network.rs` has two tests that manipulate **real system network state** and neither is `#[ignore]`d: - `start_and_stop_hotspot` — creates a real NetworkManager hotspot named `flyingCarpet_1234`, sleeps 5 s, tears it down - `join_hotspot` — calls the real join path, sleeps 20 s, tears down So `cargo test` on a Linux machine tries to reconfigure the network, needs polkit auth, and can leave a stale `flyingCarpet_*` connection behind — which is exactly the residue that issue **#51** is about. Their Windows counterparts (`join_hotspot`, `check_for_firewall_rule`) *are* both `#[ignore]`d; the second was marked in this very branch (`dbdfbb1`) for the identical reason. The Linux pair was missed. **This one is a two-line fix and worth doing before release**, since it can actively dirty a test machine. --- ## Recommendations ### Before v10 1. **Mark the two Linux hardware tests `#[ignore]`**, matching the Windows pattern. Two lines, no behavior change, stops `cargo test` from dirtying a Linux box. 2. **Fix Android's `Thread.sleep` on the BLE callback thread** — replace both with a posted delayed runnable, copying the Apple approach. This is a defect with a known-correct fix already implemented on another platform, and it's in the credential-exchange path that has been the source of most release-test failures. 3. **Bound the iOS `joinHotspot()` loop** with an attempt limit, like its neighbour. Everything else should wait. Sleeps in the BLE path are precisely the code that current hardware testing is validating, and removing them mid-test-cycle invalidates results for no user-visible gain. ### After v10 4. **Add notify/indicate to the SSID characteristic.** This is the root fix: it removes the "read, see empty, wait, re-read" pattern from Android, iOS, and macOS simultaneously, and is the reason several of the settling delays exist at all. Wire-compatible in the sense that it adds a capability rather than changing existing bytes — but it touches all three implementations and needs the usual cross-platform care. 5. **Audit the five unexplained Linux BLE sleeps individually**, on hardware, against an Apple peer specifically — that's the peer they were most likely added for. Remove them one at a time, not as a batch, so a regression identifies itself. 6. **Check whether the two hold-open-before-teardown sleeps are already covered** by the read events that immediately precede them. 7. **Give the transfer an explicit end-of-transfer message** instead of inferring completion from silence, retiring the three confirmation timers and the two "ugly hack" sleeps. Wire-format change — **v11**, per the version rule in `CLAUDE.md`. ### Not recommended Do not do a sweeping "remove all the sleeps" pass. Several of these are load-bearing in ways that only show up against one specific peer platform, the failure mode is an intermittent hang rather than a clean error, and the code has no automated coverage at this layer — every regression would be found by hand, on hardware, weeks later. --- ### Post V10 Maintenance # Post-v10 Maintenance Backlog Work deliberately deferred until **after v10 ships**, plus findings from post-release field reports. None of it is blocking. The housekeeping items touch code that the v10 release testing already covers, so doing them mid-release would invalidate hardware testing for no benefit; revisit once `docs/v10-release-test-plan.md` is signed off. --- ## 1. Consolidate the duplicate `windows` crate versions The dependency tree currently carries **three** semver-incompatible versions of the `windows` crate (windows-rs): | Version | Pulled in by | Ours to control? | |---|---|---| | **0.44.0** | `wifidirect-legacy-ap 0.4.0` → `flying-carpet-core` | **Yes** — our own crate (`github.com/spieglt/wifidirect-legacy-ap`), ~193 LOC, last published Feb 2023 | | **0.58.0** | `flying-carpet-core` directly (`core/Cargo.toml`) | **Yes** | | **0.61.3** | Tauri stack (`tao`, `tauri-runtime`, `tauri-runtime-wry`, `webview2-com`) | No — moves on Tauri's release schedule | **Why this is not urgent.** Multiple semver-incompatible versions are normal in Rust and cargo supports them by design. The only real cost is build time and some binary size (the `windows` crate is heavily feature-gated, so far less than the version count suggests). They only actually *break* something when a `windows` type has to cross a crate boundary — e.g. an attempt to reach Tauri's `ICoreWebView2` from our code failed to type-check until a matching `webview2-com`/`windows` pair was added as a direct dependency. No current feature needs that. **Why it's worth doing eventually.** The `0.44.0` pin dates to February 2023 and will eventually block something. It is also the easiest to fix, since we own the crate. **Suggested order (lowest risk first):** 1. **Bump `wifidirect-legacy-ap`** to a modern `windows` and publish 0.5.0. Separate repo, ~193 lines, isolated from the app — easy to verify on its own. 2. **Bump `core` from 0.58 → 0.61** and fix the call sites. This is the risky half: windows-rs has breaking changes across those versions (error/`Result` handling, `BOOL` vs `bool`, the `Param`/`IntoParam` traits, the `core::Interface` reorganization), and `core/src/windows/{bluetooth,peripheral,central,network}.rs` lean on it heavily for GATT and hotspot work. Budget time to re-run the Windows BLE + hotspot hardware tests afterward. **Caveat — don't chase Tauri.** "Match whatever version Tauri uses" is a treadmill: Tauri will move to 0.62+ and we will diverge again. The durable goal is **retiring the 0.44 pin**; landing on the same version as Tauri is a pleasant side effect, not the objective. --- ## 2. Mobile transfers die when the app is backgrounded or the screen sleeps Reported by users and reproduced informally. **Neither mobile app does anything to keep running when it loses the foreground** — this is not a bug in the transfer code, it's a missing platform integration on both sides. Confirmed by audit, 2026-07-26: - **Android** — `AndroidManifest.xml` declares no `` at all, no `FOREGROUND_SERVICE` permission, no `WakeLock`, no `WifiLock`, and no `FLAG_KEEP_SCREEN_ON`. The only lock anywhere is the `MulticastLock` in `Discovery.kt:183`. Transfers run in bare `GlobalScope.launch` coroutines off `MainViewModel` (`MainViewModel.kt:366`, `:566`, `:906`), with UI callbacks bound to one Activity instance (`MainActivity.kt:240-244`). - **iOS** — `Apple/iOS/FlyingCarpet/Info.plist` has no `UIBackgroundModes` key. `SceneDelegate.swift:44` `sceneDidEnterBackground` is still the empty Xcode template. No `beginBackgroundTask`, no `isIdleTimerDisabled` anywhere in `Apple/`. **Screen sleep and app-switch are the same event on both platforms.** Display timeout stops the Activity on Android and backgrounds the scene on iOS. Any fix has to cover both, and the screen-timeout case is almost certainly the bulk of the reports: user starts a transfer, puts the phone down, the display sleeps, the peer sees a dead socket. ### iOS: structurally cannot continue in the background There is no way around this, and it should be treated as a constraint to communicate rather than a bug to fix. Apple DTS on this exact scenario ([forums/thread/715118](https://developer.apple.com/forums/thread/715118)): > "The thing to keep in mind with networking in iOS is that the background/foreground state > isn't key, but rather the **suspended/running state**. Networking works just fine as long as > your process is running. Once it's suspended, everything just stops." … "When the app on > Device2 gets suspended, its TCP connections will likely be closed immediately." … "My > general advice is that, when your app moves to the background you should shut down your > networking, resuming it when you come back into the foreground." No background mode covers a raw peer-to-peer TCP transfer. The list is audio, location, voip, external-accessory, bluetooth-central, bluetooth-peripheral, fetch, processing. `bluetooth-central` would only keep the BLE credential exchange alive, never the Wi-Fi transfer; `NSURLSession` background transfers only work against an HTTP server. In hotspot mode the `NEHotspotConfiguration` association to a no-internet network may also be dropped once suspended, so even resuming won't reliably find the peer. What can be done: 1. **`UIApplication.shared.isIdleTimerDisabled = true` for the duration of a transfer.** The single highest-value change on iOS — it eliminates the dominant cause outright. Roughly five lines in `toggleUI(transferRunning:)`, set and cleared symmetrically with the rest of the transfer UI state. 2. **`beginBackgroundTask` around the transfer** buys ~30 seconds (iOS 13 cut the from-foreground grant to about that). Enough to survive a glanced-at notification or a quick app switch and back; nowhere near enough for a real transfer. 3. **Fail loudly instead of hanging.** `sceneDidEnterBackground` currently does nothing, so the user watches a frozen progress bar and then gets a generic socket error. Wire it to the running `Transfer` and emit something explicit — "Flying Carpet was moved to the background; iOS suspends apps and cannot continue transfers there." Turns a mystery bug report into a comprehensible limitation. ### Android: fixable, and worth fixing Android doesn't force-suspend the process, it *kills* it. Once no Activity is visible the process has no foreground component and drops to a **cached** process, eligible for kill at any moment under memory pressure. That explains the intermittent, device-dependent character of the reports. On top of that, screen-off puts the Wi-Fi radio into power save and lets the SoC suspend. **Checked and found false:** AOSP's current `WifiNetworkFactory` validates foreground status only at *request* time (`isRequestFromForegroundAppOrService` in `acceptRequest()`); there is no continuous importance monitoring that revokes an established `WifiNetworkSpecifier` connection when the app backgrounds. So the joined hotspot is not proactively torn down — the process dying is what kills it. Don't waste time chasing a framework teardown that isn't there. Four changes, in descending value-per-effort: 1. **`FLAG_KEEP_SCREEN_ON` while a transfer runs.** One line, alongside the existing orientation lock at `MainActivity.kt:317`. Same reasoning as iOS: mostly makes the problem not happen. 2. **A foreground service.** The actual fix. Type **`connectedDevice`** is the right one, and its runtime prerequisite is already satisfied — it accepts `CHANGE_NETWORK_STATE`, `CHANGE_WIFI_STATE`, or `CHANGE_WIFI_MULTICAST_STATE`, all three of which the manifest already declares. Needs `FOREGROUND_SERVICE` + `FOREGROUND_SERVICE_CONNECTED_DEVICE`, plus `POST_NOTIFICATIONS` for the notification on API 33+. Declaring a type is mandatory at `targetSdk = 37` anyway. Starting it from the Start button is a legal foreground start, and the notification doubles as progress display and a cancel action. (`dataSync` also matches the description but carries Android 15's extra restrictions; `connectedDevice` is cleaner.) 3. **A `PARTIAL_WAKE_LOCK`** for the transfer's duration. A foreground service does **not** keep the CPU awake — a commonly missed point. With the screen off the SoC suspends and the transfer threads stop. 4. **A `WifiLock` in `WIFI_MODE_FULL_HIGH_PERF`.** Documented as keeping Wi-Fi at high performance "even when the device screen is off." **Do not use `WIFI_MODE_FULL_LOW_LATENCY` here** — AOSP documents it as activating only when the app "is running in the foreground" *and* "the screen is on," precisely the case that needs no help. HIGH_PERF is deprecated but is the mode that covers screen-off. This matters for shared-network discovery too: the `MulticastLock` stops the driver filtering multicast, but does nothing about the radio entering power save. **Sequencing.** Items 1, 3, and 4 are small and independently shippable; do them first. Item 2 is a real refactor, not a drop-in: `MainViewModel` currently owns the sockets *and* calls back into a specific Activity for `displayQrCode`, `promptForPassword`, and `cleanUpUi`. Transfer state has to move into the service with the UI observing it, rather than the transfer holding an Activity reference. --- ## 3. Android share sheet Long-standing TODO at `MainActivity.kt:795`. Sending a file would start from the sharing app rather than from Flying Carpet. **Manifest.** Add an intent filter to `MainActivity` for `ACTION_SEND` and `ACTION_SEND_MULTIPLE` with `category.DEFAULT` and `mimeType="*/*"`. Set `android:launchMode="singleTop"` and override `onNewIntent` — otherwise sharing into the app while a transfer is running spawns a **second** MainActivity with a fresh ViewModel while the first still holds the hotspot and sockets. **The refactor that makes it work.** `getFilePicker()` (`MainActivity.kt:66-108`) has the entire "we have files, now proceed" sequence inlined in its result callback: build `DocumentFile`s, open `InputStream`s, then either BLE advertise/scan or `connectToPeer()`. Extract that body into something like `stageFilesForSending(uris: List)` so the picker and the share intent share one path. Read the URIs from `EXTRA_STREAM` via `IntentCompat.getParcelableExtra` / `getParcelableArrayListExtra` — the untyped overloads are deprecated at API 33+. **UX: don't auto-start.** In hotspot mode the user still has to choose peer OS and connection mode. Cleanest flow: the share intent lands, files are staged, the mode toggle flips to Send, the folder checkbox hides, the Start button reads "Start" instead of "Select Files", and the Start handler skips the picker when staged URIs exist. Folder sends don't apply — the share sheet hands over files, so `sendFolder` stays false and a multi-file share maps onto the existing multi-file path with empty `filePaths`. **Two real gotchas:** - **Shared URIs are not `ACTION_OPEN_DOCUMENT` URIs.** `DocumentFile.fromSingleUri` mostly works on them by accident — `DocumentsContract.Document.COLUMN_DISPLAY_NAME` and `COLUMN_SIZE` happen to be the same column strings as `OpenableColumns.DISPLAY_NAME`/`SIZE` — but it isn't guaranteed, and `file://` URIs (still shared by some apps) fail outright. `sendFile` depends on `file.name` (`Send.kt:69`, which throws "Could not get filename" on null) and `file.length()` (`Send.kt:13`, `:20`, `:33`, `:81`). Worth a small name/size/openStream abstraction, or at minimum a `file://` → `DocumentFile.fromFile` branch. - **The share grant is Activity-scoped** and revoked when the Activity finishes; unlike an OPEN_DOCUMENT grant it cannot be persisted. Opening all the `InputStream`s eagerly (which the current code already does) covers most of it, but `hashFile` (`Utilities.kt:114-116`) *reopens* the URI mid-transfer during the resume/skip check. If the Activity is gone by then, that fails — another argument for the foreground service in §2. iOS is a different design entirely — see §4. --- ## 4. iOS share menu Worth doing, but **do not assume it mirrors the Android design.** Two iOS-specific restrictions rule out the obvious approach, and between them they mean there is no single mechanism that both handles multiple files *and* lands the user in the app. Verified 2026-07-26. ### Restriction 1: a Share Extension cannot open the containing app The natural design — extension stages the files, then opens Flying Carpet to run the transfer — is **not supported and is an App Store risk.** From Apple's Frameworks Engineer on [forums/thread/773342](https://developer.apple.com/forums/thread/773342): > "There's no supported way for you to launch your app directly from App Extensions, except > Today and Widgets (which requires `OpenURLIntent` and is available to processes that can use > App Intents), with the APIs currently available." `NSExtensionContext.open(_:)` is documented for Today extensions only, and the responder-chain walk to reach `UIApplication.openURL` is the exact Objective-C runtime bypass Apple calls out as unsupported. Apple offers no sanctioned alternative — their suggestion is a local notification, or a Feedback Assistant enhancement request. **Don't build on this.** ### Restriction 2: the document-types route is effectively single-file Declaring `CFBundleDocumentTypes` + `LSSupportsOpeningDocumentsInPlace` puts a "Copy to Flying Carpet" action in the share sheet, and that route *does* launch the app, delivering the file through `scene(_:openURLContexts:)`. No extension, no app group, no new target — much cheaper than a Share Extension. But open-URL requests are atomic and don't carry multiple URLs; iOS delivers only the first file even when several are shared. Multi-select in Photos may not offer the app at all. ### The resulting shape | | Multi-file | Opens the app | Placement | Cost | |---|---|---|---|---| | **Document types** (`CFBundleDocumentTypes`) | No — first file only | **Yes** | Lower "actions" row | Info.plist only | | **Share Extension** | **Yes** | No — user must switch manually | Top app row | New target, app group, provisioning, App Store resubmit | **Suggested order.** Start with **document types** — it is an Info.plist change, it covers the single-file case (share one video to Flying Carpet, which is likely the common one), and it actually opens the app. Add the Share Extension later for multi-select, accepting that it can only stage files and show "N files ready — open Flying Carpet to send," leaving the user to switch apps. ### If/when the Share Extension is built - **Stage into an App Group container, never the Documents directory.** `emptyDocsDir()` (`iOS/FlyingCarpet/ViewController.swift:579`) sweeps `.documentDirectory` wholesale and runs both at launch and before every transfer (`:65`, `:455`) — anything the extension dropped there would be deleted before it could be sent. Files must land in the shared container and be adopted deliberately into `transfer.fileList` on next foreground. - **Sweep the container too.** The extension has no way to know whether the user ever opened the app, so staged files accumulate. Extend the `emptyDocsDir()` discipline to the group container. - **Don't reuse the Live Photo staging pattern in the extension.** The PHPicker path reads a whole asset resource into an in-memory `NSMutableData` (`iOS/FlyingCarpet/ViewController.swift:~161-183`); extensions run under a far tighter memory budget than the host app. Use `loadFileRepresentation` + a filesystem copy, which streams. - **Scope `NSExtensionActivationRule` deliberately.** `TRUEPREDICATE` offers Flying Carpet for text and URLs, which it can't send. Use the `NSExtensionActivationSupportsFileWithMaxCount` / `ImageWithMaxCount` / `MovieWithMaxCount` keys with generous counts. - **Provisioning is the hidden cost.** A new bundle ID (`dev.spiegl.FlyingCarpet.ShareExtension`), an App Group entitlement on both targets, and its own profile — on top of `DEVELOPMENT_TEAM` already being deliberately blank in `project.pbxproj` (see `Apple/CLAUDE.md`). The iOS app currently declares no URL scheme and no app group, and shipped as 10.0.0 build 1, so this means a fresh App Store submission. macOS could take the same treatment via a Share Extension, but Macs have drag-and-drop and `NSSharingService` already; lower priority. --- ## 5. Dependency housekeeping notes The 2026-07-23 Dependabot sweep is done and its resolved-alert detail has been dropped from this doc. What's still worth carrying: - **`glib` (alert #26) remains open, blocked upstream.** Reached via `atk 0.18.2` → `gtk 0.18.2` → `muda` → `tauri`. Even Tauri 2.11.1 still pins the gtk-rs **0.18** family and the fix needs 0.20. **Linux/GTK-only**, so Windows and macOS builds are unaffected. Re-check whenever Tauri is next upgraded. - **`rand` 0.7.3** is also in the lock and in an affected range, but it comes from `phf_generator` as a **build-time** dependency — not in the runtime graph, not independently updatable. Expect it to keep showing up. - **The desktop frontend's JS/CSS is not covered by Dependabot.** `Flying Carpet/src/deps/` (`bootstrap.min.css`, `qrcode.js`) is vendored with no `package.json`, so it must be refreshed by hand. - **Toolchain is now rustc 1.97.1** (up from 1.85.0). If `rustup update stable` fails on the deprecated `rls-preview` component, remove it with `rustup component remove --toolchain stable rls-preview` and retry. - **rust-analyzer proc-macro crashes are version skew, not code.** `all proc-macro server workers have exited` on every `#[tauri::command]` and `#[derive(...)]` was rustc drifting ~17 months behind the auto-updating rust-analyzer in the VS Code extension, breaking the proc-macro bridge ABI. Fixed by the toolchain upgrade; restart the server afterward. If it recurs, suspect toolchain/rust-analyzer skew before suspecting the code. --- ## 6. Windows code signing via SignPath Foundation Unsigned `.exe`/`.msi` means every Windows user meets the SmartScreen "Windows protected your PC" wall on download. It also has a second cost that only became obvious while fixing #129: the firewall rule prompt is a **UAC prompt on an unsigned binary**, so it says *Unknown publisher* rather than the author's name — on the one dialog we most want users to trust. **Route: SignPath Foundation**, which signs open-source projects for free using Sectigo certificates. Flying Carpet qualifies on every published condition: OSI-approved license with no commercial dual-licensing (GPL-3.0-only), public repository, actively maintained, and already shipping releases in the form to be signed. Alternatives, both rejected on cost/benefit rather than capability: - **Azure Trusted Signing** (rebranded Azure Artifact Signing in 2026), $9.99/mo for 5,000 signatures, open to verified US/CA/EU/UK businesses *and self-employed individuals*. The disqualifier is that nothing is exportable: lose eligibility or leave Azure and signing stops, with no certificate to carry elsewhere. - **A traditional OV certificate**, ~$200-400/yr, and since the 2023 CA/Browser Forum rules the private key must live on a FIPS 140-2 Level 2 token or HSM — no more `.pfx` on disk. ### Order of operations Apply to the Foundation **first**. The application requires a project that already publishes releases, and it issues the `organization-id`, `project-slug`, and `signing-policy-slug` that the workflow has to reference — so the workflow cannot be finished before acceptance. ### Constraints that shape the CI design - **GitHub-hosted runners only.** For OSS projects, every job leading up to the signing request must run on GitHub-hosted agents. Self-hosted is permitted only for non-OSS. - **Origin verification dislikes caches.** Build settings must be fully determined by config under source control with no manual overrides in the job, and builds must not be contaminated by previous builds' caches. So **no `Swatinem/rust-cache`** here. A cold `cargo tauri build` on `windows-latest` runs ~20 minutes, which is acceptable for something that only fires on a version tag. Confirm the exact caching stance against the assigned policy. - **This repo has no CI at all** — `.github/` holds only `FUNDING.yml` — so this is the first workflow, and nothing existing depends on it. ### The Tauri wrinkle — decide this deliberately Windows bundling produces three signable artifacts, and the installers **embed** the app binary: 1. `target/release/FlyingCarpet.exe` — the app binary 2. `target/release/bundle/nsis/FlyingCarpet__x64-setup.exe` — renamed to `FlyingCarpet_.exe` 3. `target/release/bundle/msi/FlyingCarpet__x64_en-US.msi` Signing only the installers leaves the *installed* `FlyingCarpet.exe` unsigned, which means the firewall UAC prompt still reads "Unknown publisher" — i.e. the specific problem above goes unfixed. Doing it properly needs two signing requests: `tauri build --no-bundle`, sign the binary, drop it back into `target/release/`, then `tauri bundle` and sign the installers. Start with the one-request version to prove the pipeline, then add the second. ### Workflow skeleton Only one secret to configure, `SIGNPATH_API_TOKEN`. Artifacts pass by ID, not by path. ```yaml name: Sign Windows Release on: push: tags: ['v*'] jobs: windows: runs-on: windows-latest permissions: contents: write # attach assets to the release actions: read # SignPath reads the workflow run steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable - run: cargo install tauri-cli --version "^2" --locked - run: cargo tauri build working-directory: "Flying Carpet" - name: Stage artifacts shell: bash run: | mkdir -p dist cp target/release/bundle/nsis/*.exe dist/ cp target/release/bundle/msi/*.msi dist/ - id: unsigned uses: actions/upload-artifact@v4 with: { name: unsigned-windows, path: dist/ } - uses: signpath/github-action-submit-signing-request@v2 with: api-token: ${{ secrets.SIGNPATH_API_TOKEN }} organization-id: ${{ vars.SIGNPATH_ORG_ID }} project-slug: flyingcarpet signing-policy-slug: release-signing artifact-configuration-slug: windows-installers github-artifact-id: ${{ steps.unsigned.outputs.artifact-id }} wait-for-completion: true output-artifact-directory: signed/ - uses: softprops/action-gh-release@v2 with: { files: signed/* } ``` ### Two things that will look like bugs and aren't - **The first runs appear to hang.** Foundation release-signing policies typically require a human approver in the SignPath UI, and `wait-for-completion: true` blocks until that happens. - **Asset names must be renamed.** Tauri emits `FlyingCarpet__x64-setup.exe`, but the published asset is `FlyingCarpet_.exe`. Without a rename step the download links and release history go inconsistent. ### Scope Windows only. Android already signs with the release keystore — its certificate (`b6b7891c…c211405`, `CN=Theron Spiegl`) was verified on 2026-07-27 to still match F-Droid's `AllowedAPKSigningKeys`, so developer verification did not disturb it. Apple has its own notarization path, and the `.deb`/`.AppImage` are unaffected. Separately noted while checking that: the release APK is signed with **v2 only** (no v3), so there is no `PROOF_OF_ROTATION` path — that keystore is permanent and unrecoverable-critical for both Play and F-Droid. Enabling v3 on a future release is a one-line `signingConfig` change but does not retroactively help published versions. References: `docs.signpath.io/trusted-build-systems/github`, `docs.signpath.io/origin-verification`, `signpath.org/terms.html`. --- ## 7. Android asks for location on every version, including those that don't need it Found during 10.0.3 device testing, 2026-08-05, after `neverForLocation` landed on `BLUETOOTH_SCAN` (commit `efb1927`). That flag removed the last reason API 31+ needs location for Bluetooth, but nothing downstream was updated to match. ### Where it stands `AndroidManifest.xml` declares `ACCESS_FINE_LOCATION` and `ACCESS_COARSE_LOCATION` with no `maxSdkVersion`, and both branches of `permissions` (`MainActivity.kt:725`) include fine location, so every launch on every supported version asks for it as part of the Bluetooth bundle. `checkForBluetoothPermissions()` (`:746`) then treats it as mandatory for Bluetooth to work at all, which is no longer true above API 30. ### Target Location requested only where it is genuinely required: | API | Needed for | Source | |---|---|---| | 29–30 | BLE scanning and hosting a hotspot | `neverForLocation` doesn't exist below 31 | | 31–32 | Hosting only | `startLocalOnlyHotspot()`; `NEARBY_WIFI_DEVICES` arrives at 33 | | 33+ | Nothing | — | ### Changes 1. Add `android:maxSdkVersion="32"` to `ACCESS_FINE_LOCATION` in the manifest, and the same to `ACCESS_COARSE_LOCATION`. Keep coarse rather than deleting it — see the risk below. 2. Drop `ACCESS_FINE_LOCATION` from the `SDK_INT >= S` branch of `permissions` (`MainActivity.kt:728`), leaving the pre-31 branch untouched. 3. Delete the commented-out `ACCESS_COARSE_LOCATION` at `MainActivity.kt:727`. Dead since it was commented out. 4. Leave `startHotspot()` alone. `MainViewModel.kt:628` already picks fine location below 33 and `NEARBY_WIFI_DEVICES` at 33+, and requests it on demand, so it becomes the sole requester on 31–32 and hosting keeps working. The prompt moves from launch to the first hotspot, which is the visible behavior change. An earlier reading of this called for a three-way split of the request array. It doesn't: the on-demand request in `startHotspot()` already covers 31–32. ### The risk to design around Android 12 requires that a fine-location request also declare and request coarse location in the same call, or the dialog is refused. After change 2, the only fine-location request on 31–32 is `startHotspot()`'s single-permission `launch(requiredPermission)` (`MainViewModel.kt:637`). That may already be non-conforming today and working anyway, because the permission is normally already granted from the launch bundle, which masks it. Verify on an emulator before deciding whether to widen the call to request both together. Hosting working on a current device is not evidence either way. ### Testing An Android 12 emulator (API 31 or 32) is the one that matters, for the request path and the coarse/fine pairing above. The Samsung A03s on 13 confirms no location prompt appears at all and that hosting still works through `NEARBY_WIFI_DEVICES`. An API 29 or 30 emulator confirms the old path is untouched. Emulators can't host a real hotspot, so `startLocalOnlyHotspot()` succeeding stays a hardware check. `README.md:74` describes the reasons rather than the versions, so it stays accurate either way, but revisit it once behavior matches. --- ## 8. Bluetooth off at launch is unrecoverable without restarting Found the same day, while removing a capability check that turned out to be unnecessary. Bluetooth merely being switched off reaches the same failure branch as a device with no BLE support, because `openGattServer()` returns null either way. Commit `833a0a4` made the message name which one it is, but not the recovery: that branch calls `setBluetoothSwitchEnabled(false)`, so the switch listener at `MainActivity.kt:808` that would retry `initializeBluetooth()` can't be tapped, and `onResume()` (`:843`) only retries when permissions were the problem. Turning Bluetooth on has no path back, which is why the message currently says to restart the app. ### Two fixes, covering different flows Leave the switch enabled when the failure is the adapter being off rather than absent hardware — the distinction the message already draws. This mirrors what #101 did for permission denials and makes tapping the switch the recovery. It covers turning Bluetooth on from the quick-settings shade, which doesn't stop the activity and so never fires `onResume()`. Retry in `onResume()` when the adapter is now enabled, alongside the existing permissions check. This covers leaving the app to change the setting and coming back. Needs a flag beside `bluetoothPermissionsMissing` recording that initialization failed on adapter state, so a genuinely incapable device isn't retried on every resume. Then drop "restart Flying Carpet" from the message in favor of tapping the switch. ### Alternative A `BluetoothAdapter.ACTION_STATE_CHANGED` receiver handles both flows automatically. The app already registers one receiver for bond state (`:824`), so it isn't foreign, but it's more lifecycle to get right. Reach for it only if the flag approach gets awkward. ### Testing Device-only and quick. Launch with Bluetooth off, confirm the message, turn Bluetooth on from the shade, tap the switch, confirm a transfer works. Repeat by backgrounding the app instead of tapping. --- ### Send Folder Behavior # "Send Folder" behavior across the five platforms Audited and fixed 2026-07-24, branch `shared-network` (both repos). ## The rule, as of now **Selecting a folder recreates that folder inside the destination the receiver chose, with its contents inside. Selecting individual files puts those files loose in the destination.** All five platforms behave this way. Implemented uniformly as: **every top-level selection is named relative to its own parent directory.** That single rule produces both behaviors — a selected folder's own name becomes the first path component, while a selected file's parent is stripped down to the bare filename — and it degrades sensibly for mixed and multi-directory selections. The wire format is unchanged: one relative filename string per file, `/`-separated, as before. This was never a protocol question — receivers just `mkdir -p` whatever parent components arrive. It was entirely a sender-side choice of which prefix to strip. ## What it used to be macOS was the only platform that created the folder; the other four dumped the contents. | Platform | Was | Now | |---|---|---| | **macOS** | creates the folder | unchanged — this was the model | | **iOS** | dumps contents | creates the folder | | **Android** | dumps contents (and broke against desktop, below) | creates the folder | | **Windows** | dumps contents | creates the folder | | **Linux** | dumps contents | creates the folder | ## Where each platform implements it **macOS** — `Apple/shared/Transfer.swift`, `handleFileSelection`: `sendDir = urls[0].deletingLastPathComponent()`, with `sendFolder = true` hardcoded. Unchanged. **iOS** — same function, iOS branch. Was `sendDir = urls[0]` (the folder itself); now `urls[0].deletingLastPathComponent()`, matching macOS. Both platforms share the prefix-strip in `Send.swift:112-119`, so they now agree. **Android** — `Utilities.kt` `getFilesInDir(dir, pathSoFar)`, seeded from `MainActivity.kt`. Was seeded `""`; now seeded with `dir.name`. **Windows / Linux** — `core/src/utils.rs` `expand_selection()`, called from the Tauri `expand_files` command (`Flying Carpet/src-tauri/src/main.rs`). Each selected root is expanded and its files named against the root's parent. The result — `Vec`, `{path, name}` — flows through `Mode::Send` to `sending::send_file`, which now just writes the name it was given. Moving the naming decision to selection time is what made the desktop side correct: the old code tried to recover a prefix at send time from a flat list of absolute paths, by which point the information about *what the user actually picked* was gone. ## Bugs fixed along the way ### 1. Android → Windows/Linux failed outright for folders with sub-folders `getFilesInDir` built nested paths as `pathSoFar + '/' + name` from a seed of `""`, so the first level produced `"/sub"` and Android sent **`/sub/a.jpg`** — with a leading slash. Receivers disagreed about that slash. Android (`sanitizeRelativeFilename`) and Apple (`safeDestinationURL`) both skip empty components and coped. The Rust receiver (`receiving.rs` `sanitize_relative_filename`) sees `Component::RootDir` and errors with `Received invalid filename path: /sub/a.jpg`, aborting the transfer. Worth recording that the hardening in `5debeda` did not cause this — it exposed it. Before that commit the Rust receiver did `full_path.push("/sub/a.jpg")`, and pushing an absolute path **replaces** the buffer, so those files were written outside the chosen destination entirely. Turning a silent path escape into a loud error was correct; the defect was always on the Android side. Fixed by seeding with the folder name (which makes `pathSoFar` non-empty) *and* by guarding the join so it can never emit a leading separator regardless of seed. Covered by `names_are_relative_and_slash_separated`, which asserts the Rust receiver's own sanitizer accepts everything the Rust sender produces. ### 2. Desktop lost a directory level, or aborted, depending on the folder's shape The old prefix was "the parent with the fewest components", ties broken by walk order: - `P` with ≥1 file directly inside → prefix `P`. Contents dumped (the old intended behavior). - `P` containing only `P/s1` → prefix became `P/s1`, silently dropping `s1` from every path. - `P` containing only `P/s1` and `P/s2` → prefix locked to whichever was walked first, and every file under the sibling failed `strip_prefix`, killing the transfer with `Error sending file: Strip prefix error`. The same abort hit drag-and-drop of two folders at once, or of files from two different directories — which the deleted comment in `lib.rs` had half-acknowledged. Resolving each selection against its own parent removes the shared-prefix concept entirely, so all of these now work. Covered by `folder_of_only_subdirectories_keeps_full_structure` and `selections_from_different_directories_coexist`. ### 3. Empty file list panicked the desktop sender `start_transfer` indexed `files[0]` to seed the prefix. JavaScript's `if (!selectedFiles)` check passes an empty array through (`[]` is truthy), so an empty selection reached the core and panicked the transfer task. `Mode::Send` now rejects an empty list with a message, and the frontend reports empty selections before starting. ### 4. Short write of the file hash Unrelated to folders, found by clippy while working here: `sending.rs` used `stream.write()` for the 32-byte "do you already have this file" hash. A partial write would have sent a truncated hash and left the remainder to be read as the next protocol field. Now `write_all`. ### 5. Help text described a gesture that mostly doesn't exist > "(To send a folder, drag it onto the window instead of clicking 'Start Transfer'.)" Copy-pasted into the desktop app, Android, and macOS. Android has no drag-and-drop (it has a Send Folder checkbox), and macOS has no drop handler either — its `NSOpenPanel` sets `canChooseDirectories = true`, so you just pick the folder. Each platform's text now describes what that platform actually supports, and all four (plus iOS's storyboard help) state that a sent folder is recreated on the receiving end. ## Tests `core/src/utils.rs` `selection_tests` — six cases over a real temp tree, run by `cargo test`: | Test | Guards | |---|---| | `selected_folder_keeps_its_own_name` | the headline behavior | | `selected_files_are_flat` | ordinary file sends didn't regress into folders | | `folder_of_only_subdirectories_keeps_full_structure` | bug 2 | | `selections_from_different_directories_coexist` | bug 2's abort cases | | `names_are_relative_and_slash_separated` | bug 1, asserted against the real receiver sanitizer | | `nonexistent_selection_is_skipped` | unreadable paths don't abort a whole transfer | `transfer_tests::end_to_end_encrypted_transfer` now sends under `album/photo.bin` and asserts the file arrives at `recv/album/photo.bin`, so directory recreation is covered end to end through the real Noise stack. Kotlin and Swift are covered by the Tier 6 hardware rows in `docs/v10-release-test-plan.md` — neither has a unit-test seam over its platform file APIs (SAF `DocumentFile`, `NSFileCoordinator`). ## Cross-repo note The iOS/macOS half of this lives in `Apple/` (`shared/Transfer.swift`, `macOS/…/AppDelegate.swift`, `iOS/…/Main.storyboard`) and must land with the changes here. No wire-format bytes changed, so this is not a protocol break and needs no version bump — but shipping one repo without the other would restore exactly the user-visible inconsistency this removes. --- ### Shared Network Crypto # Shared Network Mode: Cryptographic Design Status: **Implemented on all three platforms** (Rust core, Android/Kotlin, Apple/Swift), each verified byte-for-byte against the official Noise (cacophony) vector and shared app KATs, including the preamble→prologue binding and tamper-negative tests (§11 post-review hardening). Windows↔Android confirmed on real hardware pre-binding; first post-binding live transfer re-confirms. Remaining: the rest of the live cross-platform matrix (§11 Phase 4), and porting the PSK-derived discovery HMAC key (§7, §9) to the Apple repo — Rust and Android already have it. Audience: an engineer working across the Rust core, the Swift (iOS/macOS) app, and the Kotlin (Android) app. This document explains *why* the design is shaped the way it is, not just what to build, so that the person writing the code understands which properties are load-bearing and which lines they must not "simplify." --- ## 1. Why we're changing anything Today, shared network mode derives its key as `key = SHA256(password)` and encrypts only file *contents* with AES-GCM. This was acceptable when the only mode was hotspot, because the two devices formed their own WPA2 network with no third party in the path. Over a shared network (a café AP, an office LAN, an operator you don't control) the in-path attacker is the *expected* case, and the current design fails against it in three ways: 1. **Offline password cracking.** `SHA256(password)` is a single fast hash with no salt and no work factor. An eavesdropper who records the TCP stream tries each candidate password — `SHA256(guess)` → trial-decrypt one chunk → the AES-GCM tag says "right/wrong" instantly. The password space is ~2⁴⁸ (8 chars from a 55-symbol set), which a GPU clears in minutes-to-hours. 2. **No forward secrecy.** The key is a pure function of the password, so *every* transfer that ever used that password shares one key, and recovering the password (point 1) retroactively decrypts all of them. 3. **Cleartext metadata.** Only chunk *payloads* are encrypted. File count, every filename and its length, every file size, every chunk length, and the SHA-256 file hashes are sent in the clear. A passive observer reads your directory structure and file sizes with no cracking at all. The goal of this design is to fix all three using **only primitives available in Apple's CryptoKit** (a hard constraint: no third-party crypto on Apple platforms), which rules out both Argon2id (no memory-hard KDF in CryptoKit) and any PAKE like SPAKE2 (none in CryptoKit). What we *can* build from CryptoKit — X25519, HKDF, AES-GCM/ChaChaPoly, HMAC — is a **password-authenticated ephemeral key exchange**. It does not reach full PAKE security, and §7 is explicit about the one gap that leaves. --- ## 2. Threat model **Assets we protect:** file contents, filenames, file sizes, file count, hashes — i.e. everything about what is being transferred. **Attacker A — passive eavesdropper.** Sees every byte on the wire (same Wi-Fi, span port, malicious operator) but does not inject or modify traffic. This is the common, realistic threat on an untrusted network. **We defeat this attacker:** they read nothing, and their only recourse is a PBKDF2-hardened offline password crack (§7) that cannot plausibly finish within the single-use password's lifetime — and that, thanks to forward secrecy, decrypts nothing even if it someday does. **Attacker B — active in-path attacker.** Can inject, modify, and MITM the TCP connection, e.g. via ARP spoofing on the LAN, and can impersonate the peer's IP to win the connection. **We detect this attacker** (they cannot silently sit in the middle and read plaintext), **but** completing one handshake with a victim hands them an *offline* dictionary attack on the password (§7). A real PAKE would deny even that; CryptoKit can't give us one. **Explicitly out of scope:** traffic *volume* and *timing*. The length prefix of each encrypted record is in the clear (you must know how many bytes to read), so an observer still sees the approximate total transferred and the record cadence. Hiding that needs padding/cover traffic, which we do not do. The **plaintext preamble** (protocol version and send/receive direction) that precedes the handshake is visible to an eavesdropper — these values are not secret — but it is not tamperable in any useful way: every preamble byte is bound into the Noise **prologue**, so modifying it in flight fails the handshake (§7). --- ## 3. The building blocks (plain-language) **X25519 (Elliptic-Curve Diffie–Hellman).** Each side makes a random *ephemeral* keypair: a secret scalar and a public point (`A = a·G`). They swap public points over the open channel. Each combines its own secret with the other's public to get the *same* 32-byte shared secret: `a·B = b·A = ab·G`. The property we rely on: a passive observer who sees only `A` and `B` **cannot** compute `ab·G` — that's the Computational Diffie–Hellman assumption, the same one all of TLS rests on. "Ephemeral" = the keypair is generated fresh per transfer and thrown away after, which is what gives us forward secrecy. **HKDF (HMAC-based Key Derivation Function).** Turns raw secret material (like the ECDH output) plus context into one or more uniformly-random keys. Two steps: *extract* (`salt`, input) → a pseudorandom key, then *expand* (that key, `info` label) → output key(s). We use the `info` labels for *domain separation*: deriving several independent keys from one exchange by expanding with different labels. HKDF is **not** a password hardener — it has no work factor — so it is never used *alone* on the password. **AEAD (AES-256-GCM or ChaCha20-Poly1305).** Symmetric encryption that provides confidentiality *and* integrity: each encryption produces ciphertext plus a 16-byte authentication tag; decryption fails loudly if any bit was altered. Each encryption takes a **nonce** that must **never repeat under the same key** — nonce reuse in GCM is catastrophic (it leaks the XOR of plaintexts and can forge tags). Nonce management is the single most dangerous part of this design to get wrong; see §6. **HMAC.** A keyed integrity tag. We use it for *key confirmation*: proving each side derived the same session key (which simultaneously proves each side knew the password). All five are in CryptoKit (`Curve25519.KeyAgreement`, `HKDF`, `AES.GCM` / `ChaChaPoly`, `HMAC`) and in RustCrypto (`x25519-dalek`, `hkdf`, `aes-gcm`/`chacha20poly1305`, `hmac`). On Android they come from JCA (`KeyAgreement "XDH"` for X25519, API 30+; `Mac "HmacSHA256"`; `Cipher "AES/GCM/NoPadding"`) plus BouncyCastle for HKDF (or hand-rolled from HMAC) — **not** Google Tink: Tink deliberately hides raw ECDH (its Java `X25519` lives in the `subtle`/`@Alpha` namespace marked "do not use in production, may be removed at any time," and Tink Java has no production X25519 key-agreement key type). Tink is a high-level misuse-resistant library (AEAD, HPKE, MAC, signatures) and is the wrong abstraction level for hand-assembling a handshake. In practice you would not source these individually on Android at all — see §8. --- ## 4. The handshake, step by step Roles are already fixed by the existing protocol: the **receiver is the TCP server**, the **sender is the TCP client**. They already share the password (receiver displays it, sender types it) and already have a TCP connection from discovery. The handshake is the first thing that runs on that connection, before any file metadata is exchanged. ``` Sender (TCP client) Receiver (TCP server) | | gen ephemeral (a, A=a·G) gen ephemeral (b, B=b·G) | | |---------------- A (32 bytes) ------------->| |<--------------- B (32 bytes) --------------| | | dh = X25519(a, B) dh = X25519(b, A) | (both now hold ab·G) | | | derive keys = HKDF( ikm = dh || pw, (identical derivation) salt = transcript, labels... ) | | |------ confirm_sender = HMAC(kc_s, T) ----->| verify; abort if bad |<----- confirm_recv = HMAC(kc_r, T) ------| verify; abort if bad | | | |=========== encrypted record layer =========| | all file metadata + all file data, | | AES-GCM under directional keys | ``` Where: - `pw` is the password material (see §5 on whether to PBKDF2 it first). - `transcript = A || B` (the two 32-byte public keys in a fixed order — client's then server's). Binding the transcript into the derivation ties the derived keys to *this specific exchange*, which is what stops an attacker from replaying or reflecting one side's messages. - `T` is a confirmation transcript, e.g. `"FC-v10-confirm" || A || B`. **Why the cleartext public-key swap is safe.** `A` and `B` go over the wire before any key exists, so an active attacker (B) can swap in their own public key — the classic unauthenticated-DH MITM. That is exactly what the **password-bound key confirmation** catches: if the attacker substitutes their key, the victim derives its session key from `(attacker's DH) || password`, and the attacker would need the *password* to produce a matching `HMAC` confirmation. They don't have it, so confirmation fails and the transfer aborts. The password is what turns an otherwise-unauthenticated DH into an authenticated one. --- ## 5. Key derivation — exact recipe Do **not** invent the mixing yourself beyond this recipe. Better still, see §8: model the whole handshake on the Noise Protocol Framework's PSK pattern, which specifies all of the below in a reviewed way and is buildable from these same primitives. ``` ikm = dh (32 bytes) || pw_material # dh MUST be included -> passive immunity salt = transcript = A || B # 64 bytes, same on both sides prk = HKDF-Extract(salt, ikm) # Four independent keys, one HKDF-Expand each, distinct info labels: k_s2c = HKDF-Expand(prk, "FC-v10 sender->receiver data", 32) k_r2s = HKDF-Expand(prk, "FC-v10 receiver->sender data", 32) kc_sender = HKDF-Expand(prk, "FC-v10 sender confirm", 32) kc_recv = HKDF-Expand(prk, "FC-v10 receiver confirm", 32) ``` - **Hash:** SHA-256 throughout (HKDF-SHA256, HMAC-SHA256). - **AEAD:** pick **one** and hard-code it for interop. AES-256-GCM is the safe default (hardware-accelerated everywhere, in CryptoKit as `AES.GCM`). ChaCha20-Poly1305 is an equally fine alternative; do not make it negotiable in v1. - **`pw_material`:** at minimum the raw UTF-8 password bytes. **Recommended:** `pw_material = PBKDF2-HMAC-SHA256(password, salt = transcript, iterations = 600_000)`. PBKDF2 is available on Apple via CommonCrypto (a system library) and in RustCrypto / JCA. Note carefully *what this buys*: in **this §4–§6 construction**, the passive attacker has no oracle regardless of KDF (the first password-dependent value on the wire is keyed under `dh`, which they can't compute), so PBKDF2 only slows the **active** attacker's offline crack. **The implemented `NNpsk0` design does not share that property** — its first handshake message is checkable by a passive observer (§7), which makes PBKDF2 the primary defense there, not defense-in-depth. Using the transcript as the PBKDF2 salt is fine — a salt need not be secret, only unique per session, which the ephemeral transcript guarantees (and it prevents any precomputation). **Key confirmation.** Sender sends `HMAC(kc_sender, "FC-v10 confirm" || A || B)`; receiver recomputes and compares in constant time, aborting on mismatch, then sends `HMAC(kc_recv, ...)` which the sender likewise verifies. A mismatch means wrong password **or** an active MITM — the user-facing message should say "could not establish a secure connection; check the password and that you trust this network," not "wrong password" (the two are indistinguishable here, and that's fine). --- ## 6. The record layer (this is where metadata gets encrypted) > **Wire-order note (implemented design).** The version and send/receive mode are > negotiated in a **plaintext preamble** on the raw TCP socket *before* the Noise > handshake; everything after the handshake — file count, all metadata, and all file data > — is inside Noise. This holds for **both** hotspot and shared network mode: they are > identical past the preamble. Every preamble byte, sent and received, is recorded and > bound into the Noise **prologue** (§9), so the preamble is readable in the clear but not > silently modifiable: tampering makes the two sides' prologues differ, which fails the > handshake. See §10 for why the preamble is plaintext and §7 for the security discussion. The important architectural point: **we do not rewrite the send/receive protocol.** The existing logic — send file count, send filename length, send filename, send size, send chunks — runs unchanged, but writes into an *encrypting wrapper* instead of the raw socket. The chunks are sent as **raw bytes** (no application-level cipher); Noise is the sole encryption layer. Every logical message becomes one AEAD record: ``` on the wire: [ 2-byte big-endian length ] [ ciphertext || 16-byte AEAD tag ] ``` > Implementation note: the shipped Rust reference builds this record layer from Noise's > transport messages (§8), so the "directional keys" and "counter nonces" below are > managed by Noise/`snow` internally rather than derived by hand, and the length prefix is > **2 bytes** (`u16`) because Noise caps a message at 65535 bytes. §4–§6 are the conceptual > model; §9 lists the exact framing and test vectors the implementation actually uses. - **Directional keys.** Sender→receiver records use `k_s2c`; receiver→sender records use `k_r2s`. Two keys means the two nonce counters live in separate spaces and can never collide — this is the clean way to avoid nonce reuse between the two directions. - **Counter nonces, not transmitted.** Each direction keeps a 96-bit counter starting at 0, incremented by exactly one per record sent. The nonce is the counter, big-endian; it is **not** put on the wire (both sides track it deterministically, like TLS 1.3 sequence numbers). Never reset a counter mid-connection. Never reuse a key across connections (the ephemeral handshake guarantees fresh keys each time). - **GCM data limit.** Stay well under ~2³⁹ bytes (~64 GB) encrypted under a single directional key before the GCM security margin degrades. Flying Carpet transfers are far below this; if you ever expect larger, that's the point to add rekeying, but do not add it speculatively. - The plaintext inside each record is exactly what the current protocol writes today: the u64 file count, the u64 filename length + filename bytes, the u64 file size, and the file chunks. All of it is now confidential and tamper-evident. The header-value bounds we already added (file count / filename length / chunk size sanity checks) still apply to the *decrypted* values. --- ## 7. What this achieves — and why the residual gap barely matters here | Property | Status | |---|---| | Confidentiality of file **contents** | ✅ AEAD under session key | | Confidentiality of **metadata** (names, sizes, count, hashes) | ✅ record layer | | Integrity / tamper-evidence | ✅ AEAD tags + key confirmation | | Forward secrecy (past transfers safe if password later leaks) | ✅ ephemeral X25519 | | Offline password crack by **passive** eavesdropper (Attacker A), *from the Noise channel* | ⚠️ **PBKDF2-hardened** — the first handshake message's AEAD tag is an offline oracle (see below) | | Offline password crack by **passive** eavesdropper, *from the discovery announcement* | ⚠️ **PBKDF2-hardened** (same 600k-iteration cost) — see note below | | Active MITM goes undetected | ✅ **prevented** (key confirmation) | | Offline password crack by **active** attacker (Attacker B) | ⚠️ possible at the same PBKDF2 cost, and **yields nothing of value** — see below | **Discovery is keyed from the stretched PSK (shared network only).** The Noise channel rows above are not the whole wire: the UDP discovery announcement is also HMAC-signed with a password-derived key. It used to be `HMAC(SHA256(password), …)` — a *fast* hash, which gave a **passive** eavesdropper who captured a single announcement an offline dictionary attack cheap enough (~hours on one GPU, minutes on a rig, over the ~2⁴⁸ space) to plausibly finish **while the password was still live** (receiver waiting, or mid-transfer of something large). A live recovered password defeats everything: the attacker knows the PSK and can run a fully valid MITM that passes key confirmation. This was the one scenario that broke the "effectively as strong as a PAKE" argument below. Fixed: the discovery HMAC key is now `derive_discovery_key(psk) = HMAC-SHA256(psk, "Flying Carpet v10 discovery")` (§9), where `psk` is the PBKDF2-stretched key — so a captured announcement costs an offline attacker 600k PBKDF2 iterations per guess, identical to the handshake-message oracle below: centuries per GPU, not hours. The label gives domain separation (the Noise PSK itself is never used outside the handshake). No fast hash of the password goes on the air; `SHA256(password)` survives only in the hotspot SSID's 2-byte tag, which is not part of shared network mode. *Why not drop discovery authentication entirely?* Considered and rejected. It would not remove the passive oracle — `NNpsk0` message 1 carries the same PSK-keyed tag in every recorded transfer regardless (below); it would only shrink the *live* oracle window from minutes (announcements start once the receiver has the password) to milliseconds (message 1 immediately precedes handshake completion). Nor does the HMAC gate online guessing: the receiver's TCP port accepts direct connections from anyone, and Noise limits any connector to one online guess either way. What the HMAC actually buys is **peer selection**: the sender connects only to the machine that provably holds the password, so concurrent transfers on one LAN can't cross-connect and mutually fail, and an in-LAN mischief-maker can't answer discovery first to make every transfer die at the handshake. That reliability is worth a PBKDF2-hardened oracle window measured in minutes against a crack measured in GPU-years. **The residual gap: the wire carries PBKDF2-hardened password oracles.** The §4–§6 conceptual design had the property that a *passive* observer gets no oracle at all — the first password-dependent value there is keyed under `dh`, which they can't compute. The implemented `NNpsk0` pattern does **not** have that property. Per the Noise spec (§9.1), in `psk` handshake patterns the `e` token additionally calls `MixKey(e.public_key)`, so the initiator's first message — 32-byte ephemeral plus a 16-byte AEAD tag over an empty payload — is keyed by a function of (protocol name, prologue, PSK, e.pub): everything public except the PSK. A passive eavesdropper who records message 1 can test passwords offline: PBKDF2(guess) → run the key schedule → check the tag. The discovery announcement gives the same oracle even earlier (above). And an active attacker (Attacker B) who *terminates* one side's connection gets the equivalent oracle from the victim's handshake message — this last one is inherent to any password-authenticated exchange that is **not** a PAKE: someone must send a password-dependent message first, and its recipient gets an offline oracle. Every one of these oracles costs the same — one 600k-iteration PBKDF2 per guess, over a ~2⁵⁸ space (10 CSPRNG chars from a 57-symbol set): hundreds of thousands of GPU-years. The length also forecloses the one shortcut the fixed PBKDF2 salt (§9) leaves open in principle — a one-time precomputed table over the whole password space, reusable against every transfer — which at 2⁵⁸ is infeasible in both compute and storage (~exabytes). In a system with **long-term or reused** passwords the oracle would still matter — crack once (however slowly), impersonate forever — and a formally-proven PAKE (SPAKE2, CPace) is what removes it, by limiting even an active attacker to one *online* guess per connection with nothing crackable ever hitting the wire. A PAKE is precisely what the CryptoKit-only constraint excludes. **Why it has no operational payoff in Flying Carpet.** Two properties of this specific design neutralize the gap: 1. **The password is single-use and randomly generated.** The receiver mints a fresh CSPRNG password per transfer and displays it out-of-band; it is never reused. The offline crack is, by construction, not real-time — PBKDF2 makes each guess cost ~600k hashes, so recovering the password takes far longer than the transfer it belonged to. By the time the attacker holds it, it authenticates nothing, decrypts nothing, and predicts nothing (CSPRNG output doesn't leak future outputs). The "crack once, impersonate later" payoff requires the reuse this design doesn't have. 2. **A cracked password never decrypts recorded traffic.** A recorded session *does* contain the oracle — message 1's tag rides in every taped transfer — so an attacker can, in principle, spend the GPU-centuries and recover the password of a transfer they recorded. It buys nothing retroactive: the transport keys also depend on the ephemeral `ee` DH secret, which no amount of password knowledge reveals (the same CDH wall the passive attacker hits — this is forward secrecy doing its job). A recovered password is only useful *prospectively*: impersonating an endpoint of, or MITMing, a handshake that hasn't happened yet. So the crack would have to finish inside the password's live window — from first discovery broadcast to handshake completion, seconds to minutes — against a cost of years-to-centuries of GPU time. Outside that window, the single-use password authenticates nothing, decrypts nothing, and predicts nothing. **Net.** Against every attacker this design actually faces — passive eavesdropper, and active LAN attacker who intercepts a live transfer — cracking the recovered password yields nothing of value. The construction is therefore, *for Flying Carpet's single-use-password model*, effectively as strong as a PAKE would be here. A PAKE would add cleaner theory and defense-in-depth, but no practical protection against any realistic attacker — which substantially weakens the case for taking on the SPAKE2 cross-language burden. **Load-bearing invariants.** If either is ever broken, the reasoning above collapses and the case for a PAKE returns: - **Passwords must remain single-use.** No "remember this password," no user-chosen fixed passwords, no reuse across transfers. Treat this as a security invariant of the mode, not a UI convenience — the entire "the crack is worthless" argument rests on it. - **Passwords must come from a CSPRNG** (`rand::thread_rng` / `SecureRandom` / `SecRandomCopyBytes`), so recovering past passwords never helps predict future ones. **Caveats that remain regardless.** An active attacker can still *disrupt* a transfer — intercept-and-abort is a denial of service available to anyone in-path, unrelated to the crypto and not fixable by it. The **plaintext preamble** (§6) is *bound into the Noise prologue* (§9): an attacker who flips a bit in the version or mode exchange makes the two sides compute different prologues, and the handshake fails — tampering is detected, never silently accepted. For v10↔v10 alone this only converts one DoS into another (the preamble carries no secret, and each side decided its own send/receive role locally; the exchange only *verifies* they're opposite), but the binding is load-bearing for the future: when a later version changes anything crypto-relevant, an in-path attacker must not be able to rewrite the version exchange and silently pin both peers to v10 semantics. Prologue binding only closes that downgrade window if it exists from the *first* Noise version — which is why it ships in v10 rather than being retrofitted. --- ## 8. Strong recommendation: build it as a Noise pattern, don't free-hand it The Noise Protocol Framework specifies handshakes that are exactly "ephemeral X25519 + HKDF + AEAD, with an optional pre-shared key mixed in," and it pins down the transcript hashing, the key schedule, the nonce discipline, and the directional keys in a reviewed, widely- implemented way. The `NNpsk0` pattern (both sides ephemeral-only, a PSK folded in at the start) is essentially §4–§6 of this document, formalized. Feed `pw_material` in as the Noise PSK. Why this matters: Noise's security profile with a low-entropy PSK is **nearly the same** as our hand-rolled construction — not a PAKE, so the password is offline-crackable at PBKDF2 cost. The one difference: `NNpsk0` exposes that oracle to a *passive* observer via the first message's AEAD tag, where the §4–§6 construction keyed everything checkable under `dh` (§7) — a difference with no operational impact given the crack economics. In exchange we gain a specified, peer-reviewed recipe for the mechanics that are easy to get subtly wrong (transcript binding, nonce counters, key separation). ### Per-platform implementation plan Because Noise is a *spec*, conforming implementations of the same protocol name interoperate. Fix the protocol name up front — proposed: **`Noise_NNpsk0_25519_ChaChaPoly_SHA256`** (ChaChaPoly because it is Noise's most universally-tested cipher and is in CryptoKit as `ChaChaPoly`; AES-GCM is an equally valid pin if preferred). Then: - **Rust core → use `snow`.** Mature, ~1.4M downloads, tracks Noise spec rev 34, actively maintained (0.10.0 released mid-2026), supports the `NNpsk0` pattern. Pure-Rust crypto by default, no C deps. Caveat: no formal audit (but it is the de-facto Rust Noise library, widely deployed). This is the reference implementation the other two match against. - **Android → hand-rolled on JCA + a vendored X25519** (see §11 Phase 2 for the full rationale). The obvious libraries don't fit `minSdk 29` / Java 8 / modern `NNpsk0`: `noise-java` (rweather) implements the *deprecated* pre-2018 PSK scheme (incompatible with snow — verified against the cacophony vector), and `java-noise` (jchambers) needs Java 17 and JCA `X25519`, which Android's platform only added in **API 34**. So Android hand-rolls the symmetric state + handshake in Kotlin using JCA ChaCha20-Poly1305 (API 28+) and HMAC/SHA-256, plus the one pure-Java file `Curve25519.java` vendored from noise-java for X25519 (API 29 / Java 8 safe). Do **not** try to assemble it from Tink (§3). - **Apple → hand-implement `NNpsk0` on CryptoKit.** The "Apple standard crypto only" constraint forbids a third-party Noise library, and there is no well-established CryptoKit Noise library anyway. But Noise is small: the handshake needs only X25519 (`Curve25519.KeyAgreement`), HKDF (`HKDF`), the AEAD (`ChaChaPoly`), and SHA-256/HMAC — all present. You implement the Noise *symmetric state* (the `MixHash`/`MixKey`/`Split` key schedule and the PSK token) by hand, following the spec. This is the one hand-rolled side and the main source of interop risk, which is exactly what the §9 test vectors guard. The PSK fed into `NNpsk0` is 32 bytes: `psk = HKDF-or-PBKDF2(password) → 32 bytes`. Use PBKDF2 (§5) so the active-attacker offline crack is slowed; derive it identically on all three sides (same iteration count, same salt). Everything else — nonce counters, directional keys, transcript binding — is handled *by the Noise pattern itself*, which is the whole reason to use it rather than the §4–§6 hand-rolled version. Treat §4–§6 as the conceptual model; treat the Noise `NNpsk0` spec as the normative reference for the bytes. --- ## 9. Interop and testing (the part that actually eats the time) Because the handshake is standard Noise (§8), most byte-level details are fixed by the Noise spec and the protocol name. What each platform must still pin identically: - **Protocol name:** `Noise_NNpsk0_25519_ChaChaPoly_SHA256` (exact string). - **Prologue: the preamble transcript, canonically framed.** Each side records every byte it sends and receives during the plaintext preamble (the whole version exchange, including the 8-byte compatibility confirmation when versions differ, then the whole mode exchange) and builds ``` prologue = u64_be(len(T_i)) || T_i || u64_be(len(T_r)) || T_r ``` where `T_i` is every byte the **Noise initiator sent** and `T_r` every byte the **responder sent**. The initiator computes this as (my sent, my received), the responder as (my received, my sent); untampered, both get identical bytes. The length prefixes make the encoding unambiguous (no boundary-shifting between the two transcripts). Implemented as `build_prologue` / `buildPrologue` next to each Noise implementation, with the transcript captured by a recording wrapper at the stream boundary so no branch of the negotiation can leak a byte out of the transcript. Per the Noise spec the prologue enters only `h` (it gates the handshake MACs, not the derived keys) — that is exactly the desired property: mismatch ⇒ handshake abort. - **PSK derivation:** `PBKDF2-HMAC-SHA256(password_utf8, salt, iters) → 32 bytes`, with `salt = b"Flying Carpet v10 shared network PSK"` and `iters = 600000`. - **Discovery HMAC key:** `discovery_key = HMAC-SHA256(key = psk, data = b"Flying Carpet v10 discovery")` — derived from the stretched PSK (never from a fast hash of the password; §7, §10), with a fixed label for domain separation so the Noise PSK itself is never used outside the handshake. The PSK is derived once, when the password becomes known and *before discovery starts*, and reused for the handshake. Implemented as `derive_discovery_key` / `deriveDiscoveryKey` next to each PSK derivation. - **Record framing:** each Noise message is prefixed by its length as a **2-byte big-endian** integer (`u16`), tag appended (not prepended). 2 bytes, not 4, because Noise caps messages at 65535 bytes; the same framing is used for the two handshake messages and every transport record. - **Roles:** the Noise **initiator is the TCP client**, the **responder is the TCP server** — for *both* modes. Shared network: sender = client = initiator, receiver = server = responder. Hotspot: the guest that joined and connected = client = initiator, the host = server = responder. **Spec conformance is verified against the official vectors.** `core/src/noise.rs` test `official_noise_test_vector` drives `snow` with the canonical `Noise_NNpsk0_25519_ChaChaPoly_SHA256` test vector from [haskell-cryptography/cacophony](https://github.com/haskell-cryptography/cacophony) (`vectors/cacophony.txt`) — its prologue, PSK, and fixed ephemerals — and asserts the handshake message ciphertexts, the handshake hash (`f4d03dc3…be208eaf`), and the first transport record match byte-for-byte. This is what guarantees the Swift/Kotlin ports (which follow the same spec) interoperate; **each port should run the same cacophony vector against its own Noise implementation.** The app-specific KAT vectors below (PBKDF2-derived PSK, app-style prologue) are a *separate* determinism check for our exact parameters, not a spec check. **Cross-platform known-answer test vectors.** These are emitted and asserted by the Rust reference (`core/src/noise.rs`, tests `psk_known_answer`, `handshake_known_answer`, and `prologue_known_answer`); Swift and Kotlin must reproduce them exactly. `snow`'s `fixed_ephemeral_key_for_testing_only` fixes the ephemerals so the whole handshake is deterministic. - PSK for password `"flyingcarpet"`: `a3d8b7f17f2252e4c2847a365ab2f392beaa996b7e51dd6fa19ff1ad08938619` - Discovery HMAC key for that PSK: `45e49b632788b21069bf48720d6af230ecbd936b3cb16c898a8e1eac51944112` With fixed PSK = `2a`×32, initiator ephemeral private = `01`×32, responder ephemeral private = `02`×32, **empty prologue**: - Handshake msg 1 (initiator → responder), 48 bytes: `a4e09292b651c278b9772c569f5fa9bb13d906b46ab68c9df9dc2b4409f8a209a3e9c18456aba2185de800ffaca55b22` - Handshake msg 2 (responder → initiator), 48 bytes: `ce8d3ad1ccb633ec7b70c17814a5c76ecd029685050d344745ba05870e587d59d887595caf8a0b110dfab84e6b41eafc` - First transport record from the initiator, plaintext `"hello flying carpet"`: `124a00c03b4544f746828bbf9ae2d8d595a9ac1fea988f43f7206c3880180b954f9147` **Prologue-bound KAT** (same PSK/ephemerals, with the app-style preamble transcript `T_i` = `000000000000000a` `0000000000000001` (version 10, mode send) and `T_r` = `000000000000000a` `0000000000000000` (version 10, mode receive)): - `build_prologue(T_i, T_r)`, 48 bytes: `0000000000000010` `000000000000000a0000000000000001` `0000000000000010` `000000000000000a0000000000000000` - Handshake msg 1: `a4e09292b651c278b9772c569f5fa9bb13d906b46ab68c9df9dc2b4409f8a2093ae03dc8524f79ac9696d6c155df9a3c` - Handshake msg 2: `ce8d3ad1ccb633ec7b70c17814a5c76ecd029685050d344745ba05870e587d59d2668070263116ce557500fbe3fd3ba4` - First transport record (`"hello flying carpet"`): `124a00c03b4544f746828bbf9ae2d8d595a9ac1fea988f43f7206c3880180b954f9147` — **identical to the empty-prologue record by design**: the prologue enters only `h`, never the chaining key, so it changes the handshake message MACs (see msg 1/2 differing after the 32-byte ephemeral) but not the transport keys. Asserted anyway so a port that wrongly mixes the prologue into `ck` fails the KAT. (The transport-record vectors are raw Noise messages; on the wire each is preceded by its 2-byte length prefix `0023`.) These are the vectors that guarantee a macOS sender can talk to an Android receiver; each platform asserts them in its unit tests (Rust `core/src/noise.rs`, Kotlin `NoiseUnitTest`, Swift `NoiseTests`) since they catch label and endianness mismatches that otherwise surface as "handshake fails, no idea why." Each platform also asserts the *negative* cases: a tampered transport record, a tampered handshake message, a mismatched password, and a mismatched prologue must all fail. --- ## 10. Migration, scope, and locked decisions **Decisions locked** (from design review): - **Cipher: ChaCha20-Poly1305.** Noise protocol name **`Noise_NNpsk0_25519_ChaChaPoly_SHA256`**. (AES-GCM is also in CryptoKit and would be equally valid; ChaChaPoly chosen as Noise's most-tested cipher.) - **Noise over BOTH modes.** Hotspot mode also runs the Noise handshake now (password = the hotspot password, known to both sides), so there is one encryption path for all transfers. WPA2 still wraps the hotspot link, but the app no longer relies on it for confidentiality. - **No inner AES.** The old per-chunk AES-256-GCM (keyed by `SHA256(password)`) is **removed** — Noise is the sole encryption layer; chunks are raw bytes inside it. The `aes-gcm` dependency is gone. `SHA256(password)` survives only for the SSID and the discovery HMAC. - **Plaintext version/mode preamble, bound into the prologue.** Version confirmation and send/receive negotiation happen on the raw socket before the handshake (§6, §7), for clean version-mismatch reporting — and the full transcript is bound into the Noise prologue (§9), so the preamble is readable but not silently modifiable. The binding ships in v10 (the first Noise version) because that is the only point at which it can close the future downgrade window (§7). - **No v9 compatibility.** v10 is a clean break; a v9 peer is rejected with a clear message. - **Discovery HMAC keyed from the stretched PSK** (originally shipped as `HMAC(SHA256(password), announcement)`, fixed within v10 before release). The fast-hash key made a single captured announcement a cheap offline oracle — crackable within the password's live window by a resourced attacker, enabling a real MITM (§7). The announcement is now signed with `derive_discovery_key(psk)` (§9), so every oracle anywhere in the protocol costs 600k PBKDF2 iterations per guess. Wire-format unchanged (same 93 bytes); old and new builds simply never discover each other, which is fine within the unreleased v10. `SHA256(password)` now survives *only* for the hotspot SSID. **Discovery stays authenticated** — dropping the HMAC (no oracle from announcements) was considered and rejected: the handshake's message-1 oracle remains regardless, and the HMAC is what gives correct peer selection on a shared LAN (§7). Random per-device names as the selection mechanism, with the password as a separate secret, were likewise rejected: unauthenticated names are spoofable, so they'd need either the same password-derived MAC or a manual verify-the-name step. **Version bump — already implemented** (the only code landed ahead of the Noise work): - `MAJOR_VERSION` 9 → 10 in the Rust core (`core/src/lib.rs`), Android (`MainViewModel.kt`), and Apple (`Transfer.swift`, `VERSION`). - Compatibility floor raised to 10 (`utils::is_compatible`, the Kotlin `peerVersion >= 10` check, and Swift `isCompatible`), so a v9 peer is incompatible. - Version mismatch now produces a clear, user-facing message naming both versions and the download page, on all three platforms. **A versioning subtlety to respect.** The version number only protects the Noise break if the Noise wire format never ships under the *same* number as a non-Noise build: - The Noise change must land as part of **v10** — do not *release* a v10 build before Noise is in. If a v10 is ever released pre-Noise, the Noise wire change must bump to **v11**, because two builds both reporting "10" with different record-layer formats would not detect the mismatch; they'd fail cryptically instead. - There is no interop between v10's encrypted handshake and v9's cleartext protocol, and hotspot-vs-shared-network is chosen locally before any bytes flow, so v10 just runs its protocol and a v9 peer fails the version exchange with the message above. No downgrade path. **The logical send/receive protocol does not change** — only the transport under it (and the removal of the redundant per-chunk AES). The header-value bounds and filename sanitization already added remain, applied to the values read from the Noise-decrypted stream. --- ## 11. Implementation plan (phased) Order chosen so the cheapest, most-verifiable piece comes first and becomes the reference the others are tested against. ### Phase 0 — done Version bump + mismatch messaging (§10). The only pre-Noise code change. ### Phase 1 — Rust core reference implementation (`snow`) — **done** Landed in `core/src/noise.rs` and wired into `core/src/lib.rs`: 1. `snow` and `pbkdf2` added to `core/Cargo.toml` (and `aes-gcm` removed); protocol `Noise_NNpsk0_25519_ChaChaPoly_SHA256`. 2. `derive_psk()` = PBKDF2-HMAC-SHA256 with the fixed salt/iters constants (§9). 3. `start_transfer` runs the **plaintext version/mode preamble** on the raw TCP stream, then the Noise handshake, for **both** modes. Noise initiator = TCP client, responder = TCP server (§9). A wrong password fails the handshake with a clear message ("Could not establish a secure connection. Check that the password matches…"). 4. `EncryptedStream` implements tokio `AsyncRead + AsyncWrite`, transparently splitting the byte stream into ≤64 KiB Noise records with a 2-byte length prefix. `send_file` / `receive_file` / `confirm_version` / `confirm_mode` are generic over the stream; a `TransferStream` enum represents the `Plain`→`Encrypted` transition (Plain during the preamble, Encrypted after the handshake). 5. **Inner AES removed:** `send_file`/`receive_file` send/receive raw chunks; Noise is the sole encryption layer. `SHA256(password)` remains only for the SSID and discovery HMAC. 6. **Spec conformance verified against the official Noise vectors:** `official_noise_test_vector` drives `snow` with the canonical cacophony vector for the protocol and asserts the message ciphertexts, handshake hash, and transport record match byte-for-byte — this is the real cross-platform interop guarantee. Additional app-parameter KAT vectors (`psk_known_answer`, `handshake_known_answer`) are transcribed into §9 as a determinism check. 7. Verified: `end_to_end_encrypted_transfer` runs the real send/receive over an encrypted duplex with a 200 KB (multi-record) file; `wrong_password_fails_handshake`, `round_trip_small_and_large`, and `tampering_is_detected` all pass. 19 core tests green. Phases 2 and 3 must mirror the **full** design: plaintext version/mode preamble → Noise handshake (both modes) → raw chunks inside Noise (no per-chunk AES). The role rule is the same everywhere: TCP client = initiator, TCP server = responder. ### Phase 2 — Android (hand-rolled on JCA + vendored X25519) — **done** **Library dead-end (why hand-rolled).** Neither obvious Java Noise library fits this app (`minSdk 29`, Java 8, must speak modern `NNpsk0`): - **rweather/noise-java** implements the *deprecated pre-2018 PSK scheme* (`NoisePSK_` prefix, no `psk` token, `SymmetricState` has no `mixKeyAndHash`). Verified empirically: it can't parse `Noise_NNpsk0_…` ("Handshake pattern is not recognized"), and its `NoisePSK_NN_…` output diverges from the official cacophony `NNpsk0` vector after the ephemeral — cryptographically incompatible with the Rust/snow side. - **jchambers/java-noise** does modern `psk0` but is **Java 17** source and calls JCA `KeyAgreement.getInstance("X25519")`, which Android's platform (Conscrypt) only added in **Android 14 / API 34** — so it would force `minSdk 34` (dropping everything below Android 14) or bundling BouncyCastle as a provider. Not acceptable. So Android hand-rolls the `NNpsk0` symmetric state + handshake + transport in Kotlin (`Noise.kt`), the same shape as the Apple/CryptoKit side. Only the primitive that isn't in API-29 platform crypto is vendored: - **X25519**: the single pure-Java file `com/southernstorm/noise/crypto/Curve25519.java` (from rweather/noise-java, MIT; only its *crypto leaf*, not its handshake layer). Works on API 29 / Java 8. - **ChaCha20-Poly1305** (JCA, `Cipher "ChaCha20-Poly1305"`, Android 9 / API 28+), **HMAC-SHA256 / SHA-256** (JCA). PSK = the same PBKDF2 as Rust, hand-computed over the UTF-8 password so it can't hit `PBEKeySpec` char-encoding ambiguity. Verified: `NoiseUnitTest` reproduces the official cacophony vector **and** the §9 app KATs byte-for-byte, plus a multi-record stream round-trip and a wrong-password rejection — 5 tests, matching the Rust reference. Wiring mirrors Phase 1: plaintext version/mode preamble (already on the raw socket) → `noiseHandshake` for both modes (client = initiator, server = responder) → the socket streams are swapped for `NoiseInputStream`/`NoiseOutputStream`, and the per-chunk AES is removed from `Send.kt`/`Receive.kt` (raw chunks). Live Android↔Windows verification is still pending real devices, but the shared cacophony/app KATs are the interop guarantee. ### Phase 3 — Apple (hand-rolled on CryptoKit) — **done** In the Swift port (`Apple/shared/Noise.swift`), the same shape as the other two: 1. Hand-rolled `NNpsk0` symmetric state + handshake using **CryptoKit** — X25519 (`Curve25519.KeyAgreement`), ChaCha20-Poly1305 (`ChaChaPoly`), SHA-256, HMAC — and **CommonCrypto** PBKDF2 for the PSK (UTF-8 password, identical salt/iters). No third-party crypto, honoring the Apple-standard-only constraint. 2. `NoiseConnection` conforms to the existing `TCPConnectionProtocol` (`write` / `receiveNBytes`), so after the handshake the transfer code runs unchanged over it; the per-chunk AES is removed from `Send.swift`/`Receive.swift` (raw chunks). 3. Wiring (`Transfer.sendAndReceive`): plaintext version/mode preamble → `noiseHandshake` for both modes → replace `self.tcp` with the `NoiseConnection`. Role: shared-network sender = initiator / receiver = responder; hotspot = initiator (Apple always joins a hotspot, never hosts). 4. `NoiseTests` (macOS test target) reproduces the official cacophony vector **and** the §9 app KATs byte-for-byte, plus a multi-record round-trip and a wrong-password rejection — the same vectors Rust and Android assert. Compiles/runs on the developer's Mac (not buildable on the Windows dev host); `shared/Noise.swift` must be added to the iOS and macOS app targets in Xcode (created outside the IDE). ### Post-review hardening — **done** (all three platforms) From the code review of Phases 1–3: 1. **Preamble → prologue binding** (§6, §7, §9): every preamble byte, sent and received, is recorded by a stream-boundary wrapper (`RecordingStream` / `RecordingInputStream`+`RecordingOutputStream` / `RecordingTCPConnection`) and bound into the handshake via `build_prologue`/`buildPrologue`. New cross-platform `prologue_known_answer` KAT plus a prologue-mismatch negative test on each platform. The handshake-failure message now mentions tampering as well as password mismatch. **Wire-breaking within the unreleased v10** — all three platforms landed together. 2. **Real tamper tests**: each platform now asserts that a bit-flipped transport record and a bit-flipped handshake message fail authentication (the old Rust `tampering_is_detected` only exercised the happy path). 3. **Rust: hotspot stored in state before the preamble** (`start_transfer`), so `clean_up_transfer` tears the Windows hotspot down even when the version check, mode check, or handshake fails (previously those paths left it running until app exit). ### Phase 4 — cross-matrix + cleanup (remaining) All three platforms now implement the same modern `NNpsk0` and pass the shared cacophony + app KATs, so they interoperate by construction. **Confirmed on real hardware so far: Windows↔Android over both hotspot and shared network — before the prologue binding; the first post-binding live transfer re-confirms it.** Still to do: the rest of the live matrix (add macOS / iOS × sender / receiver, and Linux), a per-file size larger than one record end-to-end, and the wrong-password / version-mismatch user-facing paths. The old cleartext per-chunk AES is already removed on all three platforms. The PSK-derived discovery key (§9) is implemented in Rust and Android (with the shared KAT); the Apple repo must mirror it — `deriveDiscoveryKey` via CryptoKit `HMAC`, PSK derived once at password time (off the main thread) and fed to both discovery and the handshake — before any v10 release, since the key change is a silent discovery-compat break. ### Open items — resolved - **PBKDF2 salt** → **fixed domain string** `b"Flying Carpet v10 shared network PSK"`. The Noise handshake hash already binds the ephemeral transcript, so the salt's only job is domain separation and a fixed value is simplest to keep byte-identical across languages. - **`snow` audit posture** → **accepted.** `snow` has no formal audit (it says so), but it is the de-facto Rust Noise library and widely deployed; the fallback if that ever becomes unacceptable is a hand-rolled Rust implementation matched to the Apple one. --- ## 12. Summary for the implementer 1. Build the handshake as Noise **`Noise_NNpsk0_25519_ChaChaPoly_SHA256`** (§8), not the §4–§6 hand-rolled mixing — treat §4–§6 as the conceptual model and the Noise spec as the normative reference for the bytes. 2. The PSK is `PBKDF2(password)` → 32 bytes (§5), derived identically on all three platforms. 3. Wrap the socket so the existing send/receive logic runs unchanged over the Noise transport (§6); the receiver is the responder, the sender the initiator. 4. Write the cross-language known-answer vector **first** (§9); it is what makes a macOS sender talk to an Android receiver, and the guard for the hand-rolled Apple side. 5. Follow the phased order (§11): Rust `snow` reference → Android `noise-java` → Apple CryptoKit hand-roll → full matrix. 6. Version is already bumped to a clean-break v10 with mismatch messaging (§10); ship Noise *within* v10 (or bump to v11 if v10 releases first). 7. Know exactly what you're shipping (§7): eavesdroppers read nothing, but both passive and active attackers hold a PBKDF2-hardened offline password oracle (discovery announcement and first handshake message). It is neutralized by crack cost ≫ single-use password lifetime plus forward secrecy, so this is effectively as strong as a PAKE would be *for this design* — provided passwords stay single-use and CSPRNG-generated. --- ### V10 Release Plan # Flying Carpet v10 — Release Plan Companion to `docs/v10-release-test-plan.md` (what to test) and `docs/post-v10-maintenance.md` (what's deliberately deferred). This doc holds the **draft release notes** and the **ship checklist**. Branch: `shared-network`, both repos. 74 commits, ~11,900 insertions over `main`. Versions already bumped: Rust core `10.0.0`, Tauri app `10.0.0`, Android `versionName 10.0.0` / `versionCode 22`. Apple repo versions still need checking (see checklist). --- # Draft release notes ## Flying Carpet 10.0 **Flying Carpet 10 adds Shared Network mode and rebuilds the encryption on the Noise Protocol Framework.** > ⚠️ **Version 10 is a breaking change.** v10 devices cannot transfer with v9 or earlier — > you'll get a clear version-mismatch message instead of a hang. **Update every device you > transfer between.** ### Shared Network mode Until now Flying Carpet always created its own ad-hoc WiFi hotspot. You can now transfer over a WiFi **or wired** network that both devices are already on — useful at home or in an office, and much faster to start when there's a network handy. - Discovery is authenticated: devices announce themselves on port 3290 with an HMAC keyed from the transfer password, so you only ever see peers that hold your password. - The receiver generates a single-use password and displays it (with a QR code); the sender types or scans it. No Bluetooth involved, and the two devices' operating systems don't matter — no need to select the peer's OS. - **This is what finally makes Apple-to-Apple transfers work.** iPhone↔Mac transfers were impossible in hotspot mode because neither device can host for the other. Join both to the same network — including a hotspot you made manually beforehand — and it just works. This also unblocks Android↔iOS, which had been broken by the iOS WiFi path (#131). - **Wired connections are supported** (#124). A desktop on ethernet can transfer with a phone on WiFi, as long as they're on the same network — and a machine with no WiFi card at all can now use Flying Carpet for the first time (#93). - Interface picker: a labeled dropdown showing each interface's IP, with unusable ones hidden, for machines with several NICs. - No hotspot means no ad-hoc WiFi connection to drop partway through a large transfer (#130). ### Rebuilt encryption Every transfer, in **both** modes, now runs a `Noise_NNpsk0_25519_ChaChaPoly_SHA256` handshake (X25519 + ChaCha20-Poly1305 + SHA-256), with the pre-shared key derived from your password by PBKDF2-HMAC-SHA256 at 600,000 iterations. - **Forward secrecy.** Recording a transfer and cracking the password later no longer reveals the files — each transfer has fresh ephemeral keys. - **Metadata is encrypted too.** Filenames, file sizes, and the file count used to travel in the clear; they're now inside the encrypted channel along with the contents. - **Tamper-evident.** The plaintext version/mode preamble is bound into the Noise handshake as the prologue, so modifying a single byte of it fails the handshake rather than going unnoticed. This also closes the door on future downgrade attacks. - Replaces the previous SHA-256/AES-256-GCM per-chunk scheme; Noise is now the sole cipher. - Discovery announcements are signed with a key derived from the *stretched* PSK, so no fast hash of your password ever goes on the air — every offline guess costs a full 600k-iteration PBKDF2. - Generated passwords are now **10 characters** instead of 8 (~2⁵⁸ instead of ~2⁴⁷), foreclosing a precomputed-table attack over the whole password space. - Full design writeup: `docs/shared-network-crypto.md`. All three implementations (Rust, Kotlin, Swift) are held together by shared known-answer test vectors. Hotspot mode keeps WPA2 underneath, so it's now encrypted twice over. ### macOS ↔ Linux Bluetooth now works Hotspot transfers between a Mac and a Linux machine previously required manually pairing the two in System Settings first, and often failed afterward with "Peer removed pairing information". Root cause: macOS advertises with a *public* address and dual-mode flags, and BlueZ's bearer tiebreak prefers classic BR/EDR on a tie — so Linux was connecting over classic Bluetooth, which macOS serves no GATT over. Linux now bonds over an LE socket first, which pins the bearer to LE permanently, and keeps the bond for macOS peers so their rotating address stays resolvable. Pairing also surfaces the 6-digit code in the app for confirmation (real MITM protection), and declining now aborts the transfer cleanly instead of hanging. ### Other fixes and improvements **Desktop (Windows/Linux)** - File selection now comes first everywhere: hotspot joiners pick files and *then* get prompted for the password, instead of needing the host's password before the file dialog would open. - The UI recovers if a transfer task panics or is aborted, instead of freezing (#118). - **The Windows firewall UAC prompt no longer appears on every transfer** (#129). The check for an existing rule passed the rule name to `netsh` with literal quotes around it, so it never matched and the rule was re-added every time. Both rules are also added under a single elevated command now, so the one-time prompt is one prompt, not two. - Windows Bluetooth: recovers from GATT enumeration failures (`0x8000FFFF`) against already-paired iPhones — retries, then re-pairs once within the same transfer, rather than failing the run. Advertising is now explicitly stopped after the credential exchange. - Linux: stale `flyingCarpet_*` NetworkManager connections are pruned at startup and the hotspot is torn down on window close (#51). - Linux Bluetooth: cached BlueZ entries are purged and discovery is LE-only, so a previously-paired device (an audio device, say) can no longer be picked up as the transfer peer and then fail with "Could not find service UUID on scanned device" (#106). - The WiFi Direct failure message now points at shared network mode (#115). - Edge/WebView2 no longer offers previously typed transfer passwords in an autofill dropdown. **Android** - Fixed an intermittent iOS→Android hotspot failure ("Empty key" crash or a silent stall) caused by a BLE credential-exchange race across two GATT connections. - Fixed a stuck hotspot flag that made repeat transfers hang with "hotspot already running"; GATT client and server are now properly closed between transfers. - Successful shared-network receives no longer end with a spurious "Discovery error: StandaloneCoroutine was cancelled". - Output box auto-scrolls; the transfer log survives screen rotation (it previously could overflow the Binder transaction limit and vanish); every line is mirrored to logcat under the tag `FlyingCarpet`, so a full transfer log can finally be pulled with `adb logcat -s FlyingCarpet` for bug reports (#130). - Bluetooth pairing that's declined or fails now aborts the transfer instead of waiting forever; failed GATT reads no longer propagate empty values as the peer's OS or password. - Missing Bluetooth *permissions* are distinguished from missing *hardware*, and the switch stays usable so you can re-grant (#101). - Password-prompt dialog buttons are readable in dark mode. **Security hardening** - Received filenames are sanitized against path traversal on all five platforms before touching the filesystem. - Header values from the peer (file count, filename length, chunk size) are bounds-checked before they're used to size allocations. - Fixed a Windows WiFi-profile XML injection via the SSID/password fields. - Dependency updates closing 6 Dependabot advisories, including **CVE-2026-42184** in Tauri (`is_local_url()` misclassifying remote URLs as trusted local origins on Windows/Android, allowing a remote page to invoke local-only IPC commands). Also CVE-2026-25727 (`time`), CVE-2026-25541 (`bytes`), and fixes in `serde_with` and `rand`. **Send Folder is consistent everywhere** Sending a folder now recreates that folder inside the destination the receiving device chose, with the contents inside, on all five platforms. Previously only macOS did this — everywhere else the folder's contents were dumped loose into the destination. Sending individual files is unchanged: they still arrive flat. This also fixes sending a folder that contains sub-folders from Android to Windows or Linux, which used to fail outright, and two cases where the desktop app aborted a transfer: selecting a folder whose top level holds only sub-folders, and dropping two folders (or files from two different directories) at once. The in-app instructions were also wrong about how to send a folder — they told you to drag it onto the window, which Android and macOS have no handler for, and which is not something a screen reader user can do (#122). Each platform's help text now describes the control it actually has, and says what arrives on the other end. --- # GitHub issues Nine open issues are resolved or materially addressed by this branch, plus three closed feature requests that v10 actually delivers. Only four (#51, #101, #115, #118) are cited in commit messages — the rest were matched by reading the issue against the code, so the confidence column matters. | # | Title | Fixed by | Confidence | |---|---|---|---| | [#101](https://github.com/spieglt/FlyingCarpet/issues/101) | Bluetooth Button is Greyed Out | `a85d2c4` | **Fixed** | | [#118](https://github.com/spieglt/FlyingCarpet/issues/118) | Windows receiver freezes | `a85d2c4` | **Fixed** | | [#124](https://github.com/spieglt/FlyingCarpet/issues/124) | Support wired connection on one device | `d4883ea` | **Fixed** | | [#129](https://github.com/spieglt/FlyingCarpet/issues/129) | Firewall UAC prompt every time | `25050cb`, `04da20b` | **Fixed** | | [#131](https://github.com/spieglt/FlyingCarpet/issues/131) | Android to iOS not working | shared network mode | **Fixed** (you already told the thread this was the plan) | | [#106](https://github.com/spieglt/FlyingCarpet/issues/106) | Bluer: GATT services not resolved | `967ed6b` | Likely — ask reporter to retest | | [#51](https://github.com/spieglt/FlyingCarpet/issues/51) | Does not clean up after itself (Debian) | `a85d2c4` | **Partial** — no uninstall purge | | [#115](https://github.com/spieglt/FlyingCarpet/issues/115) | Failed to start WiFi Direct AP | `a85d2c4` + shared network | **Worked around**, not fixed | | [#130](https://github.com/spieglt/FlyingCarpet/issues/130) | WiFi drops midway on large transfers | shared network, `478ee4d` | **Partial** + diagnosis unblocked | Closed feature requests v10 delivers — worth a courtesy follow-up, since the requesters never got an answer: [#93](https://github.com/spieglt/FlyingCarpet/issues/93) (detect same network, skip the hotspot), [#61](https://github.com/spieglt/FlyingCarpet/issues/61) (static network option), [#122](https://github.com/spieglt/FlyingCarpet/issues/122) (folder-select button for screen reader users). ## Notes on the non-obvious matches **#129 was a real bug, not a design choice.** `check_for_firewall_rule` built its query as `format!("name=\"{}\"", file_name)` and passed it to `process::Command` as one argument, so netsh searched for a rule whose name *literally contained quote characters*. It never matched, the app concluded the rule was missing, and re-added it — raising UAC on every single transfer. Fixed in `25050cb`; `04da20b` then collapsed the two `netsh` invocations into one elevated `cmd.exe` so the first run costs one prompt rather than two. **#106's log identifies its own cause.** The device it connected to advertises service UUIDs `1101`/`110b`/`110c`/`110d`/`110e`/`111e` — Serial Port, Audio Sink, AV Remote Control, Handsfree. That's a classic Bluetooth audio device, not the reporter's iPhone. `967ed6b` records that bluer's `discover_devices()` pre-seeds results with every device BlueZ already knows, ahead of the discovery filter, so a previously-paired peripheral could be returned as the peer. The same commit purges those cached entries, scans LE-only, and retries/rescans on enumeration failure. **#115 is not fixed and shouldn't be closed as such.** "Failed to start WiFi Direct AP" is a card/driver limitation. v10 only offers a route that avoids it. ## Adjacent open issues — do NOT close with this release - [#109](https://github.com/spieglt/FlyingCarpet/issues/109) Resume after lost connection — shared network mode makes drops rarer, but there's still no resume. - [#58](https://github.com/spieglt/FlyingCarpet/issues/58) Log clearing / newest-first ordering / VPN warning — Android got autoscroll and logcat mirroring, but none of the three asks. - [#133](https://github.com/spieglt/FlyingCarpet/issues/133) Tighter UI + user-chosen password — the UI half is untouched. **The password half is a deliberate no:** single-use CSPRNG passwords are what make the "an offline crack is worthless" argument hold (`docs/shared-network-crypto.md` §7). Worth answering kindly and explaining why, rather than leaving it open indefinitely. - [#134](https://github.com/spieglt/FlyingCarpet/issues/134) send/receive text, [#81](https://github.com/spieglt/FlyingCarpet/issues/81) share sheet, [#75](https://github.com/spieglt/FlyingCarpet/issues/75) adaptive icon, [#62](https://github.com/spieglt/FlyingCarpet/issues/62) Android 5GHz hotspot — untouched. --- # Draft issue responses Copy-paste ready. Post **after** the release is live so the download links work. Where a response asks the reporter a question, that's deliberate — several of these are worth confirming before closing. ### #118 — Windows receiver freezes > Should be fixed in v10. > > The transfer task could panic or be aborted without ever re-enabling the UI, which left the > window looking frozen — the app was still running, but every control stayed disabled. v10 > emits the re-enable from a drop guard so the UI recovers even when the transfer task dies > unexpectedly, and the setup paths that used to panic now print an error instead. > > One thing that would help me confirm: when it freezes, is the window unresponsive to clicks > entirely, or are the controls just greyed out and unclickable? Those are two different > problems and it's the second one I've fixed. If it's the first, and especially in the "kept > open for a long time" case rather than "after completing a transfer", I'd like to keep this > open. ### #101 — Bluetooth button greyed out > Fixed in v10. > > The app was treating "Bluetooth permissions haven't been granted yet" and "this device has > no Bluetooth hardware" as the same state, and showed "Device can't use Bluetooth" for both. > That's also why the switch came to life after you tapped "Select file" — that's the point > where the permission request actually fired. > > v10 tells the two apart, keeps the switch tappable so it can re-request the permission, and > re-checks when the app returns to the foreground, so granting it in Settings now takes > effect without restarting the app. ### #129 — Firewall UAC prompt every time > Fixed in v10 — and it was a bug, not a design decision. The rule was only ever meant to be > added once. > > The check for "do I already have a firewall rule?" passed the rule name to `netsh` with > literal quote characters around it, so `netsh` went looking for a rule whose name actually > contained quotes. It never matched, so the app concluded the rule was missing and re-added > it — every transfer, hence the prompt every transfer. v10 passes the name unquoted and finds > the existing rule. > > One caveat: v10 adds a second (UDP) rule for shared-network discovery, so the first run > after upgrading will prompt once to add it. That's a single prompt covering both rules now > rather than one each. After that it should be silent. ### #124 — Wired connection on one device > This works in v10. > > Shared Network mode transfers over a network both devices are already on, and it supports > wired interfaces explicitly — so your exact setup (Windows on ethernet, Android on WiFi, > same network) is supported. The interface picker lists wired adapters alongside wireless > ones, labelled with their IP. > > The "not connected to WiFi" error you hit came from hotspot mode needing a WiFi card to host > the hotspot with. In shared network mode nothing is hosted, so that requirement is gone. ### #131 — Android to iOS not working > v10 is the fix for this, and it's close to release. > > As I mentioned above, the iOS WiFi side was broken and the fix is the shared network mode > I've been building. Join both devices to the same WiFi network and start the transfer: the > receiving device shows a password (with a QR code) that you enter on the sender. No hotspot, > no Bluetooth, and the two devices' operating systems no longer need to be selected. > > @B5-SA this should cover Linux Mint ↔ iOS too. v10 separately fixes Linux failing to > enumerate GATT services over Bluetooth, which is likely what you hit on the pairing side — > see #106. ### #106 — Bluer: GATT services have not been resolved > I think v10 fixes this, and your log is what convinced me — thank you for pasting the whole > thing. > > Look at the device it picked up: service UUIDs `1101`, `110b`, `110c`, `110d`, `110e`, > `111e` — Serial Port, Audio Sink, A/V Remote Control, Handsfree. That's a classic Bluetooth > audio device, not your iPhone. bluer's `discover_devices()` pre-seeds its results with every > device BlueZ already knows about, *before* the discovery filter applies, so a > previously-paired peripheral could come back as the "peer" — and naturally it has no Flying > Carpet GATT service on it. > > v10 scans LE-only, purges unpaired cached BlueZ entries carrying our service UUID before > discovery starts, retries service enumeration, and removes + rescans once if it still fails. > Would you be willing to retest once v10 is out? ### #51 — Does not clean up after itself (Debian) > Partly addressed in v10, and I'd rather be straight about which part. > > **Fixed:** Flying Carpet no longer accumulates `flyingCarpet_*` connections as you use it. > v10 prunes stale ones at startup and tears the hotspot down when the window closes, so a > crashed or force-quit transfer doesn't leave one behind indefinitely. > > **Not fixed:** there's still no uninstall-time purge. The AppImage has no uninstall hook to > attach one to, and the `.deb` would need a `postrm` script. > > Before I add a `--purge` flag people would have to know exists: would the startup pruning > have been enough for your case, or were the leftovers specifically a problem *after* you'd > uninstalled? If it's the latter I'll do the `postrm` script for the `.deb` at least. ### #115 — Failed to start WiFi Direct AP > v10 gives you a way around this, though I want to be clear it isn't a fix for the underlying > problem. > > "Failed to start WiFi Direct AP" means the WiFi card or its driver won't host a software > access point. That's a hardware/driver limitation Flying Carpet can't work around, which is > why the message says what it says. > > What v10 adds is Shared Network mode: if both devices are already on the same WiFi or wired > network, no hotspot is created and the WiFi Direct path is never used. The error message now > points there too. Worth a try when v10 lands. ### #130 — Drops the WiFi midway during large transfers > Two things in v10 for this. > > The direct one is Shared Network mode: if both devices are already on the same network, no > hotspot is created, so there's no ad-hoc WiFi connection to drop midway. For multi-GB > transfers from Android that's the path I'd recommend. > > The other is the thing you actually asked for — "I can't find any way to log/share error out > of the box as an end user." Fair, and fixed. Every line the Android app prints now also goes > to logcat under the tag `FlyingCarpet`, so you can pull a full transfer log with > `adb logcat -s FlyingCarpet`. The log also survives screen rotation now; it could previously > be wiped mid-transfer. > > v10 also fixes an intermittent Bluetooth credential-exchange race on Android, which may well > be the "hit and miss" half of what you were seeing. ### #93 (closed) — Detect same network, avoid the hotspot > Following up a year and a half later: this is shipping in v10, more or less exactly as you > described it. If both devices are on the same network there's no hotspot at all — and yes, > that makes transfers to desktops without WiFi cards work, since wired interfaces are > supported. ### #61 (closed) — Static network option > Following up: v10's shared network mode should remove the need for this. Instead of a fixed > SSID and password per peer, if both devices are already on a network there's no hotspot to > name — the receiving device shows a one-time password (with a QR code) and that's the only > thing to type. The long SSID + password entry for Mac ↔ Android goes away entirely. ### #122 (closed) — Folder-select button for screen reader users > Following up, because v10 improves this and you deserved a better answer at the time. > > There's a "Send Folder" checkbox now, so sending a folder no longer requires drag-and-drop. > More to the point, the instructions text still said "To send a folder, drag it onto the > window" — which was exactly the wrong thing to tell a screen reader user, and it stayed that > way far too long. It now describes the checkbox instead. > > v10 also makes a sent folder arrive *as a folder* on the receiving device on every platform. > Previously most platforms scattered the contents loose into the destination folder, which I > imagine was its own kind of unpleasant to sort out without sight. --- # Ship checklist ## Blockers — must resolve before tagging - [x] ~~Unify Send Folder across all five platforms~~ — done 2026-07-24; all five now recreate the folder on the receiving end. Code landed in both repos; see `docs/send-folder-behavior.md`. **Still needs the Tier 6 hardware rows run.** - [x] ~~Bump Apple passwords to 10 characters~~ — already done: `generatePassword()` returns 10 (`shared/Transfer.swift`), iOS prompt requires 10, macOS uses `minLength: 10` for shared network and `8` for hotspot join (correct — an Android host's WPA2 passphrase isn't ours to size). - [x] **Run the Tier 6 Send Folder rows on hardware** — core rows done 2026-07-24/25 (Windows/Linux/Android send; all five receive). The remaining edge rows (iOS and macOS send, flat multi-file, multi-directory drops, duplicate-name, Unicode sub-folder) are still open in the test plan and tracked under "Finish the test plan" below. - [x] **Build the Apple repo.** `shared/Transfer.swift` and the iOS storyboard changed and have not been compiled here (no Mac). - [x] **One more Mac test run (⌘U on the macOS target).** The Swift discovery-announcement KAT (`DiscoveryTests`, Apple commit `8d73710`) was written on Windows 2026-07-25 and has never been compiled. Tier 0's "Apple unit tests pass" checkmark predates it. - [x] ~~Confirm both repos report the same protocol version constant~~ — done; re-verified 2026-07-25 in the test plan's Tier 0 (Rust/Kotlin/Swift all write the same 8-byte big-endian `10`). - [ ] Finish `docs/v10-release-test-plan.md` — Tier 2 is green; Tiers 3–6 and the release gate remain (Tier 3 lacks only wrong-password-over-hotspot). - [x] ~~Re-run desktop smoke tests after the Tauri 2.9.5 → 2.11.1 / wry 0.53 → 0.55 bump~~ — covered: the bump landed 2026-07-23 and the Tier 0 desktop builds plus every Tier 2 interop row (Windows and Linux in both roles, both modes) ran on hardware 07-24/25, after it. ## Versions and metadata - [x] ~~Apple repo: bump iOS and macOS `CFBundleShortVersionString` to 10.0~~ — both app targets are at `MARKETING_VERSION = 10.0.0` (verified 2026-07-25 and re-checked). `CURRENT_PROJECT_VERSION = 1` is fine for a new marketing version *unless* a 10.0.0 build was already uploaded to App Store Connect — bump it only in that case. - [ ] Android: confirm `versionCode 22` is greater than what's live on Play and F-Droid. - [x] ~~Update the README's sideload APK filename~~ — now version-agnostic ("the APK is available on the releases page"), so it can't go stale again. - [ ] Decide whether `tauri.conf.json`'s stale `icons/icon.icns` entry stays (per CLAUDE.md it is intentionally left alone — confirm, don't silently "fix"). ## Screenshots and store assets Every screenshot in `screenshots/` predates the mode switch, the interface dropdown, and the password-box removal. - [ ] `screenshots/windows.png` — retake showing the Shared Network / Hotspot mode switch - [ ] `screenshots/linux.png` — retake; show the interface dropdown with IP labels - [ ] `screenshots/mac.png` — retake with the current segmented-control layout - [ ] `screenshots/android.png` — retake (mode switch + Send Folder checkbox) - [ ] `screenshots/ios.png` — retake, ideally showing shared network mode - [ ] Take a **receiver-side password + QR** screenshot — it's the single most explanatory new screen and there's currently no shot of it anywhere - [ ] Google Play: updated phone screenshots + feature graphic - [ ] App Store: updated screenshots for every required device size (iPhone 6.9"/6.5", iPad if listed) - [ ] F-Droid metadata: screenshots and changelog entry - [ ] Re-record the demo video (currently https://youtu.be/52Xkrx2BXrg, hotspot-only). The minimum is done: the README now notes it predates v10 — so this no longer blocks release, but the note is a stopgap. ## Documentation - [x] ~~Fix the stale "drag it onto the window" folder instructions~~ — done; each platform's help text now describes what it actually supports and states that a sent folder is recreated on the receiving end. - [x] ~~README: add a Shared Network mode section with the receiver-shows-password flow~~ — done 2026-07-25 (Use section: Shared Network flow, Hotspot flow, folder behavior). - [x] ~~README: mention that a sent folder is recreated inside the receiver's destination~~ - [x] ~~README: state plainly that v10 can't talk to v9 and both devices must be updated~~ — breaking-change warning added under the headline. - [x] ~~Verify README crypto Q&A matches the shipped design~~ — verified 2026-07-25 against code: NNpsk0 suite, 600k PBKDF2, prologue binding, discovery key from the stretched PSK all match; `get_key_and_ssid`'s callers all discard the key half, so SHA-256 of the password really is SSID-only. - [x] ~~Check `ARCHITECTURE.md` is accurate for the final state of the branch~~ — reviewed 2026-07-25; accurate (role axes, peer-OS-selection gating, shared-network section, BLE-is-hotspot-only rationale all match the code). One wording slip fixed ("the v10 inner per-chunk AES" → the AES v10 *removed*). ## Build and packaging - [ ] Windows: `.msi` installer + standalone `FlyingCarpet.exe`, both signed - [ ] Linux: `.AppImage` + `.deb` - [ ] macOS: `.dmg`, signed and **notarized**; verify Gatekeeper on a clean machine - [ ] Homebrew cask update (`brew install flying-carpet`) - [ ] Android: signed release APK for sideloading + AAB for Play - [ ] iOS: App Store build submitted (start early — review latency gates the announcement) - [ ] Verify each artifact launches on a machine that has never had Flying Carpet installed ## Release mechanics - [ ] Merge `shared-network` → `main` in **both** repos, close together (they must stay wire-compatible) - [ ] Tag `v10.0.0` in both repos - [ ] Publish the GitHub release with these notes and all binaries (including the Apple ones, which ship from this repo) - [ ] Google Play rollout — consider staged (20% → 100%) - [ ] F-Droid: confirm the build recipe picks up the new tag - [ ] Post the drafted issue responses (see "GitHub issues" above) once the release is live and the download links work - [ ] Close #101, #118, #124, #129, #131 outright - [ ] Leave #51, #106, #115, #130 **open** pending reporter confirmation — each response asks a question, and #115 in particular is worked around rather than fixed - [ ] Comment on closed #93, #61, #122; leave them closed ## Post-release - [ ] Watch for v9↔v10 confusion reports; the mismatch message is the first line of defense - [ ] Start on `docs/post-v10-maintenance.md` — the `windows` crate 0.44 pin first - [ ] Re-check the `glib` 0.18.5 advisory at the next Tauri upgrade (Linux/GTK only) --- ### V10 Release Test Plan # Flying Carpet v10 — Release Test Plan Branch: `shared-network` (both repos: `FlyingCarpet`, `FlyingCarpetApple`). Goal: validate everything v10 introduces before release, efficiently, by gating on builds/KATs first and exploiting the symmetry of the wire protocol. ## What v10 introduces (scope to validate) - **Shared Network mode** — transfer over an existing WiFi/wired network instead of a hotspot. HMAC-authenticated discovery, manual password (receiver generates + shows QR, sender types/scans), no Bluetooth, TCP receiver = server on port 3290. - **Noise encryption over both modes** — `Noise_NNpsk0_25519_ChaChaPoly_SHA256`, PBKDF2 PSK, plaintext version/mode preamble bound into the Noise prologue. Replaces the old inner AES-GCM. Now encrypts *all* metadata (filenames, sizes, count), not just contents. - **Protocol bumped to v10 (breaking)** — a v10 device talking to v9 must show a clear version-mismatch message, not hang or misbehave. - **Discovery key derived from the PBKDF2-stretched PSK** (no fast hash of the password on the air). Generated passwords bumped 8 → 10 chars. - **Interface chooser** — dropdown labeled with IP, hides unusable interfaces, supports wired interfaces (shared network). - **macOS ↔ Linux Bluetooth fix** — Linux LE-bonds before connecting; pairing agent confirms the 6-digit code in-UI. macOS RPA / "CBError 14" bond-keeping. - **Bug-fix bundle** — UI-freeze drop guard, stale hotspot cleanup (#51), Android permissions-vs-hardware (#101), Windows profile XML injection fix, receive-path hardening. - **Late lifecycle fixes (this branch tail)** — Android hotspot flag + GATT client/server close; Apple BLE connection/service teardown; coroutine-cancellation no longer reported as an error. See "Lifecycle status" below. - **Send Folder is consistent across all five platforms** — a sent folder is now recreated inside the receiver's chosen destination everywhere, instead of only on macOS. Fixes an Android→desktop failure and two desktop abort cases along the way. Retest rows in Tier 6; details in `docs/send-folder-behavior.md`. ## Platforms & environment Five platforms: **Windows** (10+), **Linux** (AppImage + .deb), **macOS**, **iOS**, **Android** (API 29+). Apple devices cannot host a hotspot, so **Apple↔Apple must use shared network**. Ideally have two Android and/or two desktops available for same-OS runs. --- ## Tier 0 — Build & static gates (do first) Cheapest, and catches the biggest current risk: **the Apple lifecycle changes have never been compiled.** - [x] Android: `./gradlew assembleDebug` (and `lintDebug` — only the pre-existing `MainActivity:94` MissingPermission finding is expected) - [x] Rust core: `cargo build` for Windows and Linux; `cargo clippy` clean — "clean" means no errors. `cargo clippy --workspace --all-targets` on Windows emits 23 style lints in `core` and 4 in the app (`is_none()` over `== None`, `Ok(…)?`, `expect` with a format arg, arg counts); all pre-existing, none in v10 code paths. - [x] Tauri desktop app builds on Windows and Linux (`cargo tauri build`) - [x] **iOS builds in Xcode** (FlyingCarpetApple) - [x] **macOS builds in Xcode** (FlyingCarpetApple) - [x] Android unit tests pass: `NoiseUnitTest`, `DiscoveryUnitTest` - [x] Rust unit tests pass: `cargo test` (incl. `official_noise_test_vector`, `wrong_password_fails_handshake`, `tampering_is_detected`, `round_trip_small_and_large`, and the `utils::selection_tests` folder-naming cases) - [x] Apple unit tests pass (Noise KATs, incl. cacophony vector + app KATs) - [x] Both repos report the **same protocol version constant** (wire-compat check). Re-verified 2026-07-25: Rust `MAJOR_VERSION: u64 = 10` (`core/src/lib.rs:83`), Kotlin `MAJOR_VERSION: Long = 10` (`MainViewModel.kt:56`), Swift `VERSION: UInt8 = 10` (`shared/Transfer.swift:17`). The Swift type is narrower but goes out as `Data([0,0,0,0,0,0,0,VERSION])`, so all three write the same 8 big-endian bytes. --- ## Tier 1 — Smoke (prove the pipeline, 2 transfers) - [x] Shared network: iOS → Android (small file) - [x] Hotspot: iOS → Android (small file) --- ## Tier 2 — Interop matrix The wire protocol (discovery → preamble → Noise → files) is symmetric, so a directed cycle covers each platform as **both** sender and receiver without testing every pair. W=Windows, L=Linux, M=macOS, I=iOS, A=Android. ### Shared network — core cycle (each platform sends once, receives once) - [x] W → L - [x] L → M - [x] M → I - [x] I → A - [x] A → W - [x] I → M (Apple ↔ Apple, the case that *requires* shared network) - [x] M → M or A → A (same-OS sanity, if a second device is available) ### Hotspot — each BLE stack pairs with each other, each non-Apple platform hosts Apple always guests; the peer hosts. Confirm the 6-digit pairing code and the transfer. - [x] A → I and I → A (Android hosts; Android ↔ Apple BLE) - [x] A → M and M → A (Android hosts; Android ↔ macOS BLE) - [x] W → I and I → W (Windows hosts; Windows ↔ Apple BLE) - [x] L → M and M → L (Linux hosts; **macOS ↔ Linux — the v10 BLE fix**) - [x] W → L and L → W (desktop ↔ desktop hosting) - [x] W → A and A → W (Windows ↔ Android BLE) - [x] L → A and A → L (Linux ↔ Android BLE) > If any single hop fails, expand *only that pair* to localize it. Passing the cycle + > the hotspot pairs means every platform's discovery, Noise, BLE, and hotspot code has > run in both roles. --- ## Tier 3 — Cryptography & protocol validation - [x] KATs green on all three implementations (Rust, Android, Apple) — the real cross-platform interop guarantee. 2026-07-25: Rust (28 pass, 2 hardware-ignored) and Android (`NoiseUnitTest` 10, `DiscoveryUnitTest` 4) re-run; Apple's suite was run on a Mac in Tier 0 and its sources are unchanged since. All nine shared vectors — PSK, discovery key, app handshake msg1/msg2/record, prologue + its msg1/msg2/record — are byte-identical in `core/src/noise.rs`, `NoiseUnitTest.kt`, and `macOS/FlyingCarpetTests/FlyingCarpetTests.swift`, as is the discovery announcement vector between Rust and Kotlin. - Swift had the discovery **key** KAT but no discovery **announcement** vector — the 93-byte layout + HMAC was pinned only between Rust and Kotlin. Closed 2026-07-25: `DiscoveryTests` in `macOS/FlyingCarpetTests/FlyingCarpetTests.swift` now asserts the same vector on serialize, on deserialize (field by field, plus HMAC verify), and on a flipped bit in the signed prefix. **Written on Windows and never compiled** — it needs one run on a Mac before Tier 0's Apple row can be re-checked. - The iOS test target still holds only the Xcode template tests; the Noise and discovery KATs live in the macOS target. Both projects compile the same `shared/Noise.swift` and `shared/Discovery.swift`, so iOS's implementations are covered as long as the **macOS** suite is the one that gets run. - [x] **Wrong password** (shared network): enter a mismatching password → clear "could not establish a secure connection / check the password" message, no hang - [ ] **Wrong password** (hotspot): same, via the BLE-exchanged password path - [x] **Version mismatch**: run a v10 build against a v9 build → clear version-mismatch message on both ends, no hang or garbage - [x] **Preamble tamper** (if a test hook exists / via unit test) — handshake fails, transfer aborts. Unit-tested on all three: `prologue_mismatch_fails_handshake` (Rust), `prologueMismatchFailsHandshake` (Kotlin), `testPrologueMismatchFails` (Swift). Each flips one bit of the responder's transcript and asserts the handshake fails even though the passwords match. `tampered_handshake_is_detected` covers a corrupted handshake message on all three as well. - [x] Metadata confidentiality sanity: confirm filenames/sizes are no longer sent in the clear (packet capture optional; primarily covered by Noise KATs). Verified by inspection 2026-07-25: everything after the preamble goes through the `TransferStream::Encrypted` handle — file count (`lib.rs`), then filename length, filename bytes, size, per-chunk lengths and hashes (`sending.rs:87-117`). The only plaintext writes are the version/mode preamble, which the prologue binds, and the `TransferStream::Plain` fallback used solely to report a version/mode mismatch. --- ## Tier 4 — Lifecycle regression (the fixes on this branch tail) Each row has a specific repro that previously failed — test the repro, not just "works." - [x] **Android repeat transfer**: iOS → Android **twice in a row**, no restart → transfer 2 stands up its hotspot; no "hotspot already running" in logcat; the Bluetooth switch stays **on** between legs (2026-07-25: a leftover pre-bond GATT client received iOS's teardown Service Changed after stop() and flipped it off — field guide §2c; expect "Ignoring service change after teardown" in logcat instead) - [x] **Android GATT teardown**: back-to-back hotspot transfers → no phantom "Wrote OS to peer" / "Device connected" churn between transfers (logcat) - [x] **Apple central teardown**: macOS/iOS as **receiver** (central), then a second transfer → no leaked connection; second transfer clean - [x] **Apple peripheral service**: Apple as **sender** twice in a row → service re-registers; second advertise/read works - [ ] **Cancel mid-transfer** (Android, hotspot) → UI re-enables; no spurious "Transfer error: … cancelled" - [ ] **Cancel mid-transfer** (Android, shared network) → no spurious "Transfer error" or "Discovery error: … cancelled" - [x] **Successful shared-network receive** (Android) → **no** "Discovery error" after "Transfer complete" - [ ] **Cancel mid-transfer** (each desktop) → UI recovers (drop-guard); hotspot torn down - [ ] **Stale BLE pairing recovery**: forget the peer on one side only, retry → re-pairs cleanly (do not need to forget both). **Known to fail as of 2026-07-25** — this is the unresolved half of the Windows↔Linux failure; the re-pair returns `Pairing result: Failed` and keeps failing on subsequent attempts. Capture `bluetoothctl paired-devices` and `btmon` on the Linux side when testing this. - [x] **Bidirectional BLE hotspot, Windows ↔ Linux** (the 2026-07-25 repro): Windows → Linux, then immediately Linux → Windows without restarting either app. Leg 2 must reuse the bond and enumerate services. Previously leg 1 made Linux delete its half of the bond, so leg 2 got an empty GATT service list and then failed to re-pair permanently. Confirm `bluetoothctl paired-devices` on Linux still lists the Windows box after leg 1. - [x] **Both bond provenances, Windows ↔ Linux.** Run the pair twice from fully unpaired, once in each starting order, because the two produce *different* cached state on Linux and only one of them used to work: - [x] unpaired → **Windows → Linux** first (Linux bonds as central) → then Linux → Windows - [x] unpaired → **Linux → Windows** first (Linux bonds as peripheral) → then Windows → Linux. This is the order that hung: Linux's cache entry for the peer had no Flying Carpet UUID, and the scan only reacted to `DeviceAdded`, which never fires twice for a bonded peer. Expect `Found peer … by re-reading known devices` in the Linux stdout. - [ ] Then a third leg in each case, to confirm repeat transfers keep working - [ ] **Bidirectional BLE hotspot, Linux ↔ Android** and **Linux ↔ macOS**, same pattern — Linux no longer removes any peer's bond, so all three pairings need one clean round trip. macOS was already exempt and should be unchanged. - [x] **Android post-bond bearer** (`TRANSPORT_AUTO` → `TRANSPORT_LE`, fixed 2026-07-25): Android ↔ **macOS** and Android ↔ Linux/Windows, from fully unpaired, so the post-bond `connectGatt` runs against a dual-mode peer right after cross-transport key derivation. This was the direct analogue of the Windows↔Linux `br-connection-canceled` bug and had never been exercised. Failure looks like a connect that succeeds with no Flying Carpet service. - [x] **Android as central, twice in a row, bonded** — Android↔Windows and Android↔Linux, Android **receiving** both legs, no app restart between them. This row was written when Android never invalidated its GATT cache and predicted a silent hang; both halves of that prediction have since been fixed (`onServiceChanged` now re-discovers, gated on `exchangeComplete`, and every `onServicesDiscovered` exit reports and calls `bluetoothFailed()`). Expected now: leg 2 logs "Services changed" followed by a successful re-discovery and a normal transfer. A "Did not find the Flying Carpet service" abort or any hang after "Discovered services" is a regression in that fix, not the previously predicted stale-cache hang. See `docs/ble-bond-asymmetries.md`. - [ ] **Poisoned-bond self-heal still works** (Linux as central): the deliberate `remove_device` on characteristic-discovery failure was kept; confirm a genuinely bad bond still recovers via the "retrying with a fresh pairing" path. - [ ] **Android rotation mid-transfer**: rotate during a *multi-file* transfer (enough files that the log has scrolled) → transfer keeps running; the log survives whole, with no duplicated or missing line at the seam and no truncation; auto-scroll still follows new lines afterward; progress bar and button states are preserved. The log now lives in the ViewModel instead of the saved-state `Bundle`, so this also covers the `TransactionTooLargeException` that a long transfer's log previously risked on rotation. Rotate a second time after the transfer completes to confirm the finished log persists. --- ## Tier 5 — Platform-specific - [x] **Android**: LocalOnlyHotspot works on target device (known-broken on some Xiaomi/MIUI/HarmonyOS — note device model tested) - [ ] **Android**: deny then re-grant Bluetooth permission → switch stays usable, recovers on resume (#101); password-prompt dialog readable in dark mode - [ ] **iOS**: after a hotspot transfer, no leftover `flyingCarpet_*` Wi-Fi config; force- quit mid-transfer then relaunch → stale config removed on startup - [ ] **Linux**: force-kill mid-hotspot, relaunch → stale `flyingCarpet_*` NetworkManager connection removed on startup (#51) - [ ] **Windows**: WiFi Direct AP starts/stops cleanly; SSID with special characters does not break profile handling (XML-injection fix); firewall prompt handled - [ ] **macOS**: long transfer doesn't silently drop when macOS switches back to an internet network (known caveat — confirm behavior/messaging) - [x] **Interface chooser** (desktop): dropdown lists interfaces with IPs, hides unusable ones, wired interface works in shared network mode - [ ] **Shared network over a manual iPhone Personal Hotspot** joined by both devices (documented Apple↔Apple path) --- ## Tier 6 — Robustness / edge cases - [x] Multi-file transfer (2+ files) - [ ] Large single file (> 2 GB if feasible; sustained multi-record Noise streaming) - [ ] Empty / zero-byte file - [ ] Filename with Unicode / spaces / emoji - [ ] Peer never starts → no infinite hang; cancellable; clean message - [x] Receiver started long before sender → still connects (no premature timeout) - [ ] Sender and receiver both pick the same mode (both Send / both Receive) → clean "both sides picked the same mode" error - [ ] Two transfers in a row in **shared network** mode on every platform (mirror the Android repeat-transfer regression) ### Send Folder — retest on all five (behavior changed; previously inconsistent) Every platform now sends a chosen folder so the **receiver recreates that folder inside the destination they picked, with the contents inside**. Before this change only macOS did that; Windows, Linux, Android, and iOS dumped the contents loose into the destination, and Android→desktop failed outright for any folder with sub-folders. Rationale, the old per-platform behavior, and the fixes: `docs/send-folder-behavior.md`. Use one test folder throughout, so results are comparable. It must exercise every case that used to break: ``` TestFolder/ top.txt <- file directly inside the selection Nested/inner.txt <- one level down Nested/Deeper/deep.txt <- two levels down OnlyDirs/x/1.txt <- selection level with NO loose files (used to flatten or fail) OnlyDirs/y/2.txt <- sibling of the above (used to abort: "Strip prefix error") ``` Pass = destination contains `TestFolder/` with all five files at the paths above, and **nothing** loose in the destination root. - [x] Windows sends `TestFolder` (Send Folder checkbox) → receiver gets `TestFolder/…` - [x] Windows sends `TestFolder` by **drag-and-drop** onto the window → same result - [x] Linux sends `TestFolder` (checkbox and drag-and-drop) - [x] Android sends `TestFolder` (Send Folder checkbox) → **to a Windows or Linux receiver**; this is the combination that used to fail with "Received invalid filename path" - [ ] iOS sends `TestFolder` ("Send Folder" in the "Send from:" prompt) - [ ] macOS sends `TestFolder` (choose the folder in the picker) → confirm **no regression**; this is the one platform whose behavior did not change - [x] Each of the five **receives** `TestFolder` from at least one other platform, and the folder is recreated rather than flattened - [ ] Plain multi-file selection still arrives **flat** (no folder created) on all five — the fix must not wrap ordinary file sends in a directory - [ ] Desktop: select/drop files from **two different directories** at once → all arrive, flat, no "Strip prefix error" (previously aborted the transfer) - [ ] Desktop: drop **two folders** at once → both recreated side by side (previously aborted) - [ ] Send a folder twice into the same destination → second copy lands under "(1) name" siblings rather than clobbering - [ ] Folder containing a file with Unicode / spaces / emoji in a **sub-folder** name --- ## Lifecycle status (BLE + hotspot resource teardown) Post-fix state. ✅ = correct; ⚠️ = fragile/by-design; see caveats. | Platform | Hotspot torn down | Central: scan stopped | Central: connection closed | Peripheral: advertising stopped | Peripheral: service removed | |---|---|---|---|---|---| | **Android** | ✅ fixed | ✅ | ✅ **fixed** | ✅ | ✅ **fixed** | | **Windows** | ✅ | ✅ | ⚠️ persists (by design) | ✅ **fixed** (explicit StopAdvertising) | ✅ (registration released on drop) | | **Linux** | ✅ | ✅ (RAII) | ✅ (RAII + remove_device) | ✅ (explicit drop) | ✅ (explicit drop) | | **iOS** | ✅ | ✅ | ✅ **fixed** | ✅ | ✅ **fixed** | | **macOS** | ✅ | ✅ | ✅ **fixed** | ✅ | ✅ **fixed** | **Verification status of the fixes:** - Android fixes: compile + lint verified; **not yet hardware-tested** (Tier 4 rows). - Apple fixes: **not yet built** (Tier 0) and not tested (Tier 4 rows). - Windows peripheral advertising now stops explicitly (`StopAdvertising()` + `RemoveAdvertisementStatusChanged`); the one remaining ⚠️ is the device connection/pairing persisting after a transfer, which is **by design** (Windows has trouble re-enumerating already-paired devices, so "unpair after every transfer" is intentionally disabled) and is **pre-existing**, not a v10 regression. **Hardware-tested 2026-07-24:** Windows→iPhone hotspot (fresh pairing) passed, but the reversed second leg (iPhone→Windows, reused bond) failed GATT service enumeration with `0x8000FFFF`; a manual rerun with fresh pairing succeeded. The Windows central now recovers from this automatically: on enumeration failure it retries enumeration (up to 3×, ~1 s apart), and only if the bond was reused and all retries fail does it unpair, rescan, and re-pair (new PIN confirmation) within the same transfer instead of aborting. **Retest 2026-07-24: passed** — reversed second leg reused the bond and enumerated services on attempt 1 (leg-1 BLE link was still up, so no reconnect was needed); no recovery rung fired, so the ladder itself remains field-unexercised. The original failure is intermittent — if it recurs, the UI log's attempt/timing diagnostics will show which rung fixed it. Root-cause investigation, sources, and the recovery-ladder design: `docs/windows-ble-gatt-0x8000ffff.md`. - Linux is the reference implementation (full RAII); no action. **Release read on the lifecycle front:** the code gaps found in the audit are closed on Android and Apple, so on paper every platform is ✅ except Windows' pre-existing ⚠️. But "buttoned up" requires the verification: **Tier 0 build of Apple + Tier 4 regressions on Android and Apple must pass.** Until then the fixes are unverified. Windows' remaining items are not new in v10 and should not block release. --- ## Release gate - [x] Tier 0 fully green (**hard blocker**: Apple must build) - [x] Tier 1 green - [x] Tier 2 core cycle + hotspot pairs green - [ ] Tier 3 green (wrong password + version mismatch are must-pass) - [ ] Tier 4 green (lifecycle regressions — highest-risk new code) - [ ] Tier 5 green or documented known-issue per platform - [ ] Tier 6 green or documented - [ ] Version strings bumped to 10 in all artifacts; changelog/README updated. Versions checked 2026-07-25 and all agree: `core/Cargo.toml`, `src-tauri/Cargo.toml`, and `tauri.conf.json` at 10.0.0; Android `versionName "10.0.0"` (`versionCode 22`); iOS and macOS app targets `MARKETING_VERSION = 10.0.0` (the 1.0 entries in both pbxprojs belong to the test bundles, which don't ship). README covers Shared Network + Noise. Left unchecked for the changelog only — the repo has no `CHANGELOG.md`, so if release notes live on the GitHub Releases page, that's the remaining item. - [ ] Both repos tagged in lockstep (wire-compatible) ---