mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-06-30 00:15:48 -04:00
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:
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 ->
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user