fix: address review triage (F-1,2,5,7,13,14,16)

- migrate all remaining permission call sites (PrivacySection, NodeDetailScreens,
  TakPermissionUtil, ConnectionsScreen) to rememberXxxPermissionState(); delete the
  old rememberRequestXxx/isXxx wrappers — single source of truth, resolves the
  FINE-vs-FINE|COARSE location-grant conflict (F-5)
- add in-context Bluetooth + local-network requests with permanent-denial→settings
  recovery on the Connections scan toggles (F-1)
- pre-Android-12 Bluetooth state reports granted instead of delegating to location,
  fixing the intro Bluetooth-screen regression (F-2)
- null Activity no longer collapses to PERMANENTLY_DENIED (F-13)
- extract + test isPermissionGroupGranted (requireAll coarse-location logic) (F-7)
- make granular PermissionRecoveryCard overload internal (F-14)
- order PermissionStatus by lifecycle (F-16)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
James Rich
2026-06-18 13:04:42 -05:00
parent e1872598a5
commit 81b5e03f27
11 changed files with 120 additions and 237 deletions

View File

@@ -30,7 +30,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.mutableStateOf
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
@@ -185,30 +184,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 =
@@ -227,42 +202,6 @@ actual fun rememberOpenLocationSettings(): () -> Unit {
}
}
@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),
)
}
}
}
@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).
@@ -270,48 +209,6 @@ 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
@@ -351,9 +248,10 @@ actual fun rememberLocationPermissionState(): PermissionUiState = rememberRuntim
@Composable
actual fun rememberBluetoothPermissionState(): PermissionUiState {
if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.S) {
// On pre-Android 12, BLE scanning is gated by the location permission, not Bluetooth. Delegate so the
// recovery UI surfaces the permission the system actually requires.
return rememberLocationPermissionState()
// 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 =
@@ -412,13 +310,21 @@ private fun rememberRuntimePermissionState(permissions: Array<String>, requireAl
fun compute(): PermissionStatus {
val granted =
if (requireAll) {
permissions.all { ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED }
} else {
permissions.any { ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED }
}
isPermissionGroupGranted(
results =
permissions.map {
ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED
},
requireAll = requireAll,
)
val shouldShowRationale =
activity?.let { ActivityCompat.shouldShowRequestPermissionRationale(it, primaryPermission) } ?: false
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),

View File

@@ -54,7 +54,7 @@ import org.meshtastic.core.ui.util.PermissionUiState
* @param rationale a feature-specific explanation of why the permission is needed.
*/
@Composable
fun PermissionRecoveryCard(
internal fun PermissionRecoveryCard(
status: PermissionStatus,
rationale: String,
onRequest: () -> Unit,

View File

@@ -20,19 +20,19 @@ 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).
* - [GRANTED] — the permission is held; proceed.
* (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 {
GRANTED,
NOT_REQUESTED,
DENIED_CAN_RETRY,
PERMANENTLY_DENIED,
GRANTED,
}
/**
@@ -73,3 +73,12 @@ class PermissionUiState(val status: PermissionStatus, val request: () -> Unit, v
/** 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,34 +55,9 @@ 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 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 whether GPS/location services are currently disabled at the system level. Always `false` on platforms where
* this concept doesn't apply.

View File

@@ -18,6 +18,8 @@ package org.meshtastic.core.ui.util
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class PermissionStatusTest {
@@ -70,4 +72,20 @@ class PermissionStatusTest {
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,8 @@ 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 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 isGpsDisabled(): Boolean = false
@Composable actual fun SetScreenBrightness(brightness: Float) {}

View File

@@ -115,37 +115,9 @@ 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. */
@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()
}
/** 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. */
@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
/** JVM — GPS is never disabled on Desktop (concept doesn't apply). */
@Composable actual fun isGpsDisabled(): Boolean = false

View File

@@ -64,8 +64,9 @@ import org.meshtastic.core.ui.component.MainAppBar
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.rememberBluetoothPermissionState
import org.meshtastic.core.ui.util.rememberLocalNetworkPermissionState
import org.meshtastic.core.ui.viewmodel.ConnectionStatus
import org.meshtastic.core.ui.viewmodel.ConnectionsViewModel
import org.meshtastic.feature.connections.MOCK_DEVICE_PREFIX
@@ -124,16 +125,11 @@ 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.
// Granting the permission upfront lets discovery run silently in-app.
val requestLocalNetworkPermission =
rememberRequestLocalNetworkPermission(
onGranted = { scanModel.startNetworkScan() },
onDenied = { scanModel.stopNetworkScan() },
)
// 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()
// 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 +139,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() }
}
@@ -295,17 +291,30 @@ fun ConnectionsScreen(
showNetworkSection = showNetworkTransport,
showUsbSection = showUsbTransport,
onSelectDevice = { scanModel.onSelected(it) },
onToggleBleScan = { scanModel.toggleBleScan() },
onToggleBleScan = {
when {
isBleScanning || 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

@@ -157,12 +157,24 @@ 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) }
LaunchedEffect(locationPermission.status) {
if (locationPermission.isGranted && positionPendingGrant) {
node?.let { onRequestPosition(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 +192,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,18 @@ 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()