Skip to content

fix(bindings): keep bluetooth le links working with the app in the background or the screen off - #668

Merged
bahdotsh merged 16 commits into
mainfrom
fix/ble-background-links
Oct 31, 2026
Merged

bahdotsh merged 16 commits into
mainfrom
fix/ble-background-links

Conversation

@mizanxali

@mizanxali mizanxali commented Oct 30, 2026 •

Copy link
Copy Markdown
Member

Summary

A backgrounded iPhone stopped sending on every Bluetooth LE link about two minutes after the app left the foreground, and never recovered, even back in the foreground. An Android phone with its screen off discovered nobody and churned its advertising address every 90 s. Both came from a scan watchdog reading the quiet of an OS-restricted scan as a broken stack. This PR makes each mobile bridge scan for the mesh service whenever the OS restricts scanning, and stops treating that silence as a stall.

Context

Found during a two-hop device run (Python host → Samsung M17 → iPhone 16 → Chromium over Web Bluetooth). With both phones in "pocket" state, no message crossed in either direction after five minutes, and foregrounding did not recover it. Root-caused with temporary os_log tracing on the iPhone (not part of this PR):

  • iOS: in the background, iOS runs only a scan that names a service; bluetoothd reports "0 advertisements delivered" to our filterless one.
    • The watchdog restarted the quiet scan, and after three restarts replaced the CBCentralManager.
    • The new manager, under the same restore identifier while the old session was still alive, reported .unsupported and never left it (it never registered a session with bluetoothd).
    • The bridge took that as a device with no Bluetooth: bleStatusChanged(false) dropped every neighbour in the core in one instant, and the fragment drain's state == .running guard stopped every send. Inbound kept arriving, so each peer saw a link that took its writes and never answered.
    • It needs a peer whose writes keep waking the app. A suspended app's watchdog never runs, which is why idle-only repros passed.
  • Android: an unfiltered scan is paused with the screen off. The watchdog restarted the scan every 30 s and reset the stack every 90 s. Each reset restarted the advertisement under a new random address (the churn ADR 0030 removed from the forced refresh).

Related issues

None filed.

Type of change

  • fix: bug fix
  • test: adding or correcting tests
  • docs: documentation only

Changes

  • BleScanPolicy.swift / BleScanPolicy.kt (new): the scan names the mesh service in the restricted state (iOS background, Android screen off), and a quiet scan there is never a stall. Every SDK advertiser puts the service in its primary packet.
  • BleManager.swift:
    • observes app-state notifications and restarts a running scan under the new filter;
    • the scan monitor does nothing in the background;
    • the targeted service scan returns to the policy's filter rather than to nil;
    • the central-manager replacement (evaluateCentralHealthAfterRestart) is removed: a restart never escalates;
    • the central and peripheral managers are made once per process and reused by every start, with their state replayed through the delegate. The bridges release their BleManager on stop, and bluetoothd keeps a released manager's session for about 15 s, so an ordinary stop then start (or an engine rebuild, or a second start while .starting) made the same .unsupported pair. Stop does what releasing them used to: it unpublishes the GATT service and cancels every known, pending and restored peripheral (only while this instance still owns the managers), then detaches the delegates. Ownership is an ObjectIdentifier token, which still compares in deinit where the weak delegate reads nil; a non-owner lets go of the managers before its stop touches anything, and start stops a still-running predecessor first (a React Native reload starts the new module before the old one is freed);
    • returning to the foreground re-anchors the forced refresh, which otherwise fired on the first tick after any background stay over 2 minutes.
  • BleTransportFacade.kt:
    • a screen on/off receiver restarts a running scan under the new filter;
    • startScan passes a service ScanFilter with the screen off, at a balanced duty cycle instead of low latency (BleScanPolicy.scanMode): the stack used to pause that scan at no cost, and full duty in a pocket listens continuously to every peer nearby;
    • with the screen off the watchdog restarts a quiet scan only after 5 minutes and never counts it toward the stack reset, because Android refuses an app's sixth start within 30 s with no callback and nothing else would notice a dead pocket scan;
    • a screen transition moves the scan 2 s later, and only if its filter is wrong, so a glance at the phone costs no start;
    • only a screen-on transition re-stamps the watchdog's clock (re-stamping on screen-off let glances keep a refused scan dead), and registering the receiver re-reads the screen state, since a non-sticky SCREEN_OFF before registration was lost;
    • the stack reset restarts the advertisement only as AdvertisingRefreshPolicy allows, as the forced refresh does.
  • Source guard (crates/offline-protocol-uniffi/src/lib.rs, ble_scans_follow_the_platform_restriction_and_ios_never_replaces_its_central): pins one CBCentralManager made in startUnsafe, every iOS scan's services from BleScanPolicy.services (the targeted service scan aside), the iOS monitor and Android watchdog consulting mayRestartQuietScan before any restart, the Android scan's filters from filtersOnService, the reset's advertising restart behind AdvertisingRefreshPolicy, and the app-state observation ended only on stop (not on radio loss). Each assertion was mutation-checked.
  • Wiring: the new Swift file in MeshSdk.podspec, ios/Package.swift (source and test) and the CI typecheck list.
  • Docs: C14 in docs/bridges/README.md (the rule, the measured failure, and what stays out of reach); two CHANGELOG.md entries under Unreleased → Fixed.

Device runs (iPhone 16 + Galaxy M17 + Chromium on a Mac)

Run Before After
8 min idle, iPhone backgrounded, Samsung locked; then browser → iPhone and browser → iPhone → Samsung both pending forever; iPhone central .unsupported 668 ms direct; 8.5 s at hop 1 (incl. session setup)
Full chain Python → Samsung (LAN, locked ~6 min) → iPhone (backgrounded ~22 min) → browser, both directions dead both ways ~3 s each way, hop 2, encrypted, receipts back at hop 2
Fresh Samsung identity while the iPhone stays backgrounded not discovered (filterless background scan) discovered and linked in 13 s
Watchdog restarts / resets while idle iPhone reset at ~2 min; Samsung restart every 30 s, reset every 90 s none on either
Return to foreground / screen on n/a scans switch back to filterless; delivery unchanged
iPhone Bluetooth toggled off and on while backgrounded, then foreground and background n/a each app-state switch still followed after the power cycle
Galaxy M17: lock, two quick glances, then 5 min locked with nothing in range restart every 30 s, reset every 90 s scan moved 2 s after the lock; glances cost no start; one restart after 300 s, filter kept, no reset
iPhone messenger off then on 5 s apart (stop, start) new managers under the same restore identifiers both managers reused, powered on; scanning, GATT service, advertising and the Samsung link back within 1 s
iPhone backgrounded; Samsung Bluetooth off 60 s, then on n/a Samsung re-subscribed in 3 s; the iPhone discovered, connected and verified the Samsung's new addresses, still backgrounded
iPhone back to the foreground after 3 min away forced refresh on the next tick one scan restart (the filter switch), no forced refresh
iPhone messenger off and left off for 60 s (with process-wide managers, before the teardown) service stayed published the Samsung's next dial found no mesh service; turned back on, the link returned within seconds
Galaxy M17 locked with the iPhone nearby screen-off scan at low latency the stack's counters show the scan running Balanced; it found the iPhone within 50 ms of starting
iPhone locked for 6 min, Samsung locked; browser → iPhone and → iPhone → Samsung not run 705 ms direct; 1.3 s at hop 1; no watchdog restart or reset on either phone. MLS and state storage already use after-first-unlock protection

Not covered

  • An Android phone cannot discover a backgrounded iPhone: its service sits in Apple's overflow area. Links already formed are unaffected, and the iPhone still dials Android peers.
  • The web carrier still has no link liveness: writes are without response, and connect short-circuits while gatt.connected is true.

Checklist

  • Commits follow Conventional Commits
  • Docs / CHANGELOG.md updated
  • No new unsafe
  • UDL unchanged
  • cargo test -p offline-protocol-uniffi --lib: 316 passed (the guards that read the bridge sources)
  • swift test --package-path bindings/react-native/ios: 501 passed
  • Kotlin unit tests (:offline-protocol_mesh-sdk:testDebugUnitTest): passed, including BleScanPolicyTest
  • cargo fmt --all -- --check: clean (the only Rust is the source guard)
  • clippy / cargo test --workspace: not run; the guard is a test in offline-protocol-uniffi, whose lib tests pass
  • Swift Package / Android Library CI jobs: left to CI

@mizanxali
mizanxali marked this pull request as ready for review October 30, 2026 20:24
@mizanxali
mizanxali requested a review from bahdotsh October 30, 2026 20:24
…d by scanning for the mesh service and never replacing the central manager

In the background iOS runs only a scan that names a service and delivers
nothing to a filterless one, so the scan watchdog read the silence as a
stall and, three restarts in, replaced the CBCentralManager. A second
manager under the same restore identifier reports unsupported for good:
the bridge reported Bluetooth unavailable, the core dropped every
neighbour, and the drain stopped sending on every link until the app was
killed, while inbound kept arriving. Measured on an iPhone about two
minutes after backgrounding, beside a peer whose writes kept it awake.

A backgrounded scan now names the mesh service, a scan follows the app
across each transition, the watchdog does nothing in the background, and
a restart never escalates to a new manager (BleScanPolicy).
…and stop treating the quiet as a stalled stack

Since Android 8.1 an unfiltered scan is paused while the screen is off.
The facade scanned unfiltered regardless, so a locked phone discovered
nobody, and the watchdog restarted the scan every 30 seconds and reset
the stack every 90, restarting the advertisement each time: a new random
address that a linked peer then dials and never connects (ADR 0030).

With the screen off the scan now filters on the mesh service, a scan
follows the screen across each transition, a quiet screen-off scan is not
a stall (BleScanPolicy), and the stack reset restarts the advertisement
only as AdvertisingRefreshPolicy allows, as the forced refresh does.
…nd the failure that made it a rule

C14 names when each mobile bridge scans for the mesh service (iOS in the
background, Android with the screen off), the measured failure each half
prevents, and what stays out of reach: an Android phone cannot discover a
backgrounded iPhone, whose service is in Apple's overflow area.
…er cycle

The app-state observers were removed in clearLinkState(), which also runs
on every central or peripheral power-off and reset, while the transport
carries on and only startUnsafe() adds them. After a Bluetooth toggle the
scan kept whichever filter it had: a filterless scan in the background
again, or a service-filtered one in the foreground with the watchdog off.
They are now removed only on stop, and observeAppState() clears any pair
a second start left registered. Device-checked: Bluetooth toggled off and
on while backgrounded, then foreground and background again, each switch
followed.
…al manager

BleScanPolicy is unit-tested on both platforms, but a policy nobody calls
passes its own tests. The guard pins: one CBCentralManager, made in
startUnsafe; every iOS scan's services from BleScanPolicy.services (the
targeted service scan aside); the scan monitor and the Android watchdog
asking mayRestartQuietScan before any restart; the Android scan's filters
from filtersOnService; the stack reset restarting the advertisement only
as AdvertisingRefreshPolicy allows; and the app-state observation ended in
stop, never in clearLinkState. Each was mutation-checked to fail.
…e screen-state restart

The screen-off watchdog only re-stamped its clock, so a scan that was dead
rather than quiet stayed dead for the whole pocket: Android refuses an
app's sixth scan start within 30 seconds with no callback, each screen
transition was a start, and the proactive refresh runs only on a result.
A screen-off scan that delivers nothing for five minutes is now restarted,
without counting toward the stack reset, and a screen transition moves the
scan two seconds later and only if its filter is wrong, so a glance at the
phone costs no start. Device-checked on a Galaxy M17: the lock moved the
scan 2 s later, two glances cost no start, and a quiet locked scan was
restarted after 300 s with the filter kept and no reset.
…omes on, and catch a screen change before the receiver registered

Re-stamping on screen-off let every glance less than five minutes apart
keep a silently refused pocket scan dead, since the debounce rightly skips
a scan whose filter already matches. And the screen state was read at the
top of start, but the receiver registered about 75 lines later; a non-sticky
SCREEN_OFF in between left an unfiltered (paused) scan on the escalating
watchdog, the original bug. The receiver's handler is shared, and
registration re-reads the screen state through it.
…top a forced refresh firing on every foregrounding

A manager made under a restore identifier whose released predecessor's
session is still alive reports unsupported for good, and bluetoothd keeps
that session for about 15 seconds. The bridges release their BleManager on
stop and make a new one on the next start, so an ordinary stop then start,
an engine rebuilt, or a second start while still starting made that pair.
The managers are now made on the first start and reused, replaying their
state through the delegate; stop detaches them. Device-checked: messenger
off and on 5 s apart reused both managers, and scanning, advertising and
the link came back within a second.

The monitor skips every refresh in the background, so the forced refresh's
clock was stale on return and fired on the next tick. Returning to the
foreground now re-anchors it; device-checked after 3 minutes away.
…t on stop, now that stop keeps the managers

Releasing the managers used to make bluetoothd drop the session, which
unpublished the service and dropped every connect. With the managers kept
for the process, a stopped iPhone kept serving its identity, took writes it
never answered, and kept pending and restored connects that could complete
after the stop. Stop now removes the services and cancels every known,
pending and restored peripheral, while the managers are still its own.
Device-checked: after the messenger was turned off, the Samsung's next dial
found no mesh service; turned back on, the link returned in seconds.
…y, and stop a running predecessor before taking them

Ownership was read from the managers' weak delegate, which reads nil in
deinit, so a BleManager released while running skipped its stop teardown
and left its service published and its connects pending on managers that
now outlive it. And stop's scan and advertising commands hit the shared
managers whoever owned them, so a predecessor stopped after a new instance
started (a React Native reload) ended the new one's scan and advertising
while it still believed both were running. Start now stops a running
predecessor and records itself as owner by ObjectIdentifier; stop decides
ownership once, and a non-owner lets go of the managers before touching
anything. The PR's other paths are unchanged on device: messenger off and
on relinked at once. The comment on the targeted service scan also says
what a background scan misses.
The stack used to pause the screen-off scan at no cost; the filtered one
this PR runs instead stayed at low latency, so a phone in a pocket listened
continuously to every peer nearby. With the screen off the scan is now
balanced (BleScanPolicy.scanMode); links already up do not depend on it,
so the price is a slower discovery of a new peer. C14 says so, and no
longer claims every advertiser puts the service in its primary packet: a
backgrounded iPhone does not. Device-checked: the locked Samsung's scan ran
balanced in the stack's own counters and still found the iPhone in 50 ms.
@bahdotsh
bahdotsh force-pushed the fix/ble-background-links branch from 4fbb240 to 92a6c5c Compare October 31, 2026 09:57

@bahdotsh bahdotsh left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM!

@bahdotsh
bahdotsh merged commit ad2c9d9 into main Oct 31, 2026
45 checks passed
@github-actions github-actions Bot locked and limited conversation to collaborators Oct 31, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants