diff --git a/README.md b/README.md
index b5edf4f..c7d4c08 100644
--- a/README.md
+++ b/README.md
@@ -126,7 +126,7 @@ The migration is an upgrade rather than a workaround. Nearby gave us no control
The whole point is one thing working, so test exactly that first.
-**Setup.** Install the build on two phones (one iPhone and one Android is the interesting case). Grant the Bluetooth permissions on Android (and Location on Android 11 and below); Bluetooth on iOS. Keep the app in the **foreground** on both — background BLE is an unsolved problem, see the threat model.
+**Setup.** Install the build on two phones (one iPhone and one Android is the interesting case). Grant the Bluetooth permissions on Android (and Location on Android 11 and below); Bluetooth on iOS. Keep the app in the **foreground** on both for reliable relaying — background BLE is only best-effort, see the threat model.
**1. Cut the network for real.**
diff --git a/app.json b/app.json
index 4616fc0..1309cf1 100644
--- a/app.json
+++ b/app.json
@@ -24,6 +24,10 @@
"NSBonjourServices": [
"_nearby._tcp",
"_nearby._udp"
+ ],
+ "UIBackgroundModes": [
+ "bluetooth-central",
+ "bluetooth-peripheral"
]
},
"bundleIdentifier": "com.anonymous.protestchat"
diff --git a/docs/THREAT-MODEL.md b/docs/THREAT-MODEL.md
index bf9dc60..1647036 100644
--- a/docs/THREAT-MODEL.md
+++ b/docs/THREAT-MODEL.md
@@ -96,7 +96,7 @@ Residuals (see `docs/FORWARD-SECRECY.md`): no Double Ratchet / post-compromise h
What remains unproven: whether stopping and restarting advertising actually prompts each OS to re-randomise the link-layer BLE address. That is the only lever an app has, and it is not a guarantee. **Someone with a BLE sniffer and two phones could settle this in an afternoon, and it would be a genuinely valuable contribution.**
3. **Sybil resistance.** Identities are free, so per-identity rate limits are worthless. One device can present as hundreds and flood the mesh. Candidates: proof-of-work on send, web-of-trust relay budgets. Unsolved.
-4. **iOS background operation.** iOS suspends BLE aggressively. A relay that only works with the screen on is a much weaker relay.
+4. **Background relaying — partially addressed, limits remain.** iOS and Android both suspend BLE work when the app is not foregrounded, which is the single biggest reason a pocket phone stops relaying. We now declare the iOS `bluetooth-central` and `bluetooth-peripheral` background modes, pass CoreBluetooth state-restoration identifiers, and run an Android foreground service while the radio is on. These are best-effort: iOS still throttles scan and advertise rates, background modes are not a guarantee, and Android OEMs differ widely in how aggressively they kill a foreground service. **Keep the app open for reliable relaying.**
5. **Duress / decoy mode.** Panic wipe exists; a duress PIN opening a plausible decoy does not.
6. **Reproducible builds.**
7. **Channel passphrase strength.** No strength meter, no enforced minimum. At a protest passphrases spread by shouting and will be weak.
diff --git a/modules/ble-mesh/README.md b/modules/ble-mesh/README.md
index 1f1624c..d64913e 100644
--- a/modules/ble-mesh/README.md
+++ b/modules/ble-mesh/README.md
@@ -334,9 +334,13 @@ Both `CBCentralManager` and `CBPeripheralManager` are constructed lazily on the
first `start*` call, because constructing either is what triggers the permission
prompt and an app that has not yet asked to use the radio should not be prompting.
-Background operation is **not** configured (no `UIBackgroundModes`). iOS suspends
-BLE aggressively and background mesh relaying is open problem #4 in the threat
-model; keep the app in the foreground.
+Background operation is **best-effort**. iOS has `bluetooth-central` and
+`bluetooth-peripheral` `UIBackgroundModes` plus CoreBluetooth state-restoration
+identifiers, and Android runs a `connectedDevice` foreground service while the
+radio is on. These improve the odds that a pocketed phone continues to relay,
+but they are not guarantees: iOS still throttles scan/advertise rates when
+backgrounded, and Android OEMs vary widely in how aggressively they kill a
+foreground service. Keep the app open for reliable relaying.
## Platform semantics the TS layer must paper over
diff --git a/modules/ble-mesh/android/src/main/AndroidManifest.xml b/modules/ble-mesh/android/src/main/AndroidManifest.xml
index a244fe5..1745580 100644
--- a/modules/ble-mesh/android/src/main/AndroidManifest.xml
+++ b/modules/ble-mesh/android/src/main/AndroidManifest.xml
@@ -36,7 +36,20 @@
android:name="android.permission.ACCESS_FINE_LOCATION"
android:maxSdkVersion="30" />
+
+
+
+
+
+
+
+
+
diff --git a/modules/ble-mesh/android/src/main/java/expo/modules/blemesh/BleMeshModule.kt b/modules/ble-mesh/android/src/main/java/expo/modules/blemesh/BleMeshModule.kt
index bed9c46..524c176 100644
--- a/modules/ble-mesh/android/src/main/java/expo/modules/blemesh/BleMeshModule.kt
+++ b/modules/ble-mesh/android/src/main/java/expo/modules/blemesh/BleMeshModule.kt
@@ -379,6 +379,7 @@ class BleMeshModule : Module() {
private var lastPublishedState: String? = null
private var housekeepingScheduled = false
private var stateReceiver: BroadcastReceiver? = null
+ private var serviceStarted = false
private val random = SecureRandom()
@@ -417,6 +418,7 @@ class BleMeshModule : Module() {
scheduleRotation()
scheduleHousekeeping()
startAdvertisingNow()
+ startForegroundServiceIfNeeded()
promise.resolve(null)
}
}
@@ -431,6 +433,7 @@ class BleMeshModule : Module() {
if (!ensureAdapter(promise)) return@post
scheduleHousekeeping()
startScanningNow()
+ startForegroundServiceIfNeeded()
promise.resolve(null)
}
}
@@ -604,6 +607,34 @@ class BleMeshModule : Module() {
// Radio lifecycle
// -------------------------------------------------------------------------
+ private fun startForegroundServiceIfNeeded() {
+ if (serviceStarted) return
+ val context = appContext.reactContext ?: return
+ val intent = Intent(context, BleMeshService::class.java)
+ try {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ context.startForegroundService(intent)
+ } else {
+ @Suppress("DEPRECATION")
+ context.startService(intent)
+ }
+ serviceStarted = true
+ } catch (e: Throwable) {
+ // Foreground service is best-effort. If the OS refuses it, the radio still
+ // works while the app is foregrounded; emit an advisory error so the UI can
+ // tell the user background relay is not available.
+ emitError(BleErrorCode.INTERNAL, "Could not start mesh foreground service: ${describe(e)}")
+ }
+ }
+
+ private fun stopForegroundService() {
+ if (!serviceStarted) return
+ val context = appContext.reactContext ?: return
+ val intent = Intent(context, BleMeshService::class.java)
+ runCatching { context.stopService(intent) }
+ serviceStarted = false
+ }
+
private fun ensureAdapter(promise: Promise?): Boolean {
if (adapter == null) {
val context = appContext.reactContext
@@ -1122,6 +1153,7 @@ class BleMeshModule : Module() {
stopAdvertisingNow()
stopScanningNow()
+ stopForegroundService()
val announced = links.values.mapNotNull { if (it.announced) it.remoteTag else null }.distinct()
for (link in links.values) {
diff --git a/modules/ble-mesh/android/src/main/java/expo/modules/blemesh/BleMeshService.kt b/modules/ble-mesh/android/src/main/java/expo/modules/blemesh/BleMeshService.kt
new file mode 100644
index 0000000..96e90f0
--- /dev/null
+++ b/modules/ble-mesh/android/src/main/java/expo/modules/blemesh/BleMeshService.kt
@@ -0,0 +1,99 @@
+package expo.modules.blemesh
+
+import android.app.Notification
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.app.Service
+import android.content.Context
+import android.content.Intent
+import android.os.Build
+import android.os.IBinder
+
+/**
+ * Foreground service that keeps the mesh radio process alive while the app is
+ * backgrounded.
+ *
+ * Android 8+ (API 26) throttles or stops background BLE work unless the app holds
+ * a foreground service. This service does not itself talk to the radio — the
+ * BleMeshModule does — it only creates a persistent notification so the OS
+ * knows the process is doing user-visible work. The module starts it when
+ * advertising or scanning begins and stops it in teardown().
+ *
+ * The notification is deliberately discreet: it does not appear on the lock
+ * screen and it does not describe what the app is doing, because a visible
+ * lock-screen tell would defeat the purpose of a privacy-first mesh app. The
+ * OS still shows the app label in the notification shade (unavoidable), but
+ * that is only visible to someone who already has the unlocked phone.
+ */
+class BleMeshService : Service() {
+
+ override fun onCreate() {
+ super.onCreate()
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ val channel = NotificationChannel(
+ CHANNEL_ID,
+ "Bluetooth background use",
+ NotificationManager.IMPORTANCE_LOW
+ ).apply {
+ description = "Used while the app is backgrounded."
+ }
+ val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+ notificationManager.createNotificationChannel(channel)
+ }
+ }
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ val notification = buildNotification()
+ startForeground(FOREGROUND_ID, notification)
+ return START_STICKY
+ }
+
+ private fun buildNotification(): Notification {
+ val builder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ Notification.Builder(this, CHANNEL_ID)
+ } else {
+ @Suppress("DEPRECATION")
+ Notification.Builder(this)
+ }
+
+ builder
+ .setContentTitle("Bluetooth active")
+ .setContentText("Running in the background.")
+ .setSmallIcon(android.R.drawable.stat_sys_data_bluetooth)
+ .setOngoing(true)
+ .setWhen(System.currentTimeMillis())
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
+ builder.setCategory(Notification.CATEGORY_SERVICE)
+ }
+
+ // Never show this notification on the lock screen. A persistent, self-
+ // describing lock-screen tell would be more damaging than the benefit of
+ // background relaying.
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
+ builder.setVisibility(Notification.VISIBILITY_SECRET)
+ }
+
+ // Tapping the notification returns the user to the app rather than doing nothing.
+ val launchIntent = packageManager.getLaunchIntentForPackage(packageName)
+ if (launchIntent != null) {
+ val pendingIntent = PendingIntent.getActivity(
+ this,
+ 0,
+ launchIntent,
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
+ )
+ builder.setContentIntent(pendingIntent)
+ }
+
+ return builder.build()
+ }
+
+ override fun onBind(intent: Intent?): IBinder? = null
+
+ companion object {
+ private const val CHANNEL_ID = "protestchat_mesh_relay"
+ private const val FOREGROUND_ID = 1
+ }
+}
diff --git a/modules/ble-mesh/ios/BleMeshModule.swift b/modules/ble-mesh/ios/BleMeshModule.swift
index 87ab744..1103293 100644
--- a/modules/ble-mesh/ios/BleMeshModule.swift
+++ b/modules/ble-mesh/ios/BleMeshModule.swift
@@ -461,14 +461,20 @@ private final class BleMeshRadio: NSObject {
central = CBCentralManager(
delegate: self,
queue: queue,
- options: [CBCentralManagerOptionShowPowerAlertKey: true]
+ options: [
+ CBCentralManagerOptionShowPowerAlertKey: true,
+ CBCentralManagerOptionRestoreIdentifierKey: "org.protestchat.central",
+ ]
)
}
if peripheralManager == nil {
peripheralManager = CBPeripheralManager(
delegate: self,
queue: queue,
- options: [CBPeripheralManagerOptionShowPowerAlertKey: false]
+ options: [
+ CBPeripheralManagerOptionShowPowerAlertKey: false,
+ CBPeripheralManagerOptionRestoreIdentifierKey: "org.protestchat.peripheral",
+ ]
)
}
}
@@ -986,6 +992,18 @@ extension BleMeshRadio: CBCentralManagerDelegate {
applyCentralState()
}
+ func centralManager(_ manager: CBCentralManager, willRestoreState dict: [String: Any]) {
+ // iOS relaunched the app to handle a BLE event. If we were scanning when the
+ // OS terminated us, resume scanning so the mesh can discover peers again.
+ // Reconnections happen through fresh discovery; restoring connected
+ // peripherals is deliberately left to the next scan cycle to keep this path
+ // simple and safe.
+ if !wantScanning {
+ wantScanning = true
+ }
+ applyCentralState()
+ }
+
func centralManager(
_ manager: CBCentralManager,
didDiscover peripheral: CBPeripheral,
@@ -1154,6 +1172,15 @@ extension BleMeshRadio: CBPeripheralManagerDelegate {
applyPeripheralState()
}
+ func peripheralManager(_ manager: CBPeripheralManager, willRestoreState dict: [String: Any]) {
+ // iOS relaunched the app to handle a BLE event. If we were advertising when
+ // the OS terminated us, resume advertising so peers can still find us.
+ if !wantAdvertising {
+ wantAdvertising = true
+ }
+ applyPeripheralState()
+ }
+
func peripheralManagerDidStartAdvertising(
_ manager: CBPeripheralManager,
error: Error?
diff --git a/src/app/settings.tsx b/src/app/settings.tsx
index 00bf899..bd837ac 100644
--- a/src/app/settings.tsx
+++ b/src/app/settings.tsx
@@ -46,6 +46,7 @@ export default function SettingsScreen() {
copy('settings.limitationLocation'),
copy('settings.limitationShoulder'),
copy('settings.limitationContact'),
+ copy('settings.limitationBackground'),
];
const runWipe = async () => {
diff --git a/src/i18n/bn.ts b/src/i18n/bn.ts
index f1911e1..44fc767 100644
--- a/src/i18n/bn.ts
+++ b/src/i18n/bn.ts
@@ -137,6 +137,7 @@ export const bn: Catalog = {
'settings.limitationLocation': 'আপনার অবস্থান শনাক্ত হওয়া। Bluetooth একটি রেডিও; সঠিক যন্ত্র থাকা কেউ বুঝতে পারে এখানে একটি ফোন পাঠাচ্ছে, যদিও বার্তা পড়তে পারে না।',
'settings.limitationShoulder': 'পাশে দাঁড়ানো কেউ আপনার স্ক্রিন পড়া।',
'settings.limitationContact': 'সামনাসামনি যাচাই না-করা যোগাযোগটি অন্য কেউ হওয়া।',
+ 'settings.limitationBackground': 'Background relaying. iOS and Android may pause Bluetooth when the app is not on screen; a phone in a pocket may stop relaying. Keep the app open for reliable meshing.',
'settings.auditWarning': 'এই সফটওয়্যার স্বাধীনভাবে অডিট করা হয়নি। একে উপকারী ভাবুন, নিশ্চয়তা নয়। নিরাপত্তা এর ওপর নির্ভর করলে ধরে নিন দৃঢ়প্রতিজ্ঞ রাষ্ট্রীয় প্রতিপক্ষ তবুও জানতে পারে আপনি সেখানে ছিলেন এবং সম্প্রচার করছিলেন।',
'settings.openSourceA11y': '100 শতাংশ ওপেন সোর্স। GitHub-এ সোর্স কোড খোলে।',
'settings.openSource': '100% ওপেন সোর্স',
diff --git a/src/i18n/en.ts b/src/i18n/en.ts
index 11c9336..79c90fa 100644
--- a/src/i18n/en.ts
+++ b/src/i18n/en.ts
@@ -184,7 +184,7 @@ export const en = {
'settings.name': 'Your name',
'settings.nameHint': 'Stays on this phone. It is never broadcast — nearby devices see only a rotating random code, never this name — and it is shown to you, not to them.',
'settings.meshRadio': 'Mesh radio',
- 'settings.radioOnDetail': 'Reachable, and relaying sealed messages for people you will never meet.',
+ 'settings.radioOnDetail': 'Reachable while this screen is open. Background relaying is best-effort: iOS and Android may pause Bluetooth when the app is not in the foreground, so keep protestchat open for reliable relay. On Android, the radio shows a persistent notification while the app is backgrounded.',
'settings.radioOffDetail': 'Nothing in, nothing out. You are also not relaying for anyone else.',
'settings.panicWipe': 'Panic wipe',
'settings.panicDetail': 'Erases every message, contact and key on this phone immediately, and gives you a fresh identity. There is no undo and no backup.',
@@ -198,6 +198,7 @@ export const en = {
'settings.limitationLocation': 'Being physically located. Bluetooth is a radio; anyone with the right equipment can tell that a phone here is transmitting, even though they cannot read it.',
'settings.limitationShoulder': 'Someone standing next to you reading your screen.',
'settings.limitationContact': 'A contact you never verified in person turning out to be someone else.',
+ 'settings.limitationBackground': 'Background relaying. iOS and Android may pause Bluetooth when the app is not on screen; a phone in a pocket may stop relaying. Keep the app open for reliable meshing.',
'settings.auditWarning': 'This software has not been independently audited. Treat it as useful, not as guaranteed. If your safety depends on it, assume a determined state adversary can still learn that you were present and transmitting.',
'settings.openSourceA11y': '100 percent open source. Opens the source code on GitHub.',
'settings.openSource': '100% open source',
diff --git a/src/i18n/hi.ts b/src/i18n/hi.ts
index 24773b6..61ebda6 100644
--- a/src/i18n/hi.ts
+++ b/src/i18n/hi.ts
@@ -183,6 +183,7 @@ export const hi: Catalog = {
'settings.limitationLocation': 'आपकी भौतिक जगह का पता लगना। Bluetooth एक रेडियो है; सही उपकरण वाला कोई भी जान सकता है कि यहाँ कोई फ़ोन प्रसारण कर रहा है, भले ही वह उसे पढ़ न सके।',
'settings.limitationShoulder': 'आपके पास खड़ा कोई व्यक्ति आपकी स्क्रीन पढ़ रहा हो।',
'settings.limitationContact': 'जिस संपर्क को आपने आमने-सामने सत्यापित नहीं किया, उसका कोई और निकलना।',
+ 'settings.limitationBackground': 'Background relaying. iOS and Android may pause Bluetooth when the app is not on screen; a phone in a pocket may stop relaying. Keep the app open for reliable meshing.',
'settings.auditWarning': 'इस सॉफ़्टवेयर का स्वतंत्र ऑडिट नहीं हुआ है। इसे उपयोगी मानें, गारंटी नहीं। यदि आपकी सुरक्षा इस पर निर्भर है, तो मानें कि एक दृढ़ राज्य विरोधी फिर भी जान सकता है कि आप वहाँ मौजूद थे और प्रसारण कर रहे थे।',
'settings.openSourceA11y': '100 प्रतिशत ओपन सोर्स। GitHub पर सोर्स कोड खोलता है।',
'settings.openSource': '100% ओपन सोर्स',
diff --git a/src/i18n/mr.ts b/src/i18n/mr.ts
index 715e79a..2dcfc58 100644
--- a/src/i18n/mr.ts
+++ b/src/i18n/mr.ts
@@ -134,6 +134,7 @@ export const mr: Catalog = {
'settings.limitationLocation': 'तुमचे प्रत्यक्ष स्थान शोधले जाणे. Bluetooth हा रेडिओ आहे; योग्य उपकरण असलेला कोणीही येथे फोन प्रसारित करत आहे हे ओळखू शकतो, जरी संदेश वाचता येत नसले तरी.',
'settings.limitationShoulder': 'शेजारी उभी व्यक्ती तुमची स्क्रीन वाचणे.',
'settings.limitationContact': 'प्रत्यक्ष पडताळणी न केलेला संपर्क दुसराच कोणी असणे.',
+ 'settings.limitationBackground': 'Background relaying. iOS and Android may pause Bluetooth when the app is not on screen; a phone in a pocket may stop relaying. Keep the app open for reliable meshing.',
'settings.auditWarning': 'या सॉफ्टवेअरचे स्वतंत्र ऑडिट झालेले नाही. ते उपयोगी समजा, हमी नाही. तुमची सुरक्षितता यावर अवलंबून असल्यास, ठाम सरकारी विरोधकाला तुम्ही तिथे होतात आणि प्रसारण करत होतात हे कळू शकते असे गृहीत धरा.',
'settings.openSourceA11y': '100 टक्के ओपन सोर्स. GitHub वर सोर्स कोड उघडते.',
'settings.openSource': '100% ओपन सोर्स',
diff --git a/src/i18n/ta.ts b/src/i18n/ta.ts
index 184685c..2f944c2 100644
--- a/src/i18n/ta.ts
+++ b/src/i18n/ta.ts
@@ -133,6 +133,7 @@ export const ta: Catalog = {
'settings.limitationLocation': 'உங்கள் இருப்பிடம் கண்டறியப்படுவது. Bluetooth ஒரு ரேடியோ; சரியான கருவி உள்ளவர் இங்கே ஒரு தொலைபேசி ஒலிபரப்புவதை அறிய முடியும், செய்தியைப் படிக்க முடியாவிட்டாலும்.',
'settings.limitationShoulder': 'உங்கள் அருகில் நிற்பவர் திரையைப் படிப்பது.',
'settings.limitationContact': 'நேரில் சரிபார்க்காத தொடர்பு வேறொருவராக இருப்பது.',
+ 'settings.limitationBackground': 'Background relaying. iOS and Android may pause Bluetooth when the app is not on screen; a phone in a pocket may stop relaying. Keep the app open for reliable meshing.',
'settings.auditWarning': 'இந்த மென்பொருள் சுயாதீனமாகத் தணிக்கை செய்யப்படவில்லை. பயனுள்ளது என்று கருதுங்கள், உத்தரவாதம் அல்ல. உங்கள் பாதுகாப்பு இதைச் சார்ந்திருந்தால், உறுதியான அரசு எதிரி நீங்கள் அங்கு இருந்து ஒலிபரப்பியதை இன்னும் அறிய முடியும் என்று கருதுங்கள்.',
'settings.openSourceA11y': '100 சதவீதம் திறந்த மூலமாகும். GitHub-ல் மூலக் குறியீட்டைத் திறக்கும்.',
'settings.openSource': '100% திறந்த மூலம்',
diff --git a/src/i18n/te.ts b/src/i18n/te.ts
index 25f75b5..4da6a29 100644
--- a/src/i18n/te.ts
+++ b/src/i18n/te.ts
@@ -131,6 +131,7 @@ export const te: Catalog = {
'settings.limitationLocation': 'మీ భౌతిక స్థానం గుర్తించడం. Bluetooth ఒక రేడియో; సరైన పరికరాలు ఉన్నవారు ఇక్కడ ఫోన్ ప్రసారం చేస్తోందని తెలుసుకోగలరు, సందేశాన్ని చదవలేకపోయినా.',
'settings.limitationShoulder': 'మీ పక్కన నిలబడినవారు స్క్రీన్ చదవడం.',
'settings.limitationContact': 'మీరు ఎదురెదురుగా ధృవీకరించని కాంటాక్ట్ వేరొకరిగా ఉండటం.',
+ 'settings.limitationBackground': 'Background relaying. iOS and Android may pause Bluetooth when the app is not on screen; a phone in a pocket may stop relaying. Keep the app open for reliable meshing.',
'settings.auditWarning': 'ఈ సాఫ్ట్వేర్ స్వతంత్రంగా ఆడిట్ చేయబడలేదు. ఉపయోగకరమని భావించండి, హామీగా కాదు. మీ భద్రత దీనిపై ఆధారపడితే, దృఢమైన ప్రభుత్వ ప్రత్యర్థి మీరు అక్కడ ఉండి ప్రసారం చేశారని ఇంకా తెలుసుకోగలడని భావించండి.',
'settings.openSourceA11y': '100 శాతం ఓపెన్ సోర్స్. GitHubలో సోర్స్ కోడ్ను తెరుస్తుంది.',
'settings.openSource': '100% ఓపెన్ సోర్స్',