style: apply spotless formatting to permission changes

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
James Rich
2026-06-18 12:19:12 -05:00
parent 6c3b4b7868
commit 2aa33c8d56
8 changed files with 48 additions and 42 deletions

View File

@@ -67,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 org.meshtastic.core.ui.util.PermissionStatus
import org.meshtastic.core.ui.util.rememberLocationPermissionState
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.StringResource
@@ -129,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
@@ -636,9 +636,11 @@ fun MapView(
onToggleLocationTracking = {
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

@@ -56,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 org.meshtastic.core.ui.util.PermissionStatus
import org.meshtastic.core.ui.util.rememberLocationPermissionState
import com.google.android.gms.location.LocationCallback
import com.google.android.gms.location.LocationRequest
import com.google.android.gms.location.LocationResult
@@ -130,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
@@ -700,8 +700,11 @@ fun MapView(
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()
locationPermission.status == PermissionStatus.PERMANENTLY_DENIED ->
locationPermission.openAppSettings()
else -> {
triggerLocationToggleAfterPermission = true
locationPermission.request()

View File

@@ -76,10 +76,12 @@ fun rememberBarcodeScanner(onResult: (String?) -> Unit): BarcodeScanner {
LaunchedEffect(cameraPermission.status) {
when {
!pendingScan -> Unit
cameraPermission.isGranted -> {
showDialog = true
pendingScan = false
}
// The request completed without a grant — surface a recovery card instead of failing silently.
cameraPermission.status != PermissionStatus.NOT_REQUESTED -> {
showPermissionRecovery = true
@@ -120,7 +122,9 @@ fun rememberBarcodeScanner(onResult: (String?) -> Unit): BarcodeScanner {
override fun startScan() {
when (currentStatus.value) {
PermissionStatus.GRANTED -> showDialog = true
PermissionStatus.PERMANENTLY_DENIED -> showPermissionRecovery = true
else -> {
pendingScan = true
cameraPermission.request()

View File

@@ -21,14 +21,14 @@ 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.
* 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.
* 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)

View File

@@ -334,16 +334,15 @@ actual fun rememberOpenAppSettings(): () -> Unit {
}
@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,
)
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 {
@@ -397,9 +396,9 @@ private fun rememberGrantedPermissionState(): PermissionUiState {
}
/**
* 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.
* 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).

View File

@@ -45,11 +45,10 @@ import org.meshtastic.core.ui.util.PermissionUiState
/**
* A reusable error-state card for a missing runtime permission. Generalizes the compass warning/recovery pattern so
* every feature presents context plus a single, context-correct recovery action:
*
* - [PermissionStatus.NOT_REQUESTED] / [PermissionStatus.DENIED_CAN_RETRY] — a "Grant permission" button that
* re-launches the in-context request.
* - [PermissionStatus.PERMANENTLY_DENIED] — an "Open settings" button (user-initiated recovery) since the system
* will no longer show the dialog.
* - [PermissionStatus.PERMANENTLY_DENIED] — an "Open settings" button (user-initiated recovery) since the system will
* no longer show the dialog.
* - [PermissionStatus.GRANTED] — renders nothing.
*
* @param rationale a feature-specific explanation of why the permission is needed.

View File

@@ -19,13 +19,12 @@ package org.meshtastic.core.ui.util
/**
* 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.
* - [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.
* - [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.
*/
enum class PermissionStatus {
GRANTED,
@@ -38,13 +37,13 @@ enum class PermissionStatus {
* 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].
* **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.
* 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 {
@@ -56,8 +55,8 @@ fun computePermissionStatus(granted: Boolean, hasRequested: Boolean, shouldShowR
/**
* 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.
* `rememberXxxPermissionState()` composables and recomputed on `ON_RESUME` so it stays fresh when the user returns from
* a permission dialog or the system settings screen.
*/
data class PermissionUiState(val status: PermissionStatus, val request: () -> Unit, val openAppSettings: () -> Unit) {
val isGranted: Boolean

View File

@@ -24,9 +24,6 @@ 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.resources.Res
import org.meshtastic.core.resources.bonding_failed_permissions
import org.meshtastic.core.resources.usb_permission_denied
import org.meshtastic.core.ble.BluetoothRepository
import org.meshtastic.core.datastore.RecentAddressesDataSource
import org.meshtastic.core.model.util.anonymize
@@ -37,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