Skip to content

feat: best-effort background BLE relaying (#5) - #47

Open
natyavidhan wants to merge 4 commits into
ni5arga:mainfrom
natyavidhan:main
Open

feat: best-effort background BLE relaying (#5)#47
natyavidhan wants to merge 4 commits into
ni5arga:mainfrom
natyavidhan:main

Conversation

@natyavidhan

Copy link
Copy Markdown
Contributor

Fixes #5

Background mesh relaying was not implemented: iOS suspended BLE aggressively when the app was backgrounded, and Android killed the process, so a phone in a pocket stopped relaying. This PR adds the best-effort OS hooks available and documents the residual limits honestly.

What changed

iOS (app.json, modules/ble-mesh/ios/BleMeshModule.swift):

  • Added bluetooth-central and bluetooth-peripheral UIBackgroundModes.
  • Passed CoreBluetooth state-restoration identifiers when constructing CBCentralManager and CBPeripheralManager.
  • Implemented centralManager(_:willRestoreState:) and peripheralManager(_:willRestoreState:) to resume scanning/advertising after the OS terminates the app.

Android (modules/ble-mesh/android/src/main/AndroidManifest.xml, BleMeshModule.kt, new BleMeshService.kt):

  • Added a connectedDevice foreground service that shows a persistent notification while the radio is on.
  • Added FOREGROUND_SERVICE and FOREGROUND_SERVICE_CONNECTED_DEVICE permissions.
  • Service starts on startAdvertising / startScanning and stops in teardown().

In-app disclosure (src/app/settings.tsx, src/i18n/*.ts):

  • Updated settings.radioOnDetail to state that background relaying is best-effort.
  • Added settings.limitationBackground to the honest-limitations block.
  • Added English fallback strings to all language catalogs so the Catalog type remains satisfied.

Docs (docs/THREAT-MODEL.md, modules/ble-mesh/README.md, README.md):

Why this is only best-effort

  • iOS still throttles scan/advertise rates when backgrounded; UIBackgroundModes are not a guarantee.
  • CoreBluetooth state restoration only fires when the OS terminates the app (e.g., memory pressure), not when the user force-quits.
  • Android OEMs vary widely in how aggressively they kill a foreground service.
  • The only reliable behaviour remains: keep the app open for reliable relaying.

Test plan

  • npm run typecheck passes except for pre-existing expo-localization module-not-found errors unrelated to this PR.
  • npm test passes (228/228).
  • Native changes require a physical-device build and real-world background testing, which this PR cannot fully verify in CI.

Background mesh relaying was documented as an open problem: iOS suspends BLE
aggressively and Android kills background processes, so a phone in a pocket
stopped relaying. Implement the best-effort OS hooks available and document
the residual limits honestly.

iOS:
- Add bluetooth-central and bluetooth-peripheral UIBackgroundModes in app.json.
- Pass CoreBluetooth state-restoration identifiers when constructing
  CBCentralManager and CBPeripheralManager, and implement willRestoreState
  delegates to resume scanning/advertising after an OS-terminating event.

Android:
- Add a BleMeshService foreground service (connectedDevice type) with a
  persistent notification, started when advertising or scanning begins and
  stopped in teardown().
- Add FOREGROUND_SERVICE and FOREGROUND_SERVICE_CONNECTED_DEVICE permissions.

In-app honesty:
- Update the mesh radio switch text and the honest-limitations block to state
  that background relaying is best-effort and the app should stay open for
  reliable relaying.

Docs:
- Update the threat-model open problem ni5arga#4 and README setup notes to reflect
  partial mitigation and remaining limits.

This is best-effort by design. iOS still throttles background scan/advertise,
state restoration only fires on OS-termination (not user force-quit), and
Android OEMs vary in how aggressively they kill foreground services.
Copilot AI review requested due to automatic review settings July 21, 2026 08:07

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Implements “best-effort” background BLE mesh relaying on iOS and Android, and updates in-app/docs messaging to reflect the remaining OS-level limitations so users understand that foreground operation is still required for reliable relaying.

Changes:

  • iOS: declares BLE background modes and adds CoreBluetooth state restoration hooks to resume scan/advertise after OS relaunch.
  • Android: introduces a foreground service + notification while scanning/advertising to reduce background kill/throttling.
  • Updates settings copy and documentation (README + threat model + module README) to describe the best-effort behavior and residual constraints.

Reviewed changes

Copilot reviewed 9 out of 9 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
src/app/settings.tsx Adds a new “background limitation” disclosure line to the limitations list.
README.md Updates setup instructions to say background relaying is best-effort (foreground still recommended).
modules/ble-mesh/README.md Documents the new best-effort background strategy across iOS + Android.
modules/ble-mesh/ios/BleMeshModule.swift Adds CoreBluetooth restoration identifiers and restore callbacks to resume scan/advertise.
modules/ble-mesh/android/src/main/java/expo/modules/blemesh/BleMeshService.kt Adds an Android foreground service that maintains a persistent notification while the radio is on.
modules/ble-mesh/android/src/main/java/expo/modules/blemesh/BleMeshModule.kt Starts/stops the foreground service alongside scan/advertise lifecycle.
modules/ble-mesh/android/src/main/AndroidManifest.xml Declares foreground-service permissions and registers the BLE mesh foreground service.
docs/THREAT-MODEL.md Updates the threat model’s “background operation” open problem to “partially addressed” with explicit limitations.
app.json Adds iOS UIBackgroundModes for BLE central + peripheral.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +48 to +52
<service
android:name=".BleMeshService"
android:enabled="true"
android:exported="false"
android:foregroundServiceType="connectedDevice" />
Comment on lines +3 to +6
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.Notification
Comment on lines +1001 to +1004
if !wantScanning {
wantScanning = true
}
applyCentralState()
Comment on lines +1178 to +1182
if !wantAdvertising {
wantAdvertising = true
}
applyPeripheralState()
}

@ni5arga ni5arga left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Thank you for taking #5 on — this is the honest version of a genuinely hard feature. The disclosure updates are exactly the right instinct: radioOnDetail, limitationBackground, and moving the threat-model / README from "unsolved" to "partially addressed, limits remain" — promise less than you hope, in the app, before it matters. iOS looks sound: the restore identifiers are internal CoreBluetooth keys (not advertised), and a cold relaunch rebuilds the ephemeral advertising tag fresh, so state restoration doesn't leak a stable identifier. Tests are green (228/228), typecheck and i18n:check clean.

One thing blocks merge, and it's a threat-model problem rather than a bug.

The Android foreground-service notification is a tell. It posts, persistently:

  • title: protestchat relay active
  • text: Relaying sealed messages for nearby phones in the background.
  • default lock-screen visibility

For most apps that's fine. For this one it inverts the premise. A phone in a pocket at a protest now carries a permanent, on-by-default notification that names the app and states it is running a mesh relay — readable by anyone who glances at the screen, and sitting on the lock screen of a phone that gets picked up or confiscated. That's exactly the kind of at-rest tell the rest of the app works to avoid (rotating BLE tags, nothing incriminating on disk, the panic wipe). Android forces a foreground service to show a notification, so we can't remove it — but we can make it discreet:

  1. setVisibility(Notification.VISIBILITY_SECRET) so it does not appear on the lock screen at all — only in the shade once the phone is unlocked.
  2. Neutral, non-incriminating copy: a title/text that don't name the app's purpose or say "relaying sealed messages" — something like a generic "Bluetooth active." Android still shows the app label in the shade (unavoidable for a FGS), but an unlocked adversary already has far more access; it's the lock-screen exposure and the self-describing wording that have to go. Same for the channel name/description, which surface in system settings.
  3. Say it in the disclosure: turning the radio on now shows a persistent notification while backgrounded. Someone weighing whether background relay is worth the added visibility is entitled to know the notification exists.

Two smaller things while you're in there:

  • BleMeshService.kt imports android.app.Notification twice — drop the duplicate.
  • connectedDevice foreground services on Android 14+ require a connected-device runtime permission (BLUETOOTH_CONNECT/SCAN) to be held when startForeground is called, or it throws a SecurityException. You already note real-device testing is needed — please just confirm the radio only starts the service after the permission gate, so a background start can't crash the process.

None of this is a knock on the approach; background relay is a real gap and this is a sober take on it. It's specifically the notification's visibility and wording that need to change before it lands, because on this app that notification is a safety surface, not chrome.

@natyavidhan
natyavidhan requested a review from ni5arga July 21, 2026 15:42
)

Address review feedback on the background-relaying PR:

- Remove the duplicate android.app.Notification import.
- Change the notification title/text to neutral wording ('Bluetooth active' /
  'Running in the background.') so it does not describe the app's mesh
  purpose on the lock screen or notification shade.
- Change the notification channel name/description to similarly neutral text.
- Set Notification.VISIBILITY_SECRET so the notification never appears on
  the lock screen.
- Update the in-app disclosure (settings.radioOnDetail) to mention that
  Android shows a persistent notification while the radio is backgrounded.

The service is only started inside the withPermissions() success block, so
startForeground() is called after BLUETOOTH_SCAN/CONNECT are already granted,
which satisfies Android 14+'s connectedDevice foreground-service permission
requirement.
@commitchan

Copy link
Copy Markdown
Collaborator

@natyavidhan — you pushed 989967f after @ni5arga's review but did not comment, so this has been sitting. I checked it against that review. Most of it is addressed; one thing is not, and one new thing came out of reading the i18n side.

Addressed, verified

  • Notification visibility. setVisibility(Notification.VISIBILITY_SECRET) is in, so it is gone from the lock screen entirely.
  • Copy. "Bluetooth active" / "Running in the background." and the channel renamed to "Bluetooth background use" — neither names the app's purpose nor the word relay, in the shade or in system settings. This is the right call and the comment explaining why is the part that will stop someone helpfully "improving" the string later.
  • Duplicate android.app.Notification import. Gone.
  • The Android 14 startForeground permission question. Confirmed: both startAdvertising and startScanning wrap their bodies in withPermissions(promise) (BleMeshModule.kt:410, :428), and startForegroundServiceIfNeeded() runs inside that block. A background start cannot reach it without BLUETOOTH_SCAN/ADVERTISE/CONNECT already granted. startForegroundServiceIfNeeded also catches Throwable and downgrades to an advisory error rather than taking the process with it, which is the correct failure posture for a best-effort feature.

Not addressed — this is what blocks

Copilot's third and fourth comments, on the iOS restore path. willRestoreState re-arms the radio unconditionally on both managers:

// BleMeshModule.swift:1002
if !wantScanning { wantScanning = true }
applyCentralState()

// BleMeshModule.swift:1179
if !wantAdvertising { wantAdvertising = true }
applyPeripheralState()

ensureManagers() (:456) constructs both CBCentralManager and CBPeripheralManager with restore identifiers whenever either role is needed. So the two are coupled: a restoration event fired for the peripheral role also restores the central, which then unconditionally sets wantScanning = true and starts a scan the user's session never asked for. It works in the direction that matters for the feature, but it also works in a direction nobody chose.

For most apps that is a battery complaint. Here it is a threat-model one, for the same reason the notification was: this runs before JS is up, so the native layer decides the radio is on before any of the app's own radio state is loaded, and the user has no UI in front of them to notice. "Radio off" has to be a control that stays off; a restore path that can silently re-arm it is the same class of problem as a lock-screen tell — a phone doing something in a pocket that its owner did not agree to.

The fix is small — the dict tells you which role was actually live:

func centralManager(_ manager: CBCentralManager, willRestoreState dict: [String: Any]) {
  if dict[CBCentralManagerRestoredStateScanServicesKey] != nil { wantScanning = true }
  applyCentralState()
}

func peripheralManager(_ manager: CBPeripheralManager, willRestoreState dict: [String: Any]) {
  if dict[CBPeripheralManagerRestoredStateAdvertisementDataKey] != nil { wantAdvertising = true }
  applyPeripheralState()
}

That restores exactly what was running and nothing else. If you also want belt-and-braces against the "user had switched the radio off" case, gate on persisted intent as well — but the dict check alone closes the cross-role contamination, which is the part that is provably wrong today.

New: the disclosure only exists in English

This extends @ni5arga's point 3 rather than repeating it. settings.radioOnDetail was updated in en.ts to say background relaying is best-effort and that Android shows a persistent notification. The other five catalogs still carry their original translated string:

bn: 'পৌঁছানো যাচ্ছে এবং অচেনা মানুষের সিল করা বার্তা এগিয়ে দিচ্ছে।'
hi: 'संपर्क योग्य है और अनजान लोगों के सीलबंद संदेश आगे पहुँचा रहा है।'

No best-effort caveat, no mention of the notification. This is different from settings.limitationBackground, where you shipped an English fallback and said so in the PR body — an untranslated English sentence at least looks untranslated. A fully fluent Hindi sentence that omits the disclosure reads as complete and authoritative, and the reader has no signal that anything is missing. i18n:check passes because it verifies key presence, not staleness, so nothing catches this.

Given who this app is localised for, the users least likely to get the disclosure are the ones most likely to be standing in the situation it describes. I would take an English fallback on radioOnDetail too over a stale confident translation, and flag all six for a translation pass — your call which, but it should not ship as-is.

Copilot's manifest comment — I think this one is a false positive

It flagged android:name=".BleMeshService" resolving against the app package. modules/ble-mesh/android/build.gradle declares namespace "expo.modules.blemesh", and AGP resolves relative class names in a library manifest against that namespace before merging into the app. So it should expand to expo.modules.blemesh.BleMeshService correctly. Not asking you to change it — but since this is one of the things only a real device proves, worth confirming against the merged manifest (app/build/intermediates/merged_manifests/) on the same hardware pass where you check the FGS actually starts.

Overall

The approach is sound and the disclosure instinct throughout is right — this is the honest version of the feature and it reads like it. Blocking on the iOS restore gate and the i18n disclosure gap. Neither is a large change; ping me when they are in and I will re-review promptly. @ni5arga will need to clear the standing changes-requested before this can merge.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[High] Mesh relay requires app in foreground (esp. iOS)

4 participants