diff --git a/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt b/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt index 784329ad1..6ae95362d 100644 --- a/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt +++ b/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt @@ -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, 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), diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/PermissionRecoveryCard.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/PermissionRecoveryCard.kt index 8a782b8f9..3a686026e 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/PermissionRecoveryCard.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/PermissionRecoveryCard.kt @@ -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, diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/PermissionStatus.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/PermissionStatus.kt index fa35f9ebd..b9c578fa2 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/PermissionStatus.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/PermissionStatus.kt @@ -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, requireAll: Boolean): Boolean = + if (requireAll) results.all { it } else results.any { it } diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt index ecb92cdd9..f9fa8f223 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt @@ -55,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. diff --git a/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/util/PermissionStatusTest.kt b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/util/PermissionStatusTest.kt index b094acf9d..c8475d488 100644 --- a/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/util/PermissionStatusTest.kt +++ b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/util/PermissionStatusTest.kt @@ -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)) + } } diff --git a/core/ui/src/iosMain/kotlin/org/meshtastic/core/ui/util/NoopStubs.kt b/core/ui/src/iosMain/kotlin/org/meshtastic/core/ui/util/NoopStubs.kt index d045d9ca7..e83d377e7 100644 --- a/core/ui/src/iosMain/kotlin/org/meshtastic/core/ui/util/NoopStubs.kt +++ b/core/ui/src/iosMain/kotlin/org/meshtastic/core/ui/util/NoopStubs.kt @@ -50,22 +50,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) {} diff --git a/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt b/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt index eafdb77bb..cf730c1b8 100644 --- a/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt +++ b/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt @@ -115,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 diff --git a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/ConnectionsScreen.kt b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/ConnectionsScreen.kt index 75a1882f6..f29e3477e 100644 --- a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/ConnectionsScreen.kt +++ b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/ConnectionsScreen.kt @@ -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 -> diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailScreens.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailScreens.kt index 02f2d007a..b597ca249 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailScreens.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailScreens.kt @@ -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), diff --git a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/tak/TakPermissionUtil.kt b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/tak/TakPermissionUtil.kt index 8c55e36d6..fe26f7677 100644 --- a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/tak/TakPermissionUtil.kt +++ b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/tak/TakPermissionUtil.kt @@ -18,8 +18,8 @@ package org.meshtastic.feature.settings.tak import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import org.meshtastic.core.ui.util.isLocalNetworkPermissionGranted -import org.meshtastic.core.ui.util.rememberRequestLocalNetworkPermission +import org.meshtastic.core.ui.util.PermissionStatus +import org.meshtastic.core.ui.util.rememberLocalNetworkPermissionState @Composable actual fun TakPermissionHandler(isTakServerEnabled: Boolean, onPermissionResult: (Boolean) -> Unit) { @@ -28,21 +28,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) } } } diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/component/PrivacySection.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/component/PrivacySection.kt index 1ba5e2764..34429206f 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/component/PrivacySection.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/component/PrivacySection.kt @@ -29,8 +29,7 @@ import org.meshtastic.core.ui.icon.BugReport import org.meshtastic.core.ui.icon.LocationOn import org.meshtastic.core.ui.icon.MeshtasticIcons import org.meshtastic.core.ui.util.isGpsDisabled -import org.meshtastic.core.ui.util.isLocationPermissionGranted -import org.meshtastic.core.ui.util.rememberRequestLocationPermission +import org.meshtastic.core.ui.util.rememberLocationPermissionState import org.meshtastic.core.ui.util.rememberShowToastResource /** Section managing privacy settings like analytics and location sharing. */ @@ -47,21 +46,21 @@ fun PrivacySection( stopProvideLocation: () -> Unit, ) { val showToast = rememberShowToastResource() - val isLocationGranted = isLocationPermissionGranted() + val locationPermission = rememberLocationPermissionState() val isGpsOff = isGpsDisabled() - val requestLocationPermission = - rememberRequestLocationPermission(onGranted = { startProvideLocation() }, onDenied = {}) - LaunchedEffect(provideLocation, isLocationGranted, isGpsOff) { + // Key on the boolean grant rather than the full status so a first denial doesn't immediately re-prompt: request() + // covers both the never-asked and re-promptable cases, and is a harmless no-op once permanently denied. + LaunchedEffect(provideLocation, locationPermission.isGranted, isGpsOff) { if (provideLocation) { - if (isLocationGranted) { + if (locationPermission.isGranted) { if (!isGpsOff) { startProvideLocation() } else { showToast(Res.string.location_disabled) } } else { - requestLocationPermission() + locationPermission.request() } } else { stopProvideLocation()