logs.gokuls.in

1 pull request merged across 1 repo

Offline-Protocol/offline-protocol-sdk

Two copies of one app could stop finding each other over Bluetooth as soon as a second app built on this SDK was installed on either phone. Uninstalling the other app and toggling Bluetooth on both phones was the only recovery.

Why. Every SDK app registers the same GATT service, and iOS and Android merge every app's service into one device-wide GATT database. A phone running a chat app and a game therefore presents two instances of the service behind one BLE connection, each serving a different identity. Both bridges assumed one:

  • iOS discovered characteristics on every instance and read DEVICE_ID and IDENTITY from each into the same per-peripheral slot, then cross-checked whichever pair landed. The peer was refused as a mismatch, or announced under the other app's identity, at random.
  • Android used gatt.getService(serviceUuid) at every step, which returns the first instance: always the app that registered first.

The fix. Each app now serves an 8-byte tag in a new, optional characteristic 6E400005-B5A3-F393-E0A9-E50E24DCCA9E: the first eight bytes of SHA-256("offline-protocol-ble-app-tag-v1" || 0x00 || appId). A central that finds several instances reads their tags and picks one with BleServiceInstanceSelection:

1. the first instance serving its own tag;

2. else, if exactly one serves no tag, that one (a build that predates the tag);

3. else the first instance, which is what it did before.

The unchanged handshake, subscription and writes then run on that instance only. The choice never refuses a link: relay forwarding is keyed on the recipient, never the app, so a phone running only other SDK apps still carries our traffic and stays a neighbour.

One link, one peer, deliberately. Announcing every instance as its own peer was considered and rejected for this PR. The peripheral role attributes an inbound write to a sender by the link it arrived on, so a second identity on one link would have its hop-0 control frames refused by validate_transport_sender. Doing that properly needs a core change to accept a set of verified senders per link. A per-app service UUID was also rejected: it is a wire break, and it would stop cross-app relaying outright.

What does not change

A peer running one SDK app takes exactly the path it took before. There is no probe, no binding and no tag read, and every service lookup falls back to services.first / gatt.getService as it always did. That is every deployment that works today.

  • The new characteristic is appended after the existing three, so their attribute handles do not move for a central holding a cached GATT table.
  • No UDL change, no engine change, no event change, no config change. The tag comes from the appId each app already passes.
  • An older SDK build serves no tag and ignores the new characteristic. It keeps its old behaviour, and until it upgrades it cannot pick its own counterpart on a multi-app phone.

Related issues

None filed. Found while debugging a game built on the SDK that stopped pairing once a second SDK app was installed on the test phones.

Type of change

  • fix — bug fix
  • docs — documentation only

Checklist

  • cargo fmt --all -- --check passes
  • cargo clippy --workspace -- -D warnings passes
  • cargo test passes for the two crates touched (offline-protocol-uniffi: 136, offline-protocol-transport: 295 + 5). No other crate is changed; the full workspace run is left to CI.
  • cargo-deny is satisfied (no dependency changes)
  • Commits follow Conventional Commits
  • Docs / CHANGELOG.md updated
  • No new unsafe
  • UDL unchanged, so no binding regeneration

Bridge checks, since cargo test proves nothing about the bridges (C9):

  • swift test --package-path bindings/react-native/ios: 360/360 (13 new)
  • Android :offlineprotocol:testDebugUnitTest in a clean copy of the CI harness: 495/495 (14 new). Kotlin warning count unchanged versus main.
  • iOS typecheck with CI's file list, and of all 40 hand-written sources including OfflineProtocolModule.swift (via the BRIDGE_MAINTENANCE.md recipe, with a negative control that calls the old two-argument BleManager init and fails).
  • Two-phone matrix on real hardware (CI runs neither bridge's GATT code). See "Notes for reviewers".

Breaking changes

None. The GATT change is additive: an optional characteristic that old centrals never request.

Notes for reviewers

Where to look.

  • ios/BleManager.swift:
  • The gate is one line at the top of didDiscoverCharacteristicsFor, above the subscribe, so an unchosen instance is neither subscribed to nor read.
  • The probe/selection helpers sit together under // MARK: Service instance selection.
  • Every service lookup goes through handshakeService(on:).
  • android/.../ble/CentralGattClient.kt: the tag phase slots into the existing one-GATT-op-per-callback chain. It sits between the MTU exchange and the device-id read, at all three MTU exits (onMtuChanged, its watchdog, and a refused requestMtu), via readServiceInstanceTagsIfChoosing. Every later lookup goes through handshakeService(gatt), including the facade's write path.

Threading (iOS). CoreBluetooth delegates run on main (queue: nil), but the send path runs on fragmentQueue. The per-link binding (.choosing / .bound(CBService) / .dropping) is therefore behind its own NSLock. While a link is choosing, the send path writes nothing. Without that, a transient reconnect keeps the old peer-id mapping and would write to services.first before the re-handshake.

A choice lasts one connection. Selection state is cleared on connect, on adopting an already-connected peripheral, on disconnect and on stop. If the bound instance disappears mid-link (its app quit), iOS drops the link and re-handshakes rather than rebinding. That way the identity a link was announced under and the instance its frames go to can never be two different apps. didModifyServices is newly implemented for this, and it returns immediately for a single-instance link. Android needs no equivalent for sends: its write path already waits for the CCCD ack on the chosen instance.

Pinned from Rust. react_native_ble_binds_one_service_instance_per_link pins:

  • the UUID literal in both bridges to the new BLE_APP_TAG_CHAR_UUID constant;
  • the tag domain and the shared test vectors across both platforms;
  • the iOS gate above the subscribe;
  • exactly one first-instance lookup per bridge;
  • all three Android MTU exits.

It was mutation-tested: removing the iOS gate, removing one Android MTU exit, or reverting the facade to gatt.getService(SERVICE_UUID) each fails it. The existing identity-binding and idempotent-discovery guards pass unchanged.

Security. The tag is unsigned. Any key can sign any tag, so a signature would add nothing the Identity characteristic does not already prove. A co-installed app can serve another app's tag and be chosen in its place, but it is still bound under its own proved address. The tag is also readable in range, as a digest of the app id. Both are written up as residual risk R15 in the threat model.

Two-phone matrix to run before merge, using diagnostics that log BLE service instance chosen with the reason:

  • A: game only; B: game + another SDK app, both on this branch → pair on the first attempt, twice, and again after a Bluetooth toggle (reason: same_app)
  • B: game on this branch + another SDK app on an older SDK → game still pairs (same_app, or sole_untagged from the older app's side)
  • B: only another SDK app → game still lists it as a neighbour, as before (first_instance)
  • A and B: game only → unchanged; no BLE service instance chosen line and no tag read
  • Uninstall the other app on B mid-session, without toggling Bluetooth → game keeps or regains the link

Docs.

  • docs/spec/ble-framing.md now specifies App tag and the selection rule. It also fixes the stale "three characteristics" line that sat above a four-row table.
  • The threat model gains R15.
  • Bridge rule C5 lists the tag as the tenth hand-mirrored set.
  • The React Native README gains a troubleshooting entry.

Out of scope, noted. docs/mesh.md and the React Native README still say mesh metadata rides in advertisement service data, which neither platform does. The engine delivers frames whose app_id differs from its own, and neighbor_discovered cannot say which app a peer runs. Each would be its own change.