feat: best-effort background BLE relaying (#5) - #47
Conversation
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.
There was a problem hiding this comment.
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.
| <service | ||
| android:name=".BleMeshService" | ||
| android:enabled="true" | ||
| android:exported="false" | ||
| android:foregroundServiceType="connectedDevice" /> |
| import android.app.Notification | ||
| import android.app.NotificationChannel | ||
| import android.app.NotificationManager | ||
| import android.app.Notification |
| if !wantScanning { | ||
| wantScanning = true | ||
| } | ||
| applyCentralState() |
| if !wantAdvertising { | ||
| wantAdvertising = true | ||
| } | ||
| applyPeripheralState() | ||
| } |
ni5arga
left a comment
There was a problem hiding this comment.
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:
setVisibility(Notification.VISIBILITY_SECRET)so it does not appear on the lock screen at all — only in the shade once the phone is unlocked.- 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.
- 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.ktimportsandroid.app.Notificationtwice — drop the duplicate.connectedDeviceforeground services on Android 14+ require a connected-device runtime permission (BLUETOOTH_CONNECT/SCAN) to be held whenstartForegroundis 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.
) 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.
|
@natyavidhan — you pushed Addressed, verified
Not addressed — this is what blocksCopilot's third and fourth comments, on the iOS restore path. // BleMeshModule.swift:1002
if !wantScanning { wantScanning = true }
applyCentralState()
// BleMeshModule.swift:1179
if !wantAdvertising { wantAdvertising = true }
applyPeripheralState()
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 EnglishThis extends @ni5arga's point 3 rather than repeating it. No best-effort caveat, no mention of the notification. This is different from 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 Copilot's manifest comment — I think this one is a false positiveIt flagged OverallThe 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. |
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):bluetooth-centralandbluetooth-peripheralUIBackgroundModes.CBCentralManagerandCBPeripheralManager.centralManager(_:willRestoreState:)andperipheralManager(_:willRestoreState:)to resume scanning/advertising after the OS terminates the app.Android (
modules/ble-mesh/android/src/main/AndroidManifest.xml,BleMeshModule.kt, newBleMeshService.kt):connectedDeviceforeground service that shows a persistent notification while the radio is on.FOREGROUND_SERVICEandFOREGROUND_SERVICE_CONNECTED_DEVICEpermissions.startAdvertising/startScanningand stops inteardown().In-app disclosure (
src/app/settings.tsx,src/i18n/*.ts):settings.radioOnDetailto state that background relaying is best-effort.settings.limitationBackgroundto the honest-limitations block.Catalogtype remains satisfied.Docs (
docs/THREAT-MODEL.md,modules/ble-mesh/README.md,README.md):Why this is only best-effort
UIBackgroundModesare not a guarantee.Test plan
npm run typecheckpasses except for pre-existingexpo-localizationmodule-not-found errors unrelated to this PR.npm testpasses (228/228).