October 6, 2026
3 pull requests merged across 1 repo
Offline-Protocol/offline-protocol-sdk
0.28.0 merged in #509 (d3aed186) but was never tagged: no v0.28.0 tag, nothing on npm, crates.io or PyPI. Since then #516, #515, #517 and #518 landed, and #515/#516 opened a new [Unreleased] above [0.28.0].
This folds them into 0.28.0 instead of cutting 0.28.1 over a number that never shipped:
- CHANGELOG: the five
[Unreleased]entries move to the top of 0.28.0's### Fixedand### Added; no empty[Unreleased]left. The summary blockquote gains the Android Hello / 15 sneighbor_lostsentences and a paragraph for the dense-cluster forwarding fix, the probe outbox fix andnative_event_gap. - UPGRADING §26: Android
neighbor_losttiming, and the newnative_event_gapdiagnostic. - No version file changes: every manifest already reads 0.28.0.
scripts/generate-third-party-notices.shproduces no diff. - #517 (example apps only) and #518 (a fix to unreleased #516 behaviour) need no entry of their own.
After merge: push v0.28.0-rc.1 first (the #464 staged-publish fix has never run on a real tag), then v0.28.0, then the Swift pull workflow.
, #512 and #513. Refs #514 (instrumentation; no cause has been found yet).
One commit per issue, plus one that tightens two of the new source guards.
#510: a message could die inside a dense cluster
A forwarder that got a second copy of a frame while its own forward waited cancelled the forward. The reasoning was that a neighbour's transmission had covered the same ground. It had not: every carrier hands a frame to three chosen neighbours rather than broadcasting it. So the one device next to the way out could stand down, and the frame then died with its id suppressed on every cluster member, where the sender's retries could not rescue it.
Now a duplicate takes only its sender off the forward's fan-out (PendingRelay::heard_from). A forward is dropped only when every neighbour it could go to has handed it a copy. The is_last_hop exception is no longer needed and has been removed.
everyone_hearing_everyone_does_not_multiply_the_traffic: 4/200 → 0/200 failures locally.- New
a_device_hearing_a_frame_from_every_side_still_carries_it_onwardis deterministic (no hash tie-break involved). It fails every run under the old rule (checked by mutation). - Worst-case cost is unchanged: one fan-out per device, inside the same budgets. A simulation of the test's exact lock-step semantics gave 2.46% failures before (matching the measured ~2%) and 0 in 200,000 after, with at most 19 transmissions against the test's bound of 21.
#513: a peer with Bluetooth off stayed a neighbour for 60+ s
Two disconnect paths left the peer listed:
- Server-side clean disconnects (status 0/19) started nothing, so a server-only peer stayed listed indefinitely.
- Client links retried on any status with a 5→60 s backoff, about 135 s before giving up.
Now both paths start a 15 s grace per peer once no link to it is up. A returning link cancels it. On expiry the facade does the teardown a non-clean disconnect does, for every address the peer used. Reconnects carry on behind it.
The registry now tracks *established* client links: a dial in flight registers a gatt for the whole connect timeout, and must not count as a link.
#512: writes from an unmapped central waited on a dial-back (up to ~40 s)
Nothing on BLE proved the central to the peripheral. New optional GATT characteristic Hello (6E400006-…):
- Android central: writes the value it already serves on Identity, with response, after the CCCD ack and before link-ready, so it lands before any Message write. If it can't (no characteristic, MTU too small, identity not primed, refused write, no callback within 3 s), the link goes ready as before.
- Server: bounds the write, verifies it with the one verifier, and binds the writer's address to the derived address only. It flushes the per-peer MTU before announcing, and announces only if no other link already has.
- Peers without Hello (iPhones included): resolved exactly as today.
Documented in docs/spec/ble-framing.md and threat model R16.
#514: rare missing message_received / message_delivered
Not reproduced, so this PR makes the next occurrence attributable rather than fixing anything. Both native modules now number every event handed to JS (seq, given out and emitted under one lock). The TS layer emits a diagnostic event (native_event_gap, with the missing range) when a number is skipped. A gap means React Native lost the event; no gap and no event means it was never produced. Older native modules (no seq) and JS reloads report nothing.
Verification
cargo fmt --check,cargo clippy --workspace -D warnings,cargo test --workspace --libandcargo doc -D warningsare clean.mesh_forwardingpasses 18/18.- Android JVM suite: 626/626 (CI harness, copied to a clean directory). New tests:
CentralGattClientHelloTest(4),StaleAddressDisconnectTest(+2),StaleAddressRegistryTest(+1),PeripheralGattServerTest(+2). - iOS:
OfflineProtocolModule.swifttypechecks with the React header recipe inBRIDGE_MAINTENANCE.md. A planted error is reported, so the run is not vacuous. - New Rust source guards: peer-lost grace wiring, hello UUID and ordering, and the
seqfield name. Each was mutation-checked: removing the clean-branch schedule, the stop-time cancel, the runnable's re-check, or the MTU-before-announce order fails a guard. tsc --noEmitis clean.
Not done / owed
- No device run. #512 and #513 change Android BLE behaviour and need the two-phone soak from the issues (p95 latency, and the Bluetooth-toggle case) before release.
- #510 follow-up: narrowing is deterministic in a fully-meshed cluster but not in every graph. Letting a retransmission past the ACK timeout re-enter forwarding as a new relay generation would make delivery eventual everywhere; that is a wire change and deserves its own issue.
- iOS: does not write or serve Hello yet.
- Swift package / Android library assembly jobs were not run locally.
What
The Android library publishes as com.offlineprotocol:offline-protocol-sdk instead of com.offlineprotocol:offline-protocol-android. That matches the offline-protocol-sdk name on PyPI.
Nothing has been published to Maven Central yet (MAVEN_CENTRAL_PUBLISH is not set), so no consumer depends on the old artifact id. This has to merge before the first Maven Central rehearsal, because the Portal validates the deployment under its artifact id.
Changed
artifactIdinbindings/kotlin/offline-protocol-android/build.gradle.kts- the consumer check's dependency (
bindings/kotlin/consumer-check/app/build.gradle.kts) - the staged-repository paths to the AAR and sources jar in
ci.ymlandrelease.yml scripts/maven-central-bundle.sh,scripts/maven-central-upload.shandscripts/tests/test-maven-central.shbindings/kotlin/README.md,CONTRIBUTING.mdand the two unreleasedCHANGELOG.mdentries
Not changed
The Gradle module :offline-protocol-android and the bindings/kotlin/offline-protocol-android/ directory keep their names. Consumers never see either, and guards read the library by that path.
Verification
python3 -B -m unittest scripts/tests/test_check_android_aar.py: 43 passed locally.- Gradle and gpg are not installed on the machine this was written on. Two CI checks carry the rest: the
Android Libraryjob publishes to the staging repository and builds the consumer check against the new coordinates, so a missed reference fails there.scripts/tests/test-maven-central.shtests the bundle and upload scripts.