diff --git a/.skills/compose-ui/strings-index.txt b/.skills/compose-ui/strings-index.txt
index a792ad34d..fbb067f60 100644
--- a/.skills/compose-ui/strings-index.txt
+++ b/.skills/compose-ui/strings-index.txt
@@ -110,6 +110,7 @@ blue
bluetooth
bluetooth_available_devices
bluetooth_config
+bluetooth_disabled
bluetooth_enabled
bluetooth_feature_config
bluetooth_feature_config_description
@@ -117,6 +118,7 @@ bluetooth_feature_discovery
bluetooth_feature_discovery_description
bluetooth_permission
bold_heading
+bonding_failed_permissions
bottom_nav_settings
broadcast_interval
busy_noise_floor
@@ -125,6 +127,8 @@ buzzer_gpio
calculating
call_sign
call_sign_summary
+camera_permission
+camera_permission_rationale
cancel
cancel_reply
canned_message
@@ -653,6 +657,7 @@ gps_en_gpio
gps_mode
gps_receive_gpio
gps_transmit_gpio
+grant_permission
green
hardware
hardware_model
@@ -1011,10 +1016,13 @@ one_month
one_week
one_wire_temperature
only_favorites
+### OPEN ###
+open_bluetooth_settings
open_compass
open_settings
open_source_description
open_source_libraries
+open_wifi_settings
options
orient_north
### OUTPUT ###
@@ -1460,6 +1468,7 @@ url_must_contain_placeholders
url_template
url_template_hint
usb
+usb_permission_denied
### USE ###
use_12h_format
use_homoglyph_characters_encoding
@@ -1532,6 +1541,7 @@ wifi_provisioning
wifi_qr_code_error
wifi_qr_code_scan
wifi_rssi_threshold_defaults_to_80
+wifi_unavailable
### WIND ###
wind
wind_direction
diff --git a/androidApp/build.gradle.kts b/androidApp/build.gradle.kts
index ab72f7429..6d357ddbf 100644
--- a/androidApp/build.gradle.kts
+++ b/androidApp/build.gradle.kts
@@ -265,7 +265,6 @@ dependencies {
implementation(libs.koin.compose.viewmodel)
implementation(libs.koin.androidx.workmanager)
implementation(libs.koin.annotations)
- implementation(libs.accompanist.permissions)
implementation(libs.kermit)
implementation(libs.kotlinx.datetime)
diff --git a/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/MapView.kt b/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/MapView.kt
index 339dd574b..dba44146b 100644
--- a/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/MapView.kt
+++ b/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/MapView.kt
@@ -16,7 +16,6 @@
*/
package org.meshtastic.app.map
-import android.Manifest
import androidx.appcompat.content.res.AppCompatResources
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
@@ -68,8 +67,6 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import co.touchlab.kermit.Logger
-import com.google.accompanist.permissions.ExperimentalPermissionsApi
-import com.google.accompanist.permissions.rememberMultiplePermissionsState
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.StringResource
@@ -130,7 +127,9 @@ import org.meshtastic.core.ui.icon.Layers
import org.meshtastic.core.ui.icon.Lens
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.PinDrop
+import org.meshtastic.core.ui.util.PermissionStatus
import org.meshtastic.core.ui.util.formatAgo
+import org.meshtastic.core.ui.util.rememberLocationPermissionState
import org.meshtastic.core.ui.util.showToast
import org.meshtastic.feature.map.BaseMapViewModel.MapFilterState
import org.meshtastic.feature.map.LastHeardFilter
@@ -208,7 +207,6 @@ private fun cacheManagerCallback(onTaskComplete: () -> Unit, onTaskFailed: (Int)
* @param mapViewModel The [MapViewModel] providing data and state for the map.
* @param navigateToNodeDetails Callback to navigate to the details screen of a selected node.
*/
-@OptIn(ExperimentalPermissionsApi::class) // Added for Accompanist
@Suppress("CyclomaticComplexMethod", "LongMethod")
@Composable
fun MapView(
@@ -246,9 +244,8 @@ fun MapView(
val unknownText = stringResource(Res.string.unknown)
val nowText = stringResource(Res.string.now)
- // Accompanist permissions state for location
- val locationPermissionsState =
- rememberMultiplePermissionsState(permissions = listOf(Manifest.permission.ACCESS_FINE_LOCATION))
+ // Location permission state (native; recomputed on resume).
+ val locationPermission = rememberLocationPermissionState()
var triggerLocationToggleAfterPermission by remember { mutableStateOf(false) }
fun loadOnlineTileSourceBase(): ITileSource {
@@ -309,8 +306,8 @@ fun MapView(
}
// Effect to toggle MyLocation after permission is granted
- LaunchedEffect(locationPermissionsState.allPermissionsGranted) {
- if (locationPermissionsState.allPermissionsGranted && triggerLocationToggleAfterPermission) {
+ LaunchedEffect(locationPermission.isGranted) {
+ if (locationPermission.isGranted && triggerLocationToggleAfterPermission) {
map.toggleMyLocation()
triggerLocationToggleAfterPermission = false
}
@@ -637,11 +634,17 @@ fun MapView(
},
isLocationTrackingEnabled = myLocationOverlay != null,
onToggleLocationTracking = {
- if (locationPermissionsState.allPermissionsGranted) {
- map.toggleMyLocation()
- } else {
- triggerLocationToggleAfterPermission = true
- locationPermissionsState.launchMultiplePermissionRequest()
+ when {
+ locationPermission.isGranted -> map.toggleMyLocation()
+
+ // Permanently denied: the system won't prompt again, so send the user to settings.
+ locationPermission.status == PermissionStatus.PERMANENTLY_DENIED ->
+ locationPermission.openAppSettings()
+
+ else -> {
+ triggerLocationToggleAfterPermission = true
+ locationPermission.request()
+ }
}
},
)
diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt
index 6ca71eb6a..28f46cb35 100644
--- a/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt
+++ b/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt
@@ -18,7 +18,6 @@
package org.meshtastic.app.map
-import android.Manifest
import android.app.Activity
import android.content.Intent
import android.net.Uri
@@ -57,8 +56,6 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import co.touchlab.kermit.Logger
-import com.google.accompanist.permissions.ExperimentalPermissionsApi
-import com.google.accompanist.permissions.rememberMultiplePermissionsState
import com.google.android.gms.location.LocationCallback
import com.google.android.gms.location.LocationRequest
import com.google.android.gms.location.LocationResult
@@ -131,8 +128,10 @@ import org.meshtastic.core.ui.icon.Map
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.TripOrigin
import org.meshtastic.core.ui.theme.TracerouteColors
+import org.meshtastic.core.ui.util.PermissionStatus
import org.meshtastic.core.ui.util.formatAgo
import org.meshtastic.core.ui.util.formatPositionTime
+import org.meshtastic.core.ui.util.rememberLocationPermissionState
import org.meshtastic.feature.map.BaseMapViewModel.MapFilterState
import org.meshtastic.feature.map.LastHeardFilter
import org.meshtastic.feature.map.component.MapButton
@@ -177,7 +176,7 @@ private const val TRACEROUTE_OFFSET_METERS = 100.0
private const val TRACEROUTE_BOUNDS_PADDING_PX = 120
@Suppress("CyclomaticComplexMethod", "LongMethod")
-@OptIn(MapsComposeExperimentalApi::class, ExperimentalMaterial3Api::class, ExperimentalPermissionsApi::class)
+@OptIn(MapsComposeExperimentalApi::class, ExperimentalMaterial3Api::class)
@Composable
fun MapView(
modifier: Modifier = Modifier,
@@ -190,16 +189,15 @@ fun MapView(
val mapLayers by mapViewModel.mapLayers.collectAsStateWithLifecycle()
// --- Location permissions ---
- val locationPermissionsState =
- rememberMultiplePermissionsState(permissions = listOf(Manifest.permission.ACCESS_FINE_LOCATION))
+ val locationPermission = rememberLocationPermissionState()
var triggerLocationToggleAfterPermission by remember { mutableStateOf(false) }
// --- Location tracking ---
var isLocationTrackingEnabled by remember { mutableStateOf(false) }
var followPhoneBearing by remember { mutableStateOf(false) }
- LaunchedEffect(locationPermissionsState.allPermissionsGranted) {
- if (locationPermissionsState.allPermissionsGranted && triggerLocationToggleAfterPermission) {
+ LaunchedEffect(locationPermission.isGranted) {
+ if (locationPermission.isGranted && triggerLocationToggleAfterPermission) {
isLocationTrackingEnabled = true
triggerLocationToggleAfterPermission = false
}
@@ -280,8 +278,8 @@ fun MapView(
}
}
- LaunchedEffect(isLocationTrackingEnabled, locationPermissionsState.allPermissionsGranted) {
- if (isLocationTrackingEnabled && locationPermissionsState.allPermissionsGranted) {
+ LaunchedEffect(isLocationTrackingEnabled, locationPermission.isGranted) {
+ if (isLocationTrackingEnabled && locationPermission.isGranted) {
val locationRequest =
LocationRequest.Builder(Priority.PRIORITY_HIGH_ACCURACY, 5000L)
.setMinUpdateIntervalMillis(2000L)
@@ -529,7 +527,7 @@ fun MapView(
properties =
MapProperties(
mapType = effectiveGoogleMapType,
- isMyLocationEnabled = isLocationTrackingEnabled && locationPermissionsState.allPermissionsGranted,
+ isMyLocationEnabled = isLocationTrackingEnabled && locationPermission.isGranted,
),
onMapLongClick = { latLng ->
if (isMainMode && isConnected) {
@@ -695,14 +693,22 @@ fun MapView(
},
isLocationTrackingEnabled = isLocationTrackingEnabled,
onToggleLocationTracking = {
- if (locationPermissionsState.allPermissionsGranted) {
- isLocationTrackingEnabled = !isLocationTrackingEnabled
- if (!isLocationTrackingEnabled) {
- followPhoneBearing = false
+ when {
+ locationPermission.isGranted -> {
+ isLocationTrackingEnabled = !isLocationTrackingEnabled
+ if (!isLocationTrackingEnabled) {
+ followPhoneBearing = false
+ }
+ }
+
+ // Permanently denied: the system won't prompt again, so send the user to settings to recover.
+ locationPermission.status == PermissionStatus.PERMANENTLY_DENIED ->
+ locationPermission.openAppSettings()
+
+ else -> {
+ triggerLocationToggleAfterPermission = true
+ locationPermission.request()
}
- } else {
- triggerLocationToggleAfterPermission = true
- locationPermissionsState.launchMultiplePermissionRequest()
}
},
bearing = cameraPositionState.position.bearing,
diff --git a/androidApp/src/main/AndroidManifest.xml b/androidApp/src/main/AndroidManifest.xml
index 94e3f1d55..03038964a 100644
--- a/androidApp/src/main/AndroidManifest.xml
+++ b/androidApp/src/main/AndroidManifest.xml
@@ -56,7 +56,7 @@
Android 17 (API 37) Local Network Protection: targetSdk=37 apps are blocked
from local-network access by default. Required for both NSD/mDNS device
discovery on the Connections screen and the built-in TAK Server's localhost
- loopback binding. Requested at runtime via rememberRequestLocalNetworkPermission.
+ loopback binding. Requested at runtime via rememberLocalNetworkPermissionState.
See: https://developer.android.com/privacy-and-security/local-network-permission
-->
diff --git a/build-logic/convention/src/main/kotlin/KmpFeatureConventionPlugin.kt b/build-logic/convention/src/main/kotlin/KmpFeatureConventionPlugin.kt
index f64b47787..012c98e36 100644
--- a/build-logic/convention/src/main/kotlin/KmpFeatureConventionPlugin.kt
+++ b/build-logic/convention/src/main/kotlin/KmpFeatureConventionPlugin.kt
@@ -65,7 +65,6 @@ class KmpFeatureConventionPlugin : Plugin {
}
sourceSets.getByName("androidMain").dependencies {
- implementation(libs.library("accompanist-permissions"))
implementation(libs.library("androidx-activity-compose"))
implementation(libs.library("compose-multiplatform-ui"))
diff --git a/core/barcode/build.gradle.kts b/core/barcode/build.gradle.kts
index 240852102..a700ecce9 100644
--- a/core/barcode/build.gradle.kts
+++ b/core/barcode/build.gradle.kts
@@ -36,7 +36,6 @@ dependencies {
implementation(libs.compose.multiplatform.material3)
implementation(libs.compose.multiplatform.runtime)
implementation(libs.compose.multiplatform.ui)
- implementation(libs.accompanist.permissions)
implementation(libs.kermit)
// ML Kit is used for the Google flavor, while ZXing is used for F-Droid to avoid GMS dependencies.
diff --git a/core/barcode/src/main/kotlin/org/meshtastic/core/barcode/BarcodeScannerProvider.kt b/core/barcode/src/main/kotlin/org/meshtastic/core/barcode/BarcodeScannerProvider.kt
index 09f980977..4606450ad 100644
--- a/core/barcode/src/main/kotlin/org/meshtastic/core/barcode/BarcodeScannerProvider.kt
+++ b/core/barcode/src/main/kotlin/org/meshtastic/core/barcode/BarcodeScannerProvider.kt
@@ -14,11 +14,8 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-@file:OptIn(ExperimentalPermissionsApi::class)
-
package org.meshtastic.core.barcode
-import android.Manifest
import androidx.camera.compose.CameraXViewfinder
import androidx.camera.core.CameraSelector
import androidx.camera.core.ImageAnalysis
@@ -26,16 +23,22 @@ import androidx.camera.core.Preview
import androidx.camera.core.SurfaceRequest
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.compose.foundation.Canvas
+import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -46,34 +49,51 @@ import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.clipPath
import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.semantics.heading
+import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.core.content.ContextCompat
import androidx.lifecycle.compose.LocalLifecycleOwner
import co.touchlab.kermit.Logger
-import com.google.accompanist.permissions.ExperimentalPermissionsApi
-import com.google.accompanist.permissions.isGranted
-import com.google.accompanist.permissions.rememberPermissionState
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.asExecutor
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.resources.Res
+import org.meshtastic.core.resources.camera_permission
+import org.meshtastic.core.resources.camera_permission_rationale
import org.meshtastic.core.resources.close
+import org.meshtastic.core.ui.component.PermissionRecoveryCard
import org.meshtastic.core.ui.icon.Close
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.util.BarcodeScanner
+import org.meshtastic.core.ui.util.PermissionStatus
+import org.meshtastic.core.ui.util.rememberCameraPermissionState
@Composable
fun rememberBarcodeScanner(onResult: (String?) -> Unit): BarcodeScanner {
var showDialog by remember { mutableStateOf(false) }
var pendingScan by remember { mutableStateOf(false) }
- val cameraPermissionState = rememberPermissionState(Manifest.permission.CAMERA)
+ var showPermissionRecovery by remember { mutableStateOf(false) }
+ val cameraPermission = rememberCameraPermissionState()
+ val currentStatus = rememberUpdatedState(cameraPermission.status)
- LaunchedEffect(cameraPermissionState.status.isGranted) {
- if (cameraPermissionState.status.isGranted && pendingScan) {
- showDialog = true
- pendingScan = false
+ LaunchedEffect(cameraPermission.status) {
+ when {
+ // A grant arrived for a scan the user asked for — either the pending request or the recovery card's
+ // "Grant"/"Open settings" round-trip. Open the scanner and clear both pending flags.
+ cameraPermission.isGranted && (pendingScan || showPermissionRecovery) -> {
+ showDialog = true
+ pendingScan = false
+ showPermissionRecovery = false
+ }
+
+ // The pending request completed without a grant — surface a recovery card instead of failing silently.
+ pendingScan && cameraPermission.status != PermissionStatus.NOT_REQUESTED -> {
+ showPermissionRecovery = true
+ pendingScan = false
+ }
}
}
@@ -90,14 +110,38 @@ fun rememberBarcodeScanner(onResult: (String?) -> Unit): BarcodeScanner {
)
}
+ if (showPermissionRecovery) {
+ Dialog(onDismissRequest = { showPermissionRecovery = false }) {
+ Surface(shape = MaterialTheme.shapes.large, color = MaterialTheme.colorScheme.surface) {
+ Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
+ // Heading gives screen readers context for the standalone dialog (unlike the in-sheet Compass
+ // card).
+ Text(
+ text = stringResource(Res.string.camera_permission),
+ style = MaterialTheme.typography.titleMedium,
+ modifier = Modifier.semantics { heading() },
+ )
+ PermissionRecoveryCard(
+ state = cameraPermission,
+ rationale = stringResource(Res.string.camera_permission_rationale),
+ )
+ }
+ }
+ }
+ }
+
return remember {
object : BarcodeScanner {
override fun startScan() {
- if (cameraPermissionState.status.isGranted) {
- showDialog = true
- } else {
- pendingScan = true
- cameraPermissionState.launchPermissionRequest()
+ when (currentStatus.value) {
+ PermissionStatus.GRANTED -> showDialog = true
+
+ PermissionStatus.PERMANENTLY_DENIED -> showPermissionRecovery = true
+
+ else -> {
+ pendingScan = true
+ cameraPermission.request()
+ }
}
}
}
diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml
index 61cefe9a6..40221af04 100644
--- a/core/resources/src/commonMain/composeResources/values/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values/strings.xml
@@ -128,6 +128,7 @@
Bluetooth
Available Bluetooth Devices
Bluetooth Config
+ Bluetooth is off. Turn it on to scan for nearby devices.
Bluetooth enabled
Configuration
Wirelessly manage your device settings and channels.
@@ -135,6 +136,7 @@
Find and identify Meshtastic devices near you.
Bluetooth
Bold Heading
+ Pairing failed. Grant nearby device permissions and try again.
Settings
Broadcast Interval
Busy floor
@@ -143,6 +145,8 @@
Calculating…
Call sign
Your amateur radio call sign, up to 8 characters
+ Camera permission
+ Allow camera access to scan QR codes.
Cancel
Cancel reply
Canned Message
@@ -677,6 +681,7 @@
GPS Mode (Physical Hardware)
GPS Receive GPIO
GPS Transmit GPIO
+ Grant permission
Green
Hardware
Hardware model
@@ -1041,10 +1046,13 @@
1W
1-Wire Temp
Only Favorites
+
+ Open Bluetooth settings
Open Compass
Open settings
Meshtastic is built with the following open source libraries. Tap any library to view its license.
Open Source Libraries
+ Open Wi-Fi settings
Options
Orient north
@@ -1505,6 +1513,7 @@
URL Template
https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png
USB
+ USB permission denied. Reconnect the device to try again.
Use 12h clock format
Compact encoding for Cyrillic
@@ -1577,6 +1586,7 @@
Invalid WiFi Credential QR code format
Scan WiFi QR code
WiFi RSSI threshold (defaults to -80)
+ Not connected to Wi-Fi. Network scan may not find nearby devices.
Wind
Wind Dir
diff --git a/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PermissionRequestTracker.kt b/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PermissionRequestTracker.kt
new file mode 100644
index 000000000..5b15e0486
--- /dev/null
+++ b/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PermissionRequestTracker.kt
@@ -0,0 +1,49 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.core.ui.util
+
+import android.content.Context
+
+/**
+ * Persists, per Android permission string, whether the app has ever completed a runtime request for it.
+ *
+ * This flag is the disambiguator required by [computePermissionStatus]: `shouldShowRequestPermissionRationale` returns
+ * `false` both before the first prompt and after a permanent denial, so a persisted "has been requested" marker is the
+ * only way to tell the two apart.
+ *
+ * Deliberately backed by [android.content.SharedPreferences] rather than DataStore: the flag is read synchronously
+ * inside composition (in the same pass as the rationale check) and written synchronously from a permission-result
+ * callback. DataStore's asynchronous `Flow` model would introduce a read-after-write race on exactly the transition the
+ * permission state machine hinges on.
+ */
+internal class PermissionRequestTracker(context: Context) {
+ private val prefs = context.applicationContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
+
+ fun hasRequested(permission: String): Boolean = prefs.getBoolean(permission, false)
+
+ /**
+ * Marks [permission] as having completed a request. MUST be called from the launcher's result callback (after the
+ * OS has adjudicated the request), never when `launch()` is merely invoked — see [computePermissionStatus].
+ */
+ fun markRequested(permission: String) {
+ prefs.edit().putBoolean(permission, true).apply()
+ }
+
+ private companion object {
+ const val PREFS_NAME = "meshtastic_permissions"
+ }
+}
diff --git a/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt b/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt
index a5d55d774..d538f69b5 100644
--- a/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt
+++ b/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt
@@ -18,9 +18,22 @@
package org.meshtastic.core.ui.util
+import android.bluetooth.BluetoothAdapter
+import android.bluetooth.BluetoothManager
import android.content.ActivityNotFoundException
+import android.content.BroadcastReceiver
+import android.content.Context
import android.content.Intent
+import android.content.IntentFilter
+import android.content.pm.PackageManager
+import android.net.ConnectivityManager
+import android.net.Network
+import android.net.NetworkCapabilities
+import android.net.Uri
+import android.os.Handler
+import android.os.Looper
import android.provider.Settings
+import androidx.activity.compose.LocalActivity
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.Composable
@@ -30,6 +43,8 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
+import androidx.core.app.ActivityCompat
+import androidx.core.content.ContextCompat
import androidx.core.net.toUri
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LifecycleEventEffect
@@ -180,30 +195,6 @@ actual fun KeepScreenOn(enabled: Boolean) {
}
}
-@Composable
-actual fun rememberRequestLocationPermission(onGranted: () -> Unit, onDenied: () -> Unit): () -> Unit {
- val launcher =
- androidx.activity.compose.rememberLauncherForActivityResult(
- androidx.activity.result.contract.ActivityResultContracts.RequestMultiplePermissions(),
- ) { permissions ->
- if (permissions.values.any { it }) {
- onGranted()
- } else {
- onDenied()
- }
- }
- return remember(launcher) {
- {
- launcher.launch(
- arrayOf(
- android.Manifest.permission.ACCESS_FINE_LOCATION,
- android.Manifest.permission.ACCESS_COARSE_LOCATION,
- ),
- )
- }
- }
-}
-
@Composable
actual fun rememberOpenLocationSettings(): () -> Unit {
val launcher =
@@ -211,45 +202,17 @@ actual fun rememberOpenLocationSettings(): () -> Unit {
androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult(),
) { _ ->
}
- return remember(launcher) { { launcher.launch(Intent(android.provider.Settings.ACTION_LOCATION_SOURCE_SETTINGS)) } }
-}
-
-@Composable
-actual fun rememberRequestBluetoothPermission(onGranted: () -> Unit, onDenied: () -> Unit): () -> Unit {
- if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.S) {
- // On pre-Android 12, BLE scanning is gated by location permission, not Bluetooth.
- return remember { { onGranted() } }
- }
- val currentOnGranted = rememberUpdatedState(onGranted)
- val currentOnDenied = rememberUpdatedState(onDenied)
- val launcher =
- rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { permissions ->
- if (permissions.values.all { it }) currentOnGranted.value() else currentOnDenied.value()
- }
return remember(launcher) {
{
- launcher.launch(
- arrayOf(android.Manifest.permission.BLUETOOTH_SCAN, android.Manifest.permission.BLUETOOTH_CONNECT),
- )
+ try {
+ launcher.launch(Intent(android.provider.Settings.ACTION_LOCATION_SOURCE_SETTINGS))
+ } catch (ex: ActivityNotFoundException) {
+ Logger.w(ex) { "No location settings activity available" }
+ }
}
}
}
-@Composable
-actual fun rememberRequestNotificationPermission(onGranted: () -> Unit, onDenied: () -> Unit): () -> Unit {
- if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.TIRAMISU) {
- // Pre-Android 13, no runtime notification permission required.
- return remember { { onGranted() } }
- }
- val currentOnGranted = rememberUpdatedState(onGranted)
- val currentOnDenied = rememberUpdatedState(onDenied)
- val launcher =
- rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
- if (granted) currentOnGranted.value() else currentOnDenied.value()
- }
- return remember(launcher) { { launcher.launch(android.Manifest.permission.POST_NOTIFICATIONS) } }
-}
-
// API level at which ACCESS_LOCAL_NETWORK became a real runtime permission (Android 17 / API 37).
// Hardcoded as an integer literal because Build.VERSION_CODES does not yet expose a named constant
// for API 37 in the SDK we compile against (current max named constant is VANILLA_ICE_CREAM / API 35).
@@ -257,54 +220,255 @@ actual fun rememberRequestNotificationPermission(onGranted: () -> Unit, onDenied
// an immediate denial, which would incorrectly disable any caller that disables itself on denial.
private const val LOCAL_NETWORK_PERMISSION_API = 37
-@Composable
-actual fun rememberRequestLocalNetworkPermission(onGranted: () -> Unit, onDenied: () -> Unit): () -> Unit {
- if (android.os.Build.VERSION.SDK_INT < LOCAL_NETWORK_PERMISSION_API) {
- // Pre-Android 17, ACCESS_LOCAL_NETWORK is not a runtime permission. Localhost / LAN access
- // works implicitly under the INTERNET permission, so report granted without prompting.
- return remember { { onGranted() } }
- }
- val currentOnGranted = rememberUpdatedState(onGranted)
- val currentOnDenied = rememberUpdatedState(onDenied)
- val launcher =
- rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
- if (granted) currentOnGranted.value() else currentOnDenied.value()
- }
- return remember(launcher) { { launcher.launch(android.Manifest.permission.ACCESS_LOCAL_NETWORK) } }
-}
-
-@Composable
-actual fun isLocalNetworkPermissionGranted(): Boolean {
- if (android.os.Build.VERSION.SDK_INT < LOCAL_NETWORK_PERMISSION_API) {
- // Pre-Android 17, no runtime local-network gate; access is implicit via INTERNET.
- return true
- }
- val context = LocalContext.current
- return rememberOnResumeState {
- androidx.core.content.ContextCompat.checkSelfPermission(
- context,
- android.Manifest.permission.ACCESS_LOCAL_NETWORK,
- ) == android.content.pm.PackageManager.PERMISSION_GRANTED
- }
-}
-
-@Composable
-actual fun isLocationPermissionGranted(): Boolean {
- val context = LocalContext.current
- return rememberOnResumeState {
- androidx.core.content.ContextCompat.checkSelfPermission(
- context,
- android.Manifest.permission.ACCESS_FINE_LOCATION,
- ) == android.content.pm.PackageManager.PERMISSION_GRANTED
- }
-}
-
@Composable
actual fun isGpsDisabled(): Boolean {
val context = LocalContext.current
return rememberOnResumeState { context.gpsDisabled() }
}
+@Composable
+actual fun rememberOpenBluetoothSettings(): () -> Unit {
+ val context = LocalContext.current
+ return remember(context) {
+ {
+ try {
+ context.startActivity(
+ Intent(Settings.ACTION_BLUETOOTH_SETTINGS).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
+ )
+ } catch (ex: ActivityNotFoundException) {
+ Logger.w(ex) { "No Bluetooth settings activity available" }
+ }
+ }
+ }
+}
+
+@Composable
+actual fun rememberOpenWifiSettings(): () -> Unit {
+ val context = LocalContext.current
+ return remember(context) {
+ {
+ try {
+ context.startActivity(Intent(Settings.ACTION_WIFI_SETTINGS).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
+ } catch (ex: ActivityNotFoundException) {
+ Logger.w(ex) { "No Wi-Fi settings activity available" }
+ }
+ }
+ }
+}
+
+@Composable
+actual fun isBluetoothDisabled(): Boolean {
+ val context = LocalContext.current
+ return rememberObservedFlag(
+ read = {
+ // adapter == null means the device has no Bluetooth at all — not "disabled", so don't nag.
+ val adapter = (context.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager)?.adapter
+ adapter != null && !adapter.isEnabled
+ },
+ subscribe = { onChange ->
+ val receiver =
+ object : BroadcastReceiver() {
+ override fun onReceive(receiverContext: Context?, intent: Intent?) = onChange()
+ }
+ // ACTION_STATE_CHANGED is a protected system broadcast; NOT_EXPORTED keeps the receiver app-private.
+ // Registered without a Handler, so onReceive is delivered on the main thread.
+ ContextCompat.registerReceiver(
+ context,
+ receiver,
+ IntentFilter(BluetoothAdapter.ACTION_STATE_CHANGED),
+ ContextCompat.RECEIVER_NOT_EXPORTED,
+ )
+ val unregister = { context.unregisterReceiver(receiver) }
+ unregister
+ },
+ )
+}
+
+@Composable
+actual fun isWifiUnavailable(): Boolean {
+ val context = LocalContext.current
+ return rememberObservedFlag(
+ read = {
+ val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
+ val capabilities = cm?.activeNetwork?.let { cm.getNetworkCapabilities(it) }
+ val onLocalNetwork =
+ capabilities != null &&
+ (
+ capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) ||
+ capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET)
+ )
+ !onLocalNetwork
+ },
+ subscribe = { onChange ->
+ val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
+ val callback =
+ object : ConnectivityManager.NetworkCallback() {
+ override fun onAvailable(network: Network) = onChange()
+
+ override fun onLost(network: Network) = onChange()
+
+ override fun onCapabilitiesChanged(network: Network, networkCapabilities: NetworkCapabilities) =
+ onChange()
+ }
+ // Main-thread Handler so the state write lands on the main thread (the API-26+ overload; minSdk is 26).
+ cm.registerDefaultNetworkCallback(callback, Handler(Looper.getMainLooper()))
+ val unregister = { cm.unregisterNetworkCallback(callback) }
+ unregister
+ },
+ )
+}
+
+@Composable
+actual fun rememberOpenAppSettings(): () -> Unit {
+ val context = LocalContext.current
+ return remember(context) {
+ {
+ val intent =
+ Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
+ data = Uri.fromParts("package", context.packageName, null)
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ }
+ try {
+ context.startActivity(intent)
+ } catch (ex: ActivityNotFoundException) {
+ Logger.w(ex) { "Failed to open app settings" }
+ }
+ }
+ }
+}
+
+@Composable
+actual fun rememberLocationPermissionState(): PermissionUiState = rememberRuntimePermissionState(
+ permissions =
+ arrayOf(
+ android.Manifest.permission.ACCESS_FINE_LOCATION,
+ android.Manifest.permission.ACCESS_COARSE_LOCATION,
+ ),
+ // Coarse-only grants are an accepted degraded mode, so any granted permission counts.
+ requireAll = false,
+)
+
+@Composable
+actual fun rememberBluetoothPermissionState(): PermissionUiState {
+ if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.S) {
+ // Pre-Android 12 has no runtime Bluetooth permission — BLE scanning is gated by the location permission, which
+ // callers request separately (the intro Location screen, the map/Privacy location flows). Report granted here
+ // so the Bluetooth surface itself is a no-op rather than masquerading as a location request.
+ return rememberGrantedPermissionState()
+ }
+ return rememberRuntimePermissionState(
+ permissions =
+ arrayOf(android.Manifest.permission.BLUETOOTH_SCAN, android.Manifest.permission.BLUETOOTH_CONNECT),
+ requireAll = true,
+ )
+}
+
+@Composable
+actual fun rememberNotificationPermissionState(): PermissionUiState {
+ if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.TIRAMISU) {
+ // Pre-Android 13, no runtime notification permission required.
+ return rememberGrantedPermissionState()
+ }
+ return rememberRuntimePermissionState(
+ permissions = arrayOf(android.Manifest.permission.POST_NOTIFICATIONS),
+ requireAll = true,
+ )
+}
+
+@Composable
+actual fun rememberLocalNetworkPermissionState(): PermissionUiState {
+ if (android.os.Build.VERSION.SDK_INT < LOCAL_NETWORK_PERMISSION_API) {
+ // Pre-Android 17, ACCESS_LOCAL_NETWORK is implicit via INTERNET; treat as granted.
+ return rememberGrantedPermissionState()
+ }
+ return rememberRuntimePermissionState(
+ permissions = arrayOf(android.Manifest.permission.ACCESS_LOCAL_NETWORK),
+ requireAll = true,
+ )
+}
+
+@Composable
+actual fun rememberCameraPermissionState(): PermissionUiState =
+ rememberRuntimePermissionState(permissions = arrayOf(android.Manifest.permission.CAMERA), requireAll = true)
+
+/** A constant [PermissionUiState] for API levels where the permission is not gated at runtime. */
+@Composable private fun rememberGrantedPermissionState(): PermissionUiState = remember { grantedPermissionUiState() }
+
+/**
+ * Shared engine behind every `rememberXxxPermissionState()`. Computes the [PermissionStatus] from the live grant state,
+ * the persisted "has-been-requested" flag, and `shouldShowRequestPermissionRationale`, refreshing on `ON_RESUME`
+ * (return from settings) and immediately after a request completes.
+ *
+ * @param requireAll when true, all [permissions] must be granted to count as [PermissionStatus.GRANTED]; when false,
+ * any single grant suffices (used by location so a coarse-only grant is accepted — R7).
+ */
+@Composable
+private fun rememberRuntimePermissionState(permissions: Array, requireAll: Boolean): PermissionUiState {
+ val context = LocalContext.current
+ val activity = LocalActivity.current
+ val tracker = remember(context) { PermissionRequestTracker(context) }
+ val openAppSettings = rememberOpenAppSettings()
+ // The permission whose rationale + requested flag represents the group.
+ val primaryPermission = permissions.first()
+
+ fun compute(): PermissionStatus {
+ val granted =
+ isPermissionGroupGranted(
+ results =
+ permissions.map {
+ ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED
+ },
+ requireAll = requireAll,
+ )
+ val shouldShowRationale =
+ if (activity != null) {
+ ActivityCompat.shouldShowRequestPermissionRationale(activity, primaryPermission)
+ } else {
+ // No Activity to query (e.g. a non-Activity-hosted composition). Assume a rationale is still warranted
+ // rather than risk a false PERMANENTLY_DENIED that would strand the user with only a settings link.
+ true
+ }
+ return computePermissionStatus(
+ granted = granted,
+ hasRequested = tracker.hasRequested(primaryPermission),
+ shouldShowRationale = shouldShowRationale,
+ )
+ }
+
+ val statusState = remember { mutableStateOf(compute()) }
+ val launcher =
+ rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { _ ->
+ // The OS has now adjudicated the request; only here is it true that we have asked the user. The result
+ // callback runs on the main thread, so updating the state directly here is safe and recomposes the caller.
+ tracker.markRequested(primaryPermission)
+ statusState.value = compute()
+ }
+ LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { statusState.value = compute() }
+
+ val request = remember(launcher) { { launcher.launch(permissions) } }
+ return PermissionUiState(status = statusState.value, request = request, openAppSettings = openAppSettings)
+}
+
+/**
+ * Remembers a boolean derived from [read], kept live by an observer registered via [subscribe] for the duration of the
+ * composition. [subscribe] receives an `onChange` callback to invoke whenever the underlying state may have changed and
+ * must return a teardown function. The value is re-seeded via [read] at registration, so it is correct even before the
+ * first event arrives. Used for adapter/connectivity state that changes outside the activity lifecycle (e.g. toggling
+ * Bluetooth or Wi-Fi from the quick-settings shade).
+ */
+@Composable
+private fun rememberObservedFlag(read: () -> Boolean, subscribe: (onChange: () -> Unit) -> () -> Unit): Boolean {
+ val currentRead = rememberUpdatedState(read)
+ val currentSubscribe = rememberUpdatedState(subscribe)
+ val state = remember { mutableStateOf(read()) }
+ DisposableEffect(Unit) {
+ state.value = currentRead.value()
+ val unsubscribe = currentSubscribe.value { state.value = currentRead.value() }
+ onDispose { unsubscribe() }
+ }
+ return state.value
+}
+
/**
* Remembers a boolean state that is re-evaluated on each [Lifecycle.Event.ON_RESUME], ensuring the value stays fresh
* when the user returns from a permission dialog or system settings screen.
diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/PermissionRecoveryCard.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/PermissionRecoveryCard.kt
new file mode 100644
index 000000000..4f12040b7
--- /dev/null
+++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/PermissionRecoveryCard.kt
@@ -0,0 +1,147 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.core.ui.component
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.material3.Button
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.unit.dp
+import org.jetbrains.compose.resources.stringResource
+import org.meshtastic.core.resources.Res
+import org.meshtastic.core.resources.grant_permission
+import org.meshtastic.core.resources.open_settings
+import org.meshtastic.core.ui.icon.AppSettingsAlt
+import org.meshtastic.core.ui.icon.ErrorOutline
+import org.meshtastic.core.ui.icon.MeshtasticIcons
+import org.meshtastic.core.ui.util.PermissionStatus
+import org.meshtastic.core.ui.util.PermissionUiState
+
+/**
+ * A reusable error-state card: an `errorContainer` message box plus one full-width recovery action. Generalizes the
+ * compass warning/recovery pattern so any feature can present context plus a single corrective action (request a
+ * permission, open Bluetooth/Wi-Fi/app settings, etc.).
+ *
+ * @param message the user-facing explanation of what is wrong.
+ * @param actionLabel the recovery button label.
+ * @param onAction invoked when the recovery button is tapped.
+ * @param actionIcon optional leading icon for the recovery button.
+ */
+@Composable
+fun RecoveryCard(
+ message: String,
+ actionLabel: String,
+ onAction: () -> Unit,
+ modifier: Modifier = Modifier,
+ actionIcon: ImageVector? = null,
+) {
+ Column(modifier = modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ Surface(
+ tonalElevation = 2.dp,
+ shape = MaterialTheme.shapes.medium,
+ color = MaterialTheme.colorScheme.errorContainer,
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Row(
+ modifier = Modifier.padding(12.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ Icon(
+ imageVector = MeshtasticIcons.ErrorOutline,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onErrorContainer,
+ )
+ Text(
+ text = message,
+ color = MaterialTheme.colorScheme.onErrorContainer,
+ style = MaterialTheme.typography.bodyMedium,
+ )
+ }
+ }
+
+ Button(onClick = onAction, modifier = Modifier.fillMaxWidth()) {
+ if (actionIcon != null) {
+ Icon(imageVector = actionIcon, contentDescription = null)
+ Spacer(modifier = Modifier.width(8.dp))
+ }
+ Text(text = actionLabel)
+ }
+ }
+}
+
+/**
+ * A [RecoveryCard] specialized for a missing runtime permission, presenting a context-correct recovery action:
+ * - [PermissionStatus.NOT_REQUESTED] / [PermissionStatus.DENIED_CAN_RETRY] — shows a "Grant permission" button that
+ * re-launches the in-context request.
+ * - [PermissionStatus.PERMANENTLY_DENIED] — shows an "Open settings" button (user-initiated recovery) because the
+ * system will no longer show the dialog.
+ * - [PermissionStatus.GRANTED] — renders nothing.
+ *
+ * @param rationale a feature-specific explanation of why the permission is needed.
+ */
+@Composable
+internal fun PermissionRecoveryCard(
+ status: PermissionStatus,
+ rationale: String,
+ onRequest: () -> Unit,
+ onOpenSettings: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ if (status == PermissionStatus.GRANTED) return
+
+ if (status == PermissionStatus.PERMANENTLY_DENIED) {
+ RecoveryCard(
+ message = rationale,
+ actionLabel = stringResource(Res.string.open_settings),
+ onAction = onOpenSettings,
+ modifier = modifier,
+ actionIcon = MeshtasticIcons.AppSettingsAlt,
+ )
+ } else {
+ RecoveryCard(
+ message = rationale,
+ actionLabel = stringResource(Res.string.grant_permission),
+ onAction = onRequest,
+ modifier = modifier,
+ )
+ }
+}
+
+/** Convenience overload that reads the status and actions directly from a [PermissionUiState]. */
+@Composable
+fun PermissionRecoveryCard(state: PermissionUiState, rationale: String, modifier: Modifier = Modifier) {
+ PermissionRecoveryCard(
+ status = state.status,
+ rationale = rationale,
+ onRequest = state.request,
+ onOpenSettings = state.openAppSettings,
+ modifier = modifier,
+ )
+}
diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/PermissionStatus.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/PermissionStatus.kt
new file mode 100644
index 000000000..b9c578fa2
--- /dev/null
+++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/PermissionStatus.kt
@@ -0,0 +1,84 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.core.ui.util
+
+import androidx.compose.runtime.Stable
+
+/**
+ * The UX-relevant state of a runtime permission, as recommended by the Android permissions guidance
+ * (https://developer.android.com/training/permissions/requesting). Declared in lifecycle order.
+ * - [NOT_REQUESTED] — the user has never been prompted; request directly (no rationale needed yet).
+ * - [DENIED_CAN_RETRY] — the user denied once but the system will still show the dialog; show a rationale and offer to
+ * re-request.
+ * - [PERMANENTLY_DENIED] — the system will no longer show the dialog ("Don't allow" twice, or "Don't ask again"); the
+ * only recovery is the app's settings screen.
+ * - [GRANTED] — the permission is held; proceed.
+ */
+enum class PermissionStatus {
+ NOT_REQUESTED,
+ DENIED_CAN_RETRY,
+ PERMANENTLY_DENIED,
+ GRANTED,
+}
+
+/**
+ * Pure classifier for a runtime permission's UX state. Kept platform-agnostic and side-effect-free so it can be
+ * unit-tested in `commonTest` without an Android `Activity`.
+ *
+ * **Invariant:** [hasRequested] MUST reflect a *completed* request — it should be persisted from the launcher's result
+ * callback, never merely when `launch()` is invoked. On Android, `launch()` does not show a dialog once a permission is
+ * permanently denied, and a user can background the app before a dialog resolves; setting the flag pre-emptively would
+ * misclassify a first-run user as [PERMANENTLY_DENIED].
+ *
+ * Note that [shouldShowRationale] is `false` both *before* the first prompt and *after* a permanent denial — which is
+ * exactly why [hasRequested] is required to disambiguate the two cases.
+ */
+fun computePermissionStatus(granted: Boolean, hasRequested: Boolean, shouldShowRationale: Boolean): PermissionStatus =
+ when {
+ granted -> PermissionStatus.GRANTED
+ !hasRequested -> PermissionStatus.NOT_REQUESTED
+ shouldShowRationale -> PermissionStatus.DENIED_CAN_RETRY
+ else -> PermissionStatus.PERMANENTLY_DENIED
+ }
+
+/**
+ * A reactive snapshot of a runtime permission plus the actions a caller can take. Produced by the
+ * `rememberXxxPermissionState()` composables and recomputed on `ON_RESUME` so it stays fresh when the user returns from
+ * a permission dialog or the system settings screen.
+ *
+ * Intentionally NOT a `data class`: the lambda members would give `equals`/`hashCode` reference semantics, advertising
+ * value equality the type cannot honor. Callers read [status]/[isGranted] and invoke the actions; they do not compare
+ * instances.
+ */
+@Stable
+class PermissionUiState(val status: PermissionStatus, val request: () -> Unit, val openAppSettings: () -> Unit) {
+ val isGranted: Boolean
+ get() = status == PermissionStatus.GRANTED
+}
+
+/** A constant [PermissionUiState] for platforms / API levels where a permission is not gated at runtime. */
+fun grantedPermissionUiState(): PermissionUiState =
+ PermissionUiState(status = PermissionStatus.GRANTED, request = {}, openAppSettings = {})
+
+/**
+ * Reduces the per-permission grant [results] of a permission group to a single granted flag.
+ *
+ * @param requireAll when true every permission must be granted (e.g. Bluetooth scan + connect); when false a single
+ * grant suffices (e.g. location, where a coarse-only grant is an accepted degraded mode).
+ */
+fun isPermissionGroupGranted(results: List, requireAll: Boolean): Boolean =
+ if (requireAll) results.all { it } else results.any { it }
diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt
index 03258a77a..43909591e 100644
--- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt
+++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt
@@ -55,36 +55,62 @@ expect fun rememberSaveFileLauncher(
/** Keeps the screen awake while [enabled] is true. No-op on platforms that don't support it. */
@Composable expect fun KeepScreenOn(enabled: Boolean)
-/** Returns a launcher to request location permissions. */
-@Composable expect fun rememberRequestLocationPermission(onGranted: () -> Unit, onDenied: () -> Unit = {}): () -> Unit
-
/** Returns a launcher to open the platform's location settings. */
@Composable expect fun rememberOpenLocationSettings(): () -> Unit
-/** Returns a launcher to request Bluetooth scan + connect permissions. No-op on platforms without runtime BLE perms. */
-@Composable expect fun rememberRequestBluetoothPermission(onGranted: () -> Unit, onDenied: () -> Unit = {}): () -> Unit
+/** Returns a launcher to open the platform's Bluetooth settings. */
+@Composable expect fun rememberOpenBluetoothSettings(): () -> Unit
-/** Returns a launcher to request the ACCESS_LOCAL_NETWORK permission. No-op on platforms that don't require it. */
-@Composable
-expect fun rememberRequestLocalNetworkPermission(onGranted: () -> Unit, onDenied: () -> Unit = {}): () -> Unit
-
-/**
- * Returns whether ACCESS_LOCAL_NETWORK is currently granted. Always `true` on platforms / API levels that don't gate
- * local-network access behind a runtime permission.
- */
-@Composable expect fun isLocalNetworkPermissionGranted(): Boolean
-
-/** Returns a launcher to request the POST_NOTIFICATIONS permission. No-op on platforms that don't require it. */
-@Composable
-expect fun rememberRequestNotificationPermission(onGranted: () -> Unit, onDenied: () -> Unit = {}): () -> Unit
-
-/**
- * Returns whether location permissions are currently granted. Always `true` on platforms without runtime permissions.
- */
-@Composable expect fun isLocationPermissionGranted(): Boolean
+/** Returns a launcher to open the platform's Wi-Fi settings. */
+@Composable expect fun rememberOpenWifiSettings(): () -> Unit
/**
* Returns whether GPS/location services are currently disabled at the system level. Always `false` on platforms where
* this concept doesn't apply.
*/
@Composable expect fun isGpsDisabled(): Boolean
+
+/**
+ * Returns whether Bluetooth is currently turned off at the system level (the adapter exists but is disabled). Always
+ * `false` on devices without Bluetooth and on platforms where the concept doesn't apply.
+ */
+@Composable expect fun isBluetoothDisabled(): Boolean
+
+/**
+ * Returns whether the device currently lacks a local-network-capable connection (no active Wi-Fi or Ethernet). NSD/mDNS
+ * discovery needs a LAN, so this surfaces the "connect to Wi-Fi" hint. Always `false` where the concept doesn't apply.
+ */
+@Composable expect fun isWifiUnavailable(): Boolean
+
+/** Returns a function that opens this app's system settings page (where the user can change any permission). */
+@Composable expect fun rememberOpenAppSettings(): () -> Unit
+
+/**
+ * Returns the reactive [PermissionUiState] for the location permissions, recomputed on `ON_RESUME`. On platforms
+ * without runtime permissions the status is always [PermissionStatus.GRANTED].
+ */
+@Composable expect fun rememberLocationPermissionState(): PermissionUiState
+
+/**
+ * Returns the reactive [PermissionUiState] for the Bluetooth scan/connect permissions. On pre-Android-12 devices BLE
+ * scanning is gated by the location permission, so the returned state delegates to [rememberLocationPermissionState].
+ */
+@Composable expect fun rememberBluetoothPermissionState(): PermissionUiState
+
+/**
+ * Returns the reactive [PermissionUiState] for the POST_NOTIFICATIONS permission. Always [PermissionStatus.GRANTED] on
+ * API levels / platforms that don't gate notifications behind a runtime permission.
+ */
+@Composable expect fun rememberNotificationPermissionState(): PermissionUiState
+
+/**
+ * Returns the reactive [PermissionUiState] for the ACCESS_LOCAL_NETWORK permission. Always [PermissionStatus.GRANTED]
+ * on API levels / platforms that don't gate local-network access behind a runtime permission.
+ */
+@Composable expect fun rememberLocalNetworkPermissionState(): PermissionUiState
+
+/**
+ * Returns the reactive [PermissionUiState] for the CAMERA permission. Always [PermissionStatus.GRANTED] on platforms
+ * that don't require a runtime camera permission.
+ */
+@Composable expect fun rememberCameraPermissionState(): PermissionUiState
diff --git a/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/util/PermissionStatusTest.kt b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/util/PermissionStatusTest.kt
new file mode 100644
index 000000000..c8475d488
--- /dev/null
+++ b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/util/PermissionStatusTest.kt
@@ -0,0 +1,91 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.core.ui.util
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+
+class PermissionStatusTest {
+
+ @Test
+ fun `granted always wins regardless of other inputs`() {
+ // All four granted=true combinations resolve to GRANTED.
+ for (hasRequested in listOf(true, false)) {
+ for (shouldShowRationale in listOf(true, false)) {
+ assertEquals(
+ PermissionStatus.GRANTED,
+ computePermissionStatus(
+ granted = true,
+ hasRequested = hasRequested,
+ shouldShowRationale = shouldShowRationale,
+ ),
+ "granted=true, hasRequested=$hasRequested, shouldShowRationale=$shouldShowRationale",
+ )
+ }
+ }
+ }
+
+ @Test
+ fun `not requested when the user has never been prompted`() {
+ // shouldShowRationale is false before the first prompt — must NOT be read as permanent denial.
+ assertEquals(
+ PermissionStatus.NOT_REQUESTED,
+ computePermissionStatus(granted = false, hasRequested = false, shouldShowRationale = false),
+ )
+ // Even if the system somehow reports rationale before a request, the unrequested flag dominates.
+ assertEquals(
+ PermissionStatus.NOT_REQUESTED,
+ computePermissionStatus(granted = false, hasRequested = false, shouldShowRationale = true),
+ )
+ }
+
+ @Test
+ fun `denied can retry when requested and rationale should still show`() {
+ assertEquals(
+ PermissionStatus.DENIED_CAN_RETRY,
+ computePermissionStatus(granted = false, hasRequested = true, shouldShowRationale = true),
+ )
+ }
+
+ @Test
+ fun `permanently denied only when requested and rationale suppressed`() {
+ // The adversarial-flagged case: this resolves to PERMANENTLY_DENIED ONLY because hasRequested reflects a
+ // COMPLETED request (set from the launcher result callback, never at launch() time).
+ assertEquals(
+ PermissionStatus.PERMANENTLY_DENIED,
+ computePermissionStatus(granted = false, hasRequested = true, shouldShowRationale = false),
+ )
+ }
+
+ @Test
+ fun `requireAll false accepts a coarse-only grant`() {
+ // Location requests FINE+COARSE; a coarse-only grant ([fine=false, coarse=true]) must count as granted (R7).
+ assertTrue(isPermissionGroupGranted(results = listOf(false, true), requireAll = false))
+ assertTrue(isPermissionGroupGranted(results = listOf(true, false), requireAll = false))
+ assertFalse(isPermissionGroupGranted(results = listOf(false, false), requireAll = false))
+ }
+
+ @Test
+ fun `requireAll true demands every permission`() {
+ // Bluetooth needs both SCAN and CONNECT; a partial grant is not granted.
+ assertTrue(isPermissionGroupGranted(results = listOf(true, true), requireAll = true))
+ assertFalse(isPermissionGroupGranted(results = listOf(true, false), requireAll = true))
+ assertFalse(isPermissionGroupGranted(results = listOf(false, false), requireAll = true))
+ }
+}
diff --git a/core/ui/src/iosMain/kotlin/org/meshtastic/core/ui/util/NoopStubs.kt b/core/ui/src/iosMain/kotlin/org/meshtastic/core/ui/util/NoopStubs.kt
index 5ebfb0070..296852973 100644
--- a/core/ui/src/iosMain/kotlin/org/meshtastic/core/ui/util/NoopStubs.kt
+++ b/core/ui/src/iosMain/kotlin/org/meshtastic/core/ui/util/NoopStubs.kt
@@ -50,22 +50,28 @@ actual fun rememberOpenFileLauncher(onUriReceived: (CommonUri?) -> Unit): (mimeT
@Composable actual fun KeepScreenOn(enabled: Boolean) {}
-@Composable actual fun rememberRequestLocationPermission(onGranted: () -> Unit, onDenied: () -> Unit): () -> Unit = {}
-
@Composable actual fun rememberOpenLocationSettings(): () -> Unit = {}
-@Composable actual fun rememberRequestBluetoothPermission(onGranted: () -> Unit, onDenied: () -> Unit): () -> Unit = {}
+@Composable actual fun rememberOpenBluetoothSettings(): () -> Unit = {}
-@Composable
-actual fun rememberRequestLocalNetworkPermission(onGranted: () -> Unit, onDenied: () -> Unit): () -> Unit = {}
-
-@Composable actual fun isLocalNetworkPermissionGranted(): Boolean = true
-
-@Composable
-actual fun rememberRequestNotificationPermission(onGranted: () -> Unit, onDenied: () -> Unit): () -> Unit = {}
-
-@Composable actual fun isLocationPermissionGranted(): Boolean = true
+@Composable actual fun rememberOpenWifiSettings(): () -> Unit = {}
@Composable actual fun isGpsDisabled(): Boolean = false
+@Composable actual fun isBluetoothDisabled(): Boolean = false
+
+@Composable actual fun isWifiUnavailable(): Boolean = false
+
@Composable actual fun SetScreenBrightness(brightness: Float) {}
+
+@Composable actual fun rememberOpenAppSettings(): () -> Unit = {}
+
+@Composable actual fun rememberLocationPermissionState(): PermissionUiState = grantedPermissionUiState()
+
+@Composable actual fun rememberBluetoothPermissionState(): PermissionUiState = grantedPermissionUiState()
+
+@Composable actual fun rememberNotificationPermissionState(): PermissionUiState = grantedPermissionUiState()
+
+@Composable actual fun rememberLocalNetworkPermissionState(): PermissionUiState = grantedPermissionUiState()
+
+@Composable actual fun rememberCameraPermissionState(): PermissionUiState = grantedPermissionUiState()
diff --git a/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt b/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt
index c87c31f57..b3fa84815 100644
--- a/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt
+++ b/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt
@@ -115,36 +115,43 @@ actual fun KeepScreenOn(enabled: Boolean) {
// No-op on JVM/Desktop
}
-@Composable
-actual fun rememberRequestLocationPermission(onGranted: () -> Unit, onDenied: () -> Unit): () -> Unit = {
- Logger.w { "Location permissions not implemented on Desktop" }
- onDenied()
-}
-
@Composable
actual fun rememberOpenLocationSettings(): () -> Unit = { Logger.w { "Location settings not implemented on Desktop" } }
-/** JVM no-op — Desktop does not require runtime Bluetooth permissions. */
+/** JVM stub — Bluetooth settings are not available on Desktop. */
@Composable
-actual fun rememberRequestBluetoothPermission(onGranted: () -> Unit, onDenied: () -> Unit): () -> Unit = { onGranted() }
-
-/** JVM no-op — Desktop does not require runtime local network permissions. */
-@Composable
-actual fun rememberRequestLocalNetworkPermission(onGranted: () -> Unit, onDenied: () -> Unit): () -> Unit = {
- onGranted()
+actual fun rememberOpenBluetoothSettings(): () -> Unit = {
+ Logger.w { "Bluetooth settings not available on JVM/Desktop" }
}
-/** JVM — local network permission is always considered granted on Desktop. */
-@Composable actual fun isLocalNetworkPermissionGranted(): Boolean = true
-
-/** JVM no-op — Desktop does not require runtime notification permissions. */
+/** JVM stub — Wi-Fi settings are not available on Desktop. */
@Composable
-actual fun rememberRequestNotificationPermission(onGranted: () -> Unit, onDenied: () -> Unit): () -> Unit = {
- onGranted()
-}
-
-/** JVM — location permission is always considered granted on Desktop. */
-@Composable actual fun isLocationPermissionGranted(): Boolean = true
+actual fun rememberOpenWifiSettings(): () -> Unit = { Logger.w { "Wi-Fi settings not available on JVM/Desktop" } }
/** JVM — GPS is never disabled on Desktop (concept doesn't apply). */
@Composable actual fun isGpsDisabled(): Boolean = false
+
+/** JVM — Bluetooth adapter state is not surfaced on Desktop. */
+@Composable actual fun isBluetoothDisabled(): Boolean = false
+
+/** JVM — local-network availability is not gated on Desktop. */
+@Composable actual fun isWifiUnavailable(): Boolean = false
+
+/** JVM stub — app settings are not available on Desktop. */
+@Composable
+actual fun rememberOpenAppSettings(): () -> Unit = { Logger.w { "App settings not available on JVM/Desktop" } }
+
+/** JVM — Desktop does not gate location behind a runtime permission. */
+@Composable actual fun rememberLocationPermissionState(): PermissionUiState = grantedPermissionUiState()
+
+/** JVM — Desktop does not gate Bluetooth behind a runtime permission. */
+@Composable actual fun rememberBluetoothPermissionState(): PermissionUiState = grantedPermissionUiState()
+
+/** JVM — Desktop does not gate notifications behind a runtime permission. */
+@Composable actual fun rememberNotificationPermissionState(): PermissionUiState = grantedPermissionUiState()
+
+/** JVM — Desktop does not gate local-network access behind a runtime permission. */
+@Composable actual fun rememberLocalNetworkPermissionState(): PermissionUiState = grantedPermissionUiState()
+
+/** JVM — Desktop does not gate the camera behind a runtime permission. */
+@Composable actual fun rememberCameraPermissionState(): PermissionUiState = grantedPermissionUiState()
diff --git a/feature/connections/src/androidMain/kotlin/org/meshtastic/feature/connections/AndroidScannerViewModel.kt b/feature/connections/src/androidMain/kotlin/org/meshtastic/feature/connections/AndroidScannerViewModel.kt
index 5577e427a..d43d8a2d3 100644
--- a/feature/connections/src/androidMain/kotlin/org/meshtastic/feature/connections/AndroidScannerViewModel.kt
+++ b/feature/connections/src/androidMain/kotlin/org/meshtastic/feature/connections/AndroidScannerViewModel.kt
@@ -22,6 +22,7 @@ import co.touchlab.kermit.Severity
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
+import org.jetbrains.compose.resources.getString
import org.koin.core.annotation.KoinViewModel
import org.meshtastic.core.ble.BluetoothRepository
import org.meshtastic.core.datastore.RecentAddressesDataSource
@@ -33,6 +34,9 @@ import org.meshtastic.core.repository.RadioInterfaceService
import org.meshtastic.core.repository.RadioPrefs
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.core.repository.UiPrefs
+import org.meshtastic.core.resources.Res
+import org.meshtastic.core.resources.bonding_failed_permissions
+import org.meshtastic.core.resources.usb_permission_denied
import org.meshtastic.feature.connections.model.AndroidUsbDeviceData
import org.meshtastic.feature.connections.model.DeviceListEntry
import org.meshtastic.feature.connections.model.GetDiscoveredDevicesUseCase
@@ -78,7 +82,7 @@ class AndroidScannerViewModel(
// error and do not arm the transport.
Logger.w(ex) { "Bonding failed for ${entry.device.address.anonymize} Permissions not granted" }
serviceRepository.setErrorMessage(
- text = "Bonding failed: ${ex.message} Permissions not granted",
+ text = getString(Res.string.bonding_failed_permissions),
severity = Severity.Warn,
)
false
@@ -110,6 +114,10 @@ class AndroidScannerViewModel(
changeDeviceAddress(entry.fullAddress)
} else {
Logger.e { "USB permission denied for device ${entry.address}" }
+ serviceRepository.setErrorMessage(
+ text = getString(Res.string.usb_permission_denied),
+ severity = Severity.Warn,
+ )
}
}
.launchIn(viewModelScope)
diff --git a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/ConnectionsScreen.kt b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/ConnectionsScreen.kt
index 75a1882f6..432b3416e 100644
--- a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/ConnectionsScreen.kt
+++ b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/ConnectionsScreen.kt
@@ -54,18 +54,29 @@ import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.navigation.Route
import org.meshtastic.core.navigation.SettingsRoute
import org.meshtastic.core.resources.Res
+import org.meshtastic.core.resources.bluetooth_disabled
import org.meshtastic.core.resources.connections
import org.meshtastic.core.resources.no_device_selected
+import org.meshtastic.core.resources.open_bluetooth_settings
+import org.meshtastic.core.resources.open_wifi_settings
import org.meshtastic.core.resources.set_your_region
import org.meshtastic.core.resources.unknown_device
+import org.meshtastic.core.resources.wifi_unavailable
import org.meshtastic.core.ui.component.AdaptiveTwoPane
import org.meshtastic.core.ui.component.ListItem
import org.meshtastic.core.ui.component.MainAppBar
+import org.meshtastic.core.ui.component.RecoveryCard
+import org.meshtastic.core.ui.icon.Bluetooth
import org.meshtastic.core.ui.icon.Language
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.NoDevice
-import org.meshtastic.core.ui.util.isLocalNetworkPermissionGranted
-import org.meshtastic.core.ui.util.rememberRequestLocalNetworkPermission
+import org.meshtastic.core.ui.util.PermissionStatus
+import org.meshtastic.core.ui.util.isBluetoothDisabled
+import org.meshtastic.core.ui.util.isWifiUnavailable
+import org.meshtastic.core.ui.util.rememberBluetoothPermissionState
+import org.meshtastic.core.ui.util.rememberLocalNetworkPermissionState
+import org.meshtastic.core.ui.util.rememberOpenBluetoothSettings
+import org.meshtastic.core.ui.util.rememberOpenWifiSettings
import org.meshtastic.core.ui.viewmodel.ConnectionStatus
import org.meshtastic.core.ui.viewmodel.ConnectionsViewModel
import org.meshtastic.feature.connections.MOCK_DEVICE_PREFIX
@@ -124,16 +135,18 @@ fun ConnectionsScreen(
val showBleTransport by scanModel.showBleTransport.collectAsStateWithLifecycle()
val showNetworkTransport by scanModel.showNetworkTransport.collectAsStateWithLifecycle()
val showUsbTransport by scanModel.showUsbTransport.collectAsStateWithLifecycle()
- val localNetworkPermissionGranted = isLocalNetworkPermissionGranted()
+ // Android 17 (API 37) gates NSD/mDNS behind ACCESS_LOCAL_NETWORK. Without this prompt the platform falls back to
+ // the system "Choose a device to connect" picker on every discoverServices() call. The reactive state lets the
+ // network-scan toggle request in-context and route a permanent denial to settings.
+ val localNetworkPermission = rememberLocalNetworkPermissionState()
+ val bluetoothPermission = rememberBluetoothPermissionState()
- // Android 17 (API 37) gates NSD/mDNS behind ACCESS_LOCAL_NETWORK. Without this prompt the platform
- // falls back to the system "Choose a device to connect" picker on every discoverServices() call.
- // Granting the permission upfront lets discovery run silently in-app.
- val requestLocalNetworkPermission =
- rememberRequestLocalNetworkPermission(
- onGranted = { scanModel.startNetworkScan() },
- onDenied = { scanModel.stopNetworkScan() },
- )
+ // Adapter-state, distinct from permission state: a permission can be granted while Bluetooth is off or the device
+ // is off Wi-Fi. Detected separately so the UI can route to the adapter's settings rather than re-prompting.
+ val bluetoothDisabled = isBluetoothDisabled()
+ val wifiUnavailable = isWifiUnavailable()
+ val openBluetoothSettings = rememberOpenBluetoothSettings()
+ val openWifiSettings = rememberOpenWifiSettings()
// Auto-start BLE scan when the screen is visible (lifecycle ≥ STARTED) and the user has previously opted in.
// LifecycleStartEffect stops scanning on ON_STOP (app backgrounded) and restarts on ON_START — preventing
@@ -143,8 +156,8 @@ fun ConnectionsScreen(
onStopOrDispose { scanModel.stopBleScan() }
}
- LifecycleStartEffect(networkAutoScan, localNetworkPermissionGranted) {
- if (networkAutoScan && localNetworkPermissionGranted) scanModel.startNetworkScan()
+ LifecycleStartEffect(networkAutoScan, localNetworkPermission.isGranted) {
+ if (networkAutoScan && localNetworkPermission.isGranted) scanModel.startNetworkScan()
onStopOrDispose { scanModel.stopNetworkScan() }
}
@@ -278,6 +291,24 @@ fun ConnectionsScreen(
onToggleNetwork = { scanModel.setShowNetworkTransport(!showNetworkTransport) },
onToggleUsb = { scanModel.setShowUsbTransport(!showUsbTransport) },
)
+
+ // Adapter-off hints: shown only when the relevant permission is granted but the radio/network
+ // is unavailable, so they don't overlap the permission-recovery flow on the scan toggles.
+ if (showBleTransport && bluetoothPermission.isGranted && bluetoothDisabled) {
+ RecoveryCard(
+ message = stringResource(Res.string.bluetooth_disabled),
+ actionLabel = stringResource(Res.string.open_bluetooth_settings),
+ onAction = openBluetoothSettings,
+ actionIcon = MeshtasticIcons.Bluetooth,
+ )
+ }
+ if (showNetworkTransport && localNetworkPermission.isGranted && wifiUnavailable) {
+ RecoveryCard(
+ message = stringResource(Res.string.wifi_unavailable),
+ actionLabel = stringResource(Res.string.open_wifi_settings),
+ onAction = openWifiSettings,
+ )
+ }
},
second = {
// ── Unified device list ──
@@ -295,17 +326,40 @@ fun ConnectionsScreen(
showNetworkSection = showNetworkTransport,
showUsbSection = showUsbTransport,
onSelectDevice = { scanModel.onSelected(it) },
- onToggleBleScan = { scanModel.toggleBleScan() },
+ onToggleBleScan = {
+ when {
+ // Always allow stopping an in-progress scan.
+ isBleScanning -> scanModel.toggleBleScan()
+
+ // Granted but the radio is off — scanning can't work, so open BT settings.
+ bluetoothPermission.isGranted && bluetoothDisabled -> openBluetoothSettings()
+
+ bluetoothPermission.isGranted -> scanModel.toggleBleScan()
+
+ // Permanently denied: the system won't prompt again, so send to settings.
+ bluetoothPermission.status == PermissionStatus.PERMANENTLY_DENIED ->
+ bluetoothPermission.openAppSettings()
+
+ // Request in-context; once granted the user can start scanning.
+ else -> bluetoothPermission.request()
+ }
+ },
onToggleNetworkScan = {
- if (isNetworkScanning || localNetworkPermissionGranted) {
- scanModel.toggleNetworkScan()
- } else {
- // Prefer requesting the runtime grant over letting the platform fall
- // back to the system NSD picker. Persist the user's intent so that if
- // they grant after the prompt, the scan starts via the launcher's
- // onGranted callback and stays on for next session.
- scanModel.persistNetworkAutoScanIntent(true)
- requestLocalNetworkPermission()
+ when {
+ isNetworkScanning || localNetworkPermission.isGranted ->
+ scanModel.toggleNetworkScan()
+
+ localNetworkPermission.status == PermissionStatus.PERMANENTLY_DENIED ->
+ localNetworkPermission.openAppSettings()
+
+ else -> {
+ // Prefer requesting the runtime grant over letting the platform fall back
+ // to the system NSD picker. Persist the user's intent so that if they
+ // grant after the prompt, the scan starts via the LifecycleStartEffect and
+ // stays on for next session.
+ scanModel.persistNetworkAutoScanIntent(true)
+ localNetworkPermission.request()
+ }
}
},
onAddManualAddress = { _, fullAddress ->
diff --git a/feature/intro/src/androidMain/kotlin/org/meshtastic/feature/intro/AndroidIntroPermissions.kt b/feature/intro/src/androidMain/kotlin/org/meshtastic/feature/intro/AndroidIntroPermissions.kt
index 282f42095..34b0821e3 100644
--- a/feature/intro/src/androidMain/kotlin/org/meshtastic/feature/intro/AndroidIntroPermissions.kt
+++ b/feature/intro/src/androidMain/kotlin/org/meshtastic/feature/intro/AndroidIntroPermissions.kt
@@ -16,40 +16,36 @@
*/
package org.meshtastic.feature.intro
-import com.google.accompanist.permissions.ExperimentalPermissionsApi
-import com.google.accompanist.permissions.MultiplePermissionsState
-import com.google.accompanist.permissions.PermissionState
-import com.google.accompanist.permissions.isGranted
+import org.meshtastic.core.ui.util.PermissionUiState
-@OptIn(ExperimentalPermissionsApi::class)
internal class AndroidIntroPermissions(
- private val bluetoothState: MultiplePermissionsState,
- private val locationState: MultiplePermissionsState,
- private val notificationState: PermissionState?,
+ private val bluetoothState: PermissionUiState,
+ private val locationState: PermissionUiState,
+ private val notificationState: PermissionUiState?,
) : IntroPermissions {
override val bluetooth: IntroPermissionState =
object : IntroPermissionState {
override val isGranted: Boolean
- get() = bluetoothState.allPermissionsGranted
+ get() = bluetoothState.isGranted
- override fun launchRequest() = bluetoothState.launchMultiplePermissionRequest()
+ override fun launchRequest() = bluetoothState.request()
}
override val location: IntroPermissionState =
object : IntroPermissionState {
override val isGranted: Boolean
- get() = locationState.allPermissionsGranted
+ get() = locationState.isGranted
- override fun launchRequest() = locationState.launchMultiplePermissionRequest()
+ override fun launchRequest() = locationState.request()
}
override val notification: IntroPermissionState? =
notificationState?.let { state ->
object : IntroPermissionState {
override val isGranted: Boolean
- get() = state.status.isGranted
+ get() = state.isGranted
- override fun launchRequest() = state.launchPermissionRequest()
+ override fun launchRequest() = state.request()
}
}
}
diff --git a/feature/intro/src/androidMain/kotlin/org/meshtastic/feature/intro/AppIntroductionScreen.kt b/feature/intro/src/androidMain/kotlin/org/meshtastic/feature/intro/AppIntroductionScreen.kt
index 1e2d1d0e7..518f12227 100644
--- a/feature/intro/src/androidMain/kotlin/org/meshtastic/feature/intro/AppIntroductionScreen.kt
+++ b/feature/intro/src/androidMain/kotlin/org/meshtastic/feature/intro/AppIntroductionScreen.kt
@@ -16,7 +16,6 @@
*/
package org.meshtastic.feature.intro
-import android.Manifest
import android.os.Build
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
@@ -24,11 +23,10 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.rememberNavBackStack
-import com.google.accompanist.permissions.ExperimentalPermissionsApi
-import com.google.accompanist.permissions.PermissionState
-import com.google.accompanist.permissions.rememberMultiplePermissionsState
-import com.google.accompanist.permissions.rememberPermissionState
import org.meshtastic.core.ui.component.MeshtasticNavDisplay
+import org.meshtastic.core.ui.util.rememberBluetoothPermissionState
+import org.meshtastic.core.ui.util.rememberLocationPermissionState
+import org.meshtastic.core.ui.util.rememberNotificationPermissionState
/**
* Main application introduction screen. This Composable hosts the navigation flow and hoists the permission states.
@@ -36,29 +34,18 @@ import org.meshtastic.core.ui.component.MeshtasticNavDisplay
* @param onDone Callback invoked when the introduction flow is completed.
* @param viewModel ViewModel for tracking the introduction flow state.
*/
-@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun AppIntroductionScreen(onDone: () -> Unit, viewModel: IntroViewModel) {
val context = LocalContext.current
- val notificationPermissionState: PermissionState? =
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
- rememberPermissionState(Manifest.permission.POST_NOTIFICATIONS)
- } else {
- null
- }
+ // Pre-Android 13 has no runtime notification permission, so there is nothing to configure — keep it null so the
+ // intro flow can skip the notification screen entirely. SDK_INT is constant per process, so the conditional call
+ // is recomposition-safe.
+ val notificationPermissionState =
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) rememberNotificationPermissionState() else null
- val locationPermissions =
- listOf(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION)
- val locationPermissionState = rememberMultiplePermissionsState(permissions = locationPermissions)
-
- val bluetoothPermissions =
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
- listOf(Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_CONNECT)
- } else {
- emptyList()
- }
- val bluetoothPermissionState = rememberMultiplePermissionsState(permissions = bluetoothPermissions)
+ val locationPermissionState = rememberLocationPermissionState()
+ val bluetoothPermissionState = rememberBluetoothPermissionState()
val permissions =
remember(notificationPermissionState, locationPermissionState, bluetoothPermissionState) {
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailScreens.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailScreens.kt
index 02f2d007a..8d6c4ec67 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailScreens.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailScreens.kt
@@ -28,6 +28,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@@ -157,12 +158,26 @@ private fun NodeDetailOverlays(
onDismiss: () -> Unit,
onRequestPosition: (Node) -> Unit,
) {
- val requestLocationPermission =
- org.meshtastic.core.ui.util.rememberRequestLocationPermission(
- onGranted = { node?.let { onRequestPosition(it) } },
- onDenied = {},
- )
+ val locationPermission = org.meshtastic.core.ui.util.rememberLocationPermissionState()
val openLocationSettings = org.meshtastic.core.ui.util.rememberOpenLocationSettings()
+ // Request a fresh position once the user grants from the compass warning, mirroring the prior onGranted callback.
+ var positionPendingGrant by remember { mutableStateOf(false) }
+ val currentNode by rememberUpdatedState(node)
+ val currentOnRequestPosition by rememberUpdatedState(onRequestPosition)
+ LaunchedEffect(locationPermission.status) {
+ if (locationPermission.isGranted && positionPendingGrant) {
+ currentNode?.let { currentOnRequestPosition(it) }
+ positionPendingGrant = false
+ }
+ }
+ val onRequestLocationPermission = {
+ if (locationPermission.status == org.meshtastic.core.ui.util.PermissionStatus.PERMANENTLY_DENIED) {
+ locationPermission.openAppSettings()
+ } else {
+ positionPendingGrant = true
+ locationPermission.request()
+ }
+ }
when (overlay) {
is NodeDetailOverlay.SharedContact -> node?.let { SharedContactDialog(it, onDismiss) }
@@ -180,7 +195,7 @@ private fun NodeDetailOverlays(
) {
CompassSheetContent(
uiState = compassUiState,
- onRequestLocationPermission = { requestLocationPermission() },
+ onRequestLocationPermission = onRequestLocationPermission,
onOpenLocationSettings = { openLocationSettings() },
onRequestPosition = { node?.let { onRequestPosition(it) } },
modifier = Modifier.padding(bottom = 24.dp),
diff --git a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/tak/TakPermissionUtil.kt b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/tak/TakPermissionUtil.kt
index 8c55e36d6..62b6d9113 100644
--- a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/tak/TakPermissionUtil.kt
+++ b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/tak/TakPermissionUtil.kt
@@ -18,8 +18,8 @@ package org.meshtastic.feature.settings.tak
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
-import org.meshtastic.core.ui.util.isLocalNetworkPermissionGranted
-import org.meshtastic.core.ui.util.rememberRequestLocalNetworkPermission
+import org.meshtastic.core.ui.util.PermissionStatus
+import org.meshtastic.core.ui.util.rememberLocalNetworkPermissionState
@Composable
actual fun TakPermissionHandler(isTakServerEnabled: Boolean, onPermissionResult: (Boolean) -> Unit) {
@@ -28,21 +28,22 @@ actual fun TakPermissionHandler(isTakServerEnabled: Boolean, onPermissionResult:
// when targetSdk >= 37, and is requested up-front from the Connections screen, so it will usually
// already be granted by the time the user enables TAK. This composable handles the standalone case
// (e.g. user opens TAK settings before ever tapping the network-scan toggle).
- val isPermissionGranted = isLocalNetworkPermissionGranted()
- val requestPermission =
- rememberRequestLocalNetworkPermission(
- onGranted = { onPermissionResult(true) },
- onDenied = { onPermissionResult(false) },
- )
+ val permission = rememberLocalNetworkPermissionState()
- // The launcher must run as a post-composition side effect — invoking it directly in the composition
- // body crashes with "Launcher has not been initialized" because the underlying
- // ActivityResultLauncherHolder is not linked to the activity until composition completes. Keying on
- // both inputs also guarantees we only re-prompt when state actually transitions, not on every
- // recomposition.
- LaunchedEffect(isTakServerEnabled, isPermissionGranted) {
- if (isTakServerEnabled && !isPermissionGranted) {
- requestPermission()
+ // The launcher must run as a post-composition side effect — invoking it directly in the composition body crashes
+ // with "Launcher has not been initialized". Keying on the status enum re-runs only on real transitions: request
+ // once when never asked, and disable the server on any denial (preserving the prior request-once-then-disable
+ // behavior, now with PERMANENTLY_DENIED treated the same as a fresh denial).
+ LaunchedEffect(isTakServerEnabled, permission.status) {
+ if (!isTakServerEnabled) return@LaunchedEffect
+ when (permission.status) {
+ PermissionStatus.GRANTED -> onPermissionResult(true)
+
+ PermissionStatus.NOT_REQUESTED -> permission.request()
+
+ PermissionStatus.DENIED_CAN_RETRY,
+ PermissionStatus.PERMANENTLY_DENIED,
+ -> onPermissionResult(false)
}
}
}
diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/component/PrivacySection.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/component/PrivacySection.kt
index 1ba5e2764..34429206f 100644
--- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/component/PrivacySection.kt
+++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/component/PrivacySection.kt
@@ -29,8 +29,7 @@ import org.meshtastic.core.ui.icon.BugReport
import org.meshtastic.core.ui.icon.LocationOn
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.util.isGpsDisabled
-import org.meshtastic.core.ui.util.isLocationPermissionGranted
-import org.meshtastic.core.ui.util.rememberRequestLocationPermission
+import org.meshtastic.core.ui.util.rememberLocationPermissionState
import org.meshtastic.core.ui.util.rememberShowToastResource
/** Section managing privacy settings like analytics and location sharing. */
@@ -47,21 +46,21 @@ fun PrivacySection(
stopProvideLocation: () -> Unit,
) {
val showToast = rememberShowToastResource()
- val isLocationGranted = isLocationPermissionGranted()
+ val locationPermission = rememberLocationPermissionState()
val isGpsOff = isGpsDisabled()
- val requestLocationPermission =
- rememberRequestLocationPermission(onGranted = { startProvideLocation() }, onDenied = {})
- LaunchedEffect(provideLocation, isLocationGranted, isGpsOff) {
+ // Key on the boolean grant rather than the full status so a first denial doesn't immediately re-prompt: request()
+ // covers both the never-asked and re-promptable cases, and is a harmless no-op once permanently denied.
+ LaunchedEffect(provideLocation, locationPermission.isGranted, isGpsOff) {
if (provideLocation) {
- if (isLocationGranted) {
+ if (locationPermission.isGranted) {
if (!isGpsOff) {
startProvideLocation()
} else {
showToast(Res.string.location_disabled)
}
} else {
- requestLocationPermission()
+ locationPermission.request()
}
} else {
stopProvideLocation()
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 94edf4179..6f7069b90 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -4,7 +4,6 @@ xmlutil = "0.91.3"
# Android
agp = "9.2.1"
appcompat = "1.7.1"
-accompanist = "0.37.3"
car-app = "1.9.0-alpha01"
appfunctions = "1.0.0-alpha09"
@@ -245,7 +244,6 @@ turbine = { module = "app.cash.turbine:turbine", version.ref = "turbine" }
# Other
aboutlibraries-core = { module = "com.mikepenz:aboutlibraries-core", version.ref = "aboutlibraries" }
aboutlibraries-compose-m3 = { module = "com.mikepenz:aboutlibraries-compose-m3", version.ref = "aboutlibraries" }
-accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanist" }
coil = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" }
coil-network-ktor3 = { module = "io.coil-kt.coil3:coil-network-ktor3", version.ref = "coil" }
coil-svg = { module = "io.coil-kt.coil3:coil-svg", version.ref = "coil" }
diff --git a/workpad.md b/workpad.md
new file mode 100644
index 000000000..fee7c2969
--- /dev/null
+++ b/workpad.md
@@ -0,0 +1,132 @@
+# Craft Workpad: Live BT/Wi-Fi adapter-state detection
+
+> Generated by /craft · Started: 2026-06-18 · Branch: claude/gallant-thompson-d1b2ea (PR #5851)
+
+---
+
+## Task
+
+Make the Bluetooth/Wi-Fi adapter-disabled detection **live** instead of `ON_RESUME`-polled — add a `BroadcastReceiver` for Bluetooth adapter state and a `ConnectivityManager.NetworkCallback` for network availability, so the Connections recovery banners update in real time (e.g. toggling BT from the quick-settings shade) rather than only when the activity resumes.
+
+Scope: `isBluetoothDisabled()` and `isWifiUnavailable()` in `core/ui/src/androidMain/.../util/PlatformUtils.kt`. Follow-up to the adapter-state feature added in commit 2c06a8019 on PR #5851.
+
+---
+
+## Exploration Report
+
+### Key Facts
+
+- Both target functions live in `core/ui/src/androidMain/.../util/PlatformUtils.kt`: `isBluetoothDisabled()` (reads `BluetoothManager.adapter.isEnabled`) and `isWifiUnavailable()` (reads `ConnectivityManager.activeNetwork` transports). Both currently wrap their read in the private `rememberOnResumeState { ... }` helper — recomputed only on `Lifecycle.Event.ON_RESUME`.
+- `rememberOnResumeState(check)` is the only consumer-shared refresh primitive; also used by `isGpsDisabled()`. Changing the two BT/Wi-Fi functions must NOT change `isGpsDisabled()` behavior (out of scope).
+- **NetworkCallback pattern already exists** in `core/network/.../ConnectivityManager.kt`: `observeNetworks()` uses `callbackFlow { … registerNetworkCallback(req, cb); awaitClose { unregisterNetworkCallback(cb) } }` with `onAvailable`/`onLost`/`onCapabilitiesChanged`. Mirror it with a `DisposableEffect` in the composable (or `registerDefaultNetworkCallback` for the single active network).
+- **BroadcastReceiver pattern already exists** in `core/ble/.../AndroidBluetoothRepository.kt`: registers via `ContextCompat.registerReceiver(context, receiver, filter, ContextCompat.RECEIVER_NOT_EXPORTED)` and `context.unregisterReceiver(...)`. For adapter state, the action is `BluetoothAdapter.ACTION_STATE_CHANGED`.
+- The two functions are consumed only by `ConnectionsScreen.kt` (hoisted as `bluetoothDisabled`/`wifiUnavailable` booleans driving inline `RecoveryCard` banners + the BLE toggle routing). No other callers — the public contract (`@Composable expect fun … : Boolean`) is unchanged; only the androidMain implementation changes.
+- `androidApp/src/main/AndroidManifest.xml` already declares Bluetooth + network permissions; `ACTION_STATE_CHANGED` and a `NetworkCallback` need no extra permission beyond what's declared (`ACCESS_NETWORK_STATE` is present for connectivity callbacks — verify).
+
+### Key Files
+
+- `core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt` — the two functions + `rememberOnResumeState`; the only file that changes.
+- `core/network/.../repository/ConnectivityManager.kt` — reference NetworkCallback/callbackFlow pattern.
+- `core/ble/.../AndroidBluetoothRepository.kt` — reference registerReceiver/RECEIVER_NOT_EXPORTED pattern.
+- `feature/connections/.../ui/ConnectionsScreen.kt` — sole consumer (no change needed; reads the same booleans, now updated live).
+- jvm/ios actuals already return constant `false` — unaffected.
+
+---
+
+## Requirements
+
+R1: `isBluetoothDisabled()` updates reactively — when the Bluetooth adapter is turned on/off (incl. from the quick-settings shade while the app is foregrounded), the returned value changes without waiting for `ON_RESUME`.
+ Source: human confirmed
+ Verification: review confirms a `BroadcastReceiver` on `BluetoothAdapter.ACTION_STATE_CHANGED` drives the state; manual toggle from shade flips the banner.
+
+R2: `isWifiUnavailable()` updates reactively — connecting/disconnecting Wi-Fi (or losing the active local network) changes the returned value live.
+ Source: human confirmed
+ Verification: review confirms a `ConnectivityManager` `NetworkCallback` (default-network) drives the state.
+
+R3: `ON_RESUME` polling is removed for these two functions; the receiver/callback is registered and unregistered with the composable's lifetime via `DisposableEffect` (no leaks). `isGpsDisabled()` continues to use `rememberOnResumeState` unchanged.
+ Source: human confirmed
+ Verification: `grep` shows no `rememberOnResumeState` in the two functions; `awaitClose`/`onDispose` unregisters; `isGpsDisabled` untouched.
+
+R4: Registration follows existing repo conventions — `ContextCompat.registerReceiver(..., RECEIVER_NOT_EXPORTED)` for the BT receiver; `registerDefaultNetworkCallback`/`unregisterNetworkCallback` for the network callback. No new permissions (ACCESS_NETWORK_STATE already declared).
+ Source: craft-clarify recommendation
+ Verification: review confirms the registration calls + flag matches `RECEIVER_NOT_EXPORTED`.
+
+R5: The public `expect` contract and all consumers are unchanged — `ConnectionsScreen` reads the same `Boolean`s, now live. jvm/ios actuals stay constant `false`.
+ Source: craft-clarify recommendation
+ Verification: no change to commonMain expect or jvm/iosMain; ConnectionsScreen diff empty; both flavors assemble.
+
+---
+
+## Architectural Decision
+
+- **Chosen approach:** Extract a private `rememberObservedFlag(read, subscribe)` primitive (DisposableEffect + mutableStateOf, re-seed on registration); express `isBluetoothDisabled()` via a `BroadcastReceiver` on `ACTION_STATE_CHANGED` (RECEIVER_NOT_EXPORTED, main-thread delivery) and `isWifiUnavailable()` via `registerDefaultNetworkCallback(callback, mainHandler)`.
+- **Approved:** 2026-06-18
+- **Adversarial:** P1-ish threading risk (NetworkCallback on background thread) mitigated by main-thread Handler; leaks prevented by onDispose unregister; cold-start staleness prevented by read() re-seed. No blocker.
+- **Files:** `core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt` only.
+- **Test:** build both flavors + detekt/spotless; manual shade-toggle verification (no headless test feasible — `core/ui` has no instrumentation).
+
+---
+
+## Acceptance Criteria
+
+AC1: [R1] `isBluetoothDisabled()` is driven by a `BroadcastReceiver` on `BluetoothAdapter.ACTION_STATE_CHANGED`. Auto-verify: grep ACTION_STATE_CHANGED + BroadcastReceiver in the function.
+AC2: [R2] `isWifiUnavailable()` is driven by `registerDefaultNetworkCallback`. Auto-verify: grep registerDefaultNetworkCallback.
+AC3: [R3] neither function uses `rememberOnResumeState`; observer torn down in `onDispose`; `isGpsDisabled()` still uses `rememberOnResumeState`. Auto-verify: grep.
+AC4: [R4] BT receiver uses `RECEIVER_NOT_EXPORTED`; network callback uses a main-thread `Handler`. Auto-verify: grep.
+AC5: [R5] commonMain expect + jvm/ios actuals + ConnectionsScreen unchanged; both flavors assemble; detekt/spotless clean. Auto-verify: git diff scope + build.
+
+---
+
+## Implementation Plan
+
+**Branch:** claude/gallant-thompson-d1b2ea **Base commit:** 2c06a8019
+
+### Task list
+
+### Pre-existing failures (do not fix — out of scope)
+
+---
+
+## Completion Bar
+
+1. [x] All planned files created/modified
+2. [x] Linter clean
+3. [x] Tests pass (no new tests feasible; regressions green)
+4. [x] Every AC has a completion note
+5. [x] No open markers remain
+6. [x] Scope discipline honored
+
+---
+
+## Review
+
+Reviewer: concurrency (right-sized — single-file ~40-line change on established repo patterns; build+detekt covered the rest).
+
+Verdict: **SOUND.** Threading main-thread-confined (BT receiver no-Handler → main; NetworkCallback with main-Looper Handler); register/unregister balanced 1:1 via `DisposableEffect(Unit)`/`onDispose`; no leak or double-unregister; `read()` re-seed correct.
+
+Findings (both P3, no action):
+- F-1 [P3] `rememberUpdatedState(subscribe)` freshness is never exercised (subscribe runs once). NOT removed: accessing `subscribe` directly inside `DisposableEffect` re-triggers the `LambdaParameterInRestartableEffect` detekt rule, so the wrapper is required for lint. `LocalContext` is stable, so no stale-context defect. Kept as-is.
+- F-2 [P3] `registerDefaultNetworkCallback` `TooManyRequests` — structurally bounded (1:1 with a single live banner); no retry loop. No guard needed.
+
+### Acceptance criteria status
+- AC1 ✓ BroadcastReceiver on ACTION_STATE_CHANGED drives isBluetoothDisabled.
+- AC2 ✓ registerDefaultNetworkCallback drives isWifiUnavailable.
+- AC3 ✓ neither uses rememberOnResumeState; onDispose unregisters; isGpsDisabled untouched.
+- AC4 ✓ RECEIVER_NOT_EXPORTED + main-thread Handler.
+- AC5 ✓ expect/jvm/ios/ConnectionsScreen unchanged; both flavors assemble; detekt/spotless clean.
+
+---
+
+## Deferred Items
+
+---
+
+## Phase Log
+
+- explore: done — 2026-06-18 — lean direct explore (deep prior context). Both reactive patterns already in repo (NetworkCallback callbackFlow in core/network; registerReceiver RECEIVER_NOT_EXPORTED in core/ble). Only androidMain PlatformUtils changes.
+- clarify: done — 2026-06-18 — Q1 confirmed (replace ON_RESUME entirely). R1–R5 recorded.
+- architect: done — 2026-06-18 — single approach (rememberObservedFlag primitive) + self-adversarial pass (threading via main Handler). Approved.
+- implement: done — 2026-06-18 — one file (core/ui androidMain PlatformUtils). All 5 ACs met; build+detekt+spotless+both flavors green.
+- review: done — 2026-06-18 — concurrency reviewer: SOUND. 2×P3 non-actionable (lint-required wrapper; bounded TooManyRequests).
+- refine: done — 2026-06-18 — fast path, no actionable findings. P3s recorded as considered/declined.
+- pr: done — 2026-06-18 — pushed to existing PR #5851 (174db32ae); no new PR (same branch/feature).