From 888d8e5e7195ccbfcb8822e449cd1e9ee35203d5 Mon Sep 17 00:00:00 2001 From: natyavidhan Date: Tue, 21 Jul 2026 13:11:12 +0530 Subject: [PATCH 1/3] feat: best-effort background BLE relaying (#5) 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 #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. --- README.md | 2 +- app.json | 4 + docs/THREAT-MODEL.md | 2 +- modules/ble-mesh/README.md | 10 ++- .../android/src/main/AndroidManifest.xml | 13 +++ .../expo/modules/blemesh/BleMeshModule.kt | 32 +++++++ .../expo/modules/blemesh/BleMeshService.kt | 87 +++++++++++++++++++ modules/ble-mesh/ios/BleMeshModule.swift | 31 ++++++- src/app/settings.tsx | 1 + 9 files changed, 175 insertions(+), 7 deletions(-) create mode 100644 modules/ble-mesh/android/src/main/java/expo/modules/blemesh/BleMeshService.kt diff --git a/README.md b/README.md index 74a4dd6..35953af 100644 --- a/README.md +++ b/README.md @@ -124,7 +124,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..db913d1 --- /dev/null +++ b/modules/ble-mesh/android/src/main/java/expo/modules/blemesh/BleMeshService.kt @@ -0,0 +1,87 @@ +package expo.modules.blemesh + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.Notification +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(). + */ +class BleMeshService : Service() { + + override fun onCreate() { + super.onCreate() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel( + CHANNEL_ID, + "Mesh relay", + NotificationManager.IMPORTANCE_LOW + ).apply { + description = "Keeps protestchat relaying messages while the app is in the background." + } + 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("protestchat relay active") + .setContentText("Relaying sealed messages for nearby phones 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) + } + + // 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 () => { From 9b6398c4ff66178081455ff3d302732a4d48dc18 Mon Sep 17 00:00:00 2001 From: natyavidhan Date: Tue, 21 Jul 2026 13:43:05 +0530 Subject: [PATCH 2/3] i18n: add background-relaying limitation and updated radio-on detail --- src/i18n/bn.ts | 1 + src/i18n/en.ts | 3 ++- src/i18n/hi.ts | 1 + src/i18n/mr.ts | 1 + src/i18n/ta.ts | 1 + src/i18n/te.ts | 1 + 6 files changed, 7 insertions(+), 1 deletion(-) 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..8a44632 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.', '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% ఓపెన్ సోర్స్', From 989967fce59acb0531f69de4bed151de8989c7b8 Mon Sep 17 00:00:00 2001 From: natyavidhan Date: Tue, 21 Jul 2026 21:12:38 +0530 Subject: [PATCH 3/3] review: make Android foreground-service notification discreet (#5) 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. --- .../expo/modules/blemesh/BleMeshService.kt | 22 ++++++++++++++----- src/i18n/en.ts | 2 +- 2 files changed, 18 insertions(+), 6 deletions(-) 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 index db913d1..96e90f0 100644 --- 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 @@ -3,7 +3,6 @@ package expo.modules.blemesh import android.app.Notification import android.app.NotificationChannel import android.app.NotificationManager -import android.app.Notification import android.app.PendingIntent import android.app.Service import android.content.Context @@ -20,6 +19,12 @@ import android.os.IBinder * 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() { @@ -28,10 +33,10 @@ class BleMeshService : Service() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { val channel = NotificationChannel( CHANNEL_ID, - "Mesh relay", + "Bluetooth background use", NotificationManager.IMPORTANCE_LOW ).apply { - description = "Keeps protestchat relaying messages while the app is in the background." + description = "Used while the app is backgrounded." } val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager notificationManager.createNotificationChannel(channel) @@ -53,8 +58,8 @@ class BleMeshService : Service() { } builder - .setContentTitle("protestchat relay active") - .setContentText("Relaying sealed messages for nearby phones in the background.") + .setContentTitle("Bluetooth active") + .setContentText("Running in the background.") .setSmallIcon(android.R.drawable.stat_sys_data_bluetooth) .setOngoing(true) .setWhen(System.currentTimeMillis()) @@ -63,6 +68,13 @@ class BleMeshService : Service() { 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) { diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 8a44632..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 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.', + '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.',