feat(permissions): runtime-permission + adapter-state recovery UX; remove Accompanist (#5851)

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
James Rich
2026-06-18 14:03:14 -05:00
committed by GitHub
parent 4e7e4c39cb
commit b8ab53e712
26 changed files with 1136 additions and 302 deletions

View File

@@ -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

View File

@@ -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)

View File

@@ -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()
}
}
},
)

View File

@@ -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,

View File

@@ -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
-->
<uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" />

View File

@@ -65,7 +65,6 @@ class KmpFeatureConventionPlugin : Plugin<Project> {
}
sourceSets.getByName("androidMain").dependencies {
implementation(libs.library("accompanist-permissions"))
implementation(libs.library("androidx-activity-compose"))
implementation(libs.library("compose-multiplatform-ui"))

View File

@@ -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.

View File

@@ -14,11 +14,8 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@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()
}
}
}
}

View File

@@ -128,6 +128,7 @@
<string name="bluetooth">Bluetooth</string>
<string name="bluetooth_available_devices">Available Bluetooth Devices</string>
<string name="bluetooth_config">Bluetooth Config</string>
<string name="bluetooth_disabled">Bluetooth is off. Turn it on to scan for nearby devices.</string>
<string name="bluetooth_enabled">Bluetooth enabled</string>
<string name="bluetooth_feature_config">Configuration</string>
<string name="bluetooth_feature_config_description">Wirelessly manage your device settings and channels.</string>
@@ -135,6 +136,7 @@
<string name="bluetooth_feature_discovery_description">Find and identify Meshtastic devices near you.</string>
<string name="bluetooth_permission">Bluetooth</string>
<string name="bold_heading">Bold Heading</string>
<string name="bonding_failed_permissions">Pairing failed. Grant nearby device permissions and try again.</string>
<string name="bottom_nav_settings">Settings</string>
<string name="broadcast_interval">Broadcast Interval</string>
<string name="busy_noise_floor">Busy floor</string>
@@ -143,6 +145,8 @@
<string name="calculating">Calculating…</string>
<string name="call_sign">Call sign</string>
<string name="call_sign_summary">Your amateur radio call sign, up to 8 characters</string>
<string name="camera_permission">Camera permission</string>
<string name="camera_permission_rationale">Allow camera access to scan QR codes.</string>
<string name="cancel">Cancel</string>
<string name="cancel_reply">Cancel reply</string>
<string name="canned_message">Canned Message</string>
@@ -677,6 +681,7 @@
<string name="gps_mode">GPS Mode (Physical Hardware)</string>
<string name="gps_receive_gpio">GPS Receive GPIO</string>
<string name="gps_transmit_gpio">GPS Transmit GPIO</string>
<string name="grant_permission">Grant permission</string>
<string name="green">Green</string>
<string name="hardware">Hardware</string>
<string name="hardware_model">Hardware model</string>
@@ -1041,10 +1046,13 @@
<string name="one_week">1W</string>
<string name="one_wire_temperature">1-Wire Temp</string>
<string name="only_favorites">Only Favorites</string>
<!-- OPEN -->
<string name="open_bluetooth_settings">Open Bluetooth settings</string>
<string name="open_compass">Open Compass</string>
<string name="open_settings">Open settings</string>
<string name="open_source_description">Meshtastic is built with the following open source libraries. Tap any library to view its license.</string>
<string name="open_source_libraries">Open Source Libraries</string>
<string name="open_wifi_settings">Open Wi-Fi settings</string>
<string name="options">Options</string>
<string name="orient_north">Orient north</string>
<!-- OUTPUT -->
@@ -1505,6 +1513,7 @@
<string name="url_template">URL Template</string>
<string name="url_template_hint" translatable="false">https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png</string>
<string name="usb">USB</string>
<string name="usb_permission_denied">USB permission denied. Reconnect the device to try again.</string>
<!-- USE -->
<string name="use_12h_format">Use 12h clock format</string>
<string name="use_homoglyph_characters_encoding">Compact encoding for Cyrillic</string>
@@ -1577,6 +1586,7 @@
<string name="wifi_qr_code_error">Invalid WiFi Credential QR code format</string>
<string name="wifi_qr_code_scan">Scan WiFi QR code</string>
<string name="wifi_rssi_threshold_defaults_to_80">WiFi RSSI threshold (defaults to -80)</string>
<string name="wifi_unavailable">Not connected to Wi-Fi. Network scan may not find nearby devices.</string>
<!-- WIND -->
<string name="wind">Wind</string>
<string name="wind_direction">Wind Dir</string>

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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"
}
}

View File

@@ -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<String>, 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.

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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,
)
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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<Boolean>, requireAll: Boolean): Boolean =
if (requireAll) results.all { it } else results.any { it }

View File

@@ -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

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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))
}
}

View File

@@ -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()

View File

@@ -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()

View File

@@ -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)

View File

@@ -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 ->

View File

@@ -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()
}
}
}

View File

@@ -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) {

View File

@@ -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),

View File

@@ -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)
}
}
}

View File

@@ -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()

View File

@@ -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" }

132
workpad.md Normal file
View File

@@ -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). R1R5 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).