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.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import co.touchlab.kermit.Logger 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 com.google.android.material.dialog.MaterialAlertDialogBuilder
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.StringResource 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.Lens
import org.meshtastic.core.ui.icon.MeshtasticIcons import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.PinDrop 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.formatAgo
import org.meshtastic.core.ui.util.rememberLocationPermissionState
import org.meshtastic.core.ui.util.showToast import org.meshtastic.core.ui.util.showToast
import org.meshtastic.feature.map.BaseMapViewModel.MapFilterState import org.meshtastic.feature.map.BaseMapViewModel.MapFilterState
import org.meshtastic.feature.map.LastHeardFilter import org.meshtastic.feature.map.LastHeardFilter
@@ -636,9 +636,11 @@ fun MapView(
onToggleLocationTracking = { onToggleLocationTracking = {
when { when {
locationPermission.isGranted -> map.toggleMyLocation() locationPermission.isGranted -> map.toggleMyLocation()
// Permanently denied: the system won't prompt again, so send the user to settings. // Permanently denied: the system won't prompt again, so send the user to settings.
locationPermission.status == PermissionStatus.PERMANENTLY_DENIED -> locationPermission.status == PermissionStatus.PERMANENTLY_DENIED ->
locationPermission.openAppSettings() locationPermission.openAppSettings()
else -> { else -> {
triggerLocationToggleAfterPermission = true triggerLocationToggleAfterPermission = true
locationPermission.request() locationPermission.request()

View File

@@ -56,8 +56,6 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import co.touchlab.kermit.Logger 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.LocationCallback
import com.google.android.gms.location.LocationRequest import com.google.android.gms.location.LocationRequest
import com.google.android.gms.location.LocationResult 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.MeshtasticIcons
import org.meshtastic.core.ui.icon.TripOrigin import org.meshtastic.core.ui.icon.TripOrigin
import org.meshtastic.core.ui.theme.TracerouteColors 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.formatAgo
import org.meshtastic.core.ui.util.formatPositionTime 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.BaseMapViewModel.MapFilterState
import org.meshtastic.feature.map.LastHeardFilter import org.meshtastic.feature.map.LastHeardFilter
import org.meshtastic.feature.map.component.MapButton import org.meshtastic.feature.map.component.MapButton
@@ -700,8 +700,11 @@ fun MapView(
followPhoneBearing = false followPhoneBearing = false
} }
} }
// Permanently denied: the system won't prompt again, so send the user to settings to recover. // 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 -> { else -> {
triggerLocationToggleAfterPermission = true triggerLocationToggleAfterPermission = true
locationPermission.request() locationPermission.request()

View File

@@ -76,10 +76,12 @@ fun rememberBarcodeScanner(onResult: (String?) -> Unit): BarcodeScanner {
LaunchedEffect(cameraPermission.status) { LaunchedEffect(cameraPermission.status) {
when { when {
!pendingScan -> Unit !pendingScan -> Unit
cameraPermission.isGranted -> { cameraPermission.isGranted -> {
showDialog = true showDialog = true
pendingScan = false pendingScan = false
} }
// The request completed without a grant — surface a recovery card instead of failing silently. // The request completed without a grant — surface a recovery card instead of failing silently.
cameraPermission.status != PermissionStatus.NOT_REQUESTED -> { cameraPermission.status != PermissionStatus.NOT_REQUESTED -> {
showPermissionRecovery = true showPermissionRecovery = true
@@ -120,7 +122,9 @@ fun rememberBarcodeScanner(onResult: (String?) -> Unit): BarcodeScanner {
override fun startScan() { override fun startScan() {
when (currentStatus.value) { when (currentStatus.value) {
PermissionStatus.GRANTED -> showDialog = true PermissionStatus.GRANTED -> showDialog = true
PermissionStatus.PERMANENTLY_DENIED -> showPermissionRecovery = true PermissionStatus.PERMANENTLY_DENIED -> showPermissionRecovery = true
else -> { else -> {
pendingScan = true pendingScan = true
cameraPermission.request() 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. * 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` * This flag is the disambiguator required by [computePermissionStatus]: `shouldShowRequestPermissionRationale` returns
* returns `false` both before the first prompt and after a permanent denial, so a persisted "has been requested" * `false` both before the first prompt and after a permanent denial, so a persisted "has been requested" marker is the
* marker is the only way to tell the two apart. * only way to tell the two apart.
* *
* Deliberately backed by [android.content.SharedPreferences] rather than DataStore: the flag is read synchronously * 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 * 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 * callback. DataStore's asynchronous `Flow` model would introduce a read-after-write race on exactly the transition the
* the permission state machine hinges on. * permission state machine hinges on.
*/ */
internal class PermissionRequestTracker(context: Context) { internal class PermissionRequestTracker(context: Context) {
private val prefs = context.applicationContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) private val prefs = context.applicationContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)

View File

@@ -334,16 +334,15 @@ actual fun rememberOpenAppSettings(): () -> Unit {
} }
@Composable @Composable
actual fun rememberLocationPermissionState(): PermissionUiState = actual fun rememberLocationPermissionState(): PermissionUiState = rememberRuntimePermissionState(
rememberRuntimePermissionState( permissions =
permissions = arrayOf(
arrayOf( android.Manifest.permission.ACCESS_FINE_LOCATION,
android.Manifest.permission.ACCESS_FINE_LOCATION, android.Manifest.permission.ACCESS_COARSE_LOCATION,
android.Manifest.permission.ACCESS_COARSE_LOCATION, ),
), // Coarse-only grants are an accepted degraded mode, so any granted permission counts.
// Coarse-only grants are an accepted degraded mode, so any granted permission counts. requireAll = false,
requireAll = false, )
)
@Composable @Composable
actual fun rememberBluetoothPermissionState(): PermissionUiState { actual fun rememberBluetoothPermissionState(): PermissionUiState {
@@ -397,9 +396,9 @@ private fun rememberGrantedPermissionState(): PermissionUiState {
} }
/** /**
* Shared engine behind every `rememberXxxPermissionState()`. Computes the [PermissionStatus] from the live grant * Shared engine behind every `rememberXxxPermissionState()`. Computes the [PermissionStatus] from the live grant state,
* state, the persisted "has-been-requested" flag, and `shouldShowRequestPermissionRationale`, refreshing on * the persisted "has-been-requested" flag, and `shouldShowRequestPermissionRationale`, refreshing on `ON_RESUME`
* `ON_RESUME` (return from settings) and immediately after a request completes. * (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, * @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). * 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 * 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: * every feature presents context plus a single, context-correct recovery action:
*
* - [PermissionStatus.NOT_REQUESTED] / [PermissionStatus.DENIED_CAN_RETRY] — a "Grant permission" button that * - [PermissionStatus.NOT_REQUESTED] / [PermissionStatus.DENIED_CAN_RETRY] — a "Grant permission" button that
* re-launches the in-context request. * re-launches the in-context request.
* - [PermissionStatus.PERMANENTLY_DENIED] — an "Open settings" button (user-initiated recovery) since the system * - [PermissionStatus.PERMANENTLY_DENIED] — an "Open settings" button (user-initiated recovery) since the system will
* will no longer show the dialog. * no longer show the dialog.
* - [PermissionStatus.GRANTED] — renders nothing. * - [PermissionStatus.GRANTED] — renders nothing.
* *
* @param rationale a feature-specific explanation of why the permission is needed. * @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 * The UX-relevant state of a runtime permission, as recommended by the Android permissions guidance
* (https://developer.android.com/training/permissions/requesting). * (https://developer.android.com/training/permissions/requesting).
*
* - [GRANTED] — the permission is held; proceed. * - [GRANTED] — the permission is held; proceed.
* - [NOT_REQUESTED] — the user has never been prompted; request directly (no rationale needed yet). * - [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 * - [DENIED_CAN_RETRY] — the user denied once but the system will still show the dialog; show a rationale and offer to
* rationale and offer to re-request. * re-request.
* - [PERMANENTLY_DENIED] — the system will no longer show the dialog ("Don't allow" twice, or * - [PERMANENTLY_DENIED] — the system will no longer show the dialog ("Don't allow" twice, or "Don't ask again"); the
* "Don't ask again"); the only recovery is the app's settings screen. * only recovery is the app's settings screen.
*/ */
enum class PermissionStatus { enum class PermissionStatus {
GRANTED, 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 * 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`. * unit-tested in `commonTest` without an Android `Activity`.
* *
* **Invariant:** [hasRequested] MUST reflect a *completed* request — it should be persisted from the launcher's * **Invariant:** [hasRequested] MUST reflect a *completed* request — it should be persisted from the launcher's result
* result callback, never merely when `launch()` is invoked. On Android, `launch()` does not show a dialog once a * callback, never merely when `launch()` is invoked. On Android, `launch()` does not show a dialog once a permission is
* permission is permanently denied, and a user can background the app before a dialog resolves; setting the flag * permanently denied, and a user can background the app before a dialog resolves; setting the flag pre-emptively would
* pre-emptively would misclassify a first-run user as [PERMANENTLY_DENIED]. * misclassify a first-run user as [PERMANENTLY_DENIED].
* *
* Note that [shouldShowRationale] is `false` both *before* the first prompt and *after* a permanent denial — which * Note that [shouldShowRationale] is `false` both *before* the first prompt and *after* a permanent denial — which is
* is exactly why [hasRequested] is required to disambiguate the two cases. * exactly why [hasRequested] is required to disambiguate the two cases.
*/ */
fun computePermissionStatus(granted: Boolean, hasRequested: Boolean, shouldShowRationale: Boolean): PermissionStatus = fun computePermissionStatus(granted: Boolean, hasRequested: Boolean, shouldShowRationale: Boolean): PermissionStatus =
when { 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 * 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 * `rememberXxxPermissionState()` composables and recomputed on `ON_RESUME` so it stays fresh when the user returns from
* from a permission dialog or the system settings screen. * a permission dialog or the system settings screen.
*/ */
data class PermissionUiState(val status: PermissionStatus, val request: () -> Unit, val openAppSettings: () -> Unit) { data class PermissionUiState(val status: PermissionStatus, val request: () -> Unit, val openAppSettings: () -> Unit) {
val isGranted: Boolean val isGranted: Boolean

View File

@@ -24,9 +24,6 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.getString import org.jetbrains.compose.resources.getString
import org.koin.core.annotation.KoinViewModel 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.ble.BluetoothRepository
import org.meshtastic.core.datastore.RecentAddressesDataSource import org.meshtastic.core.datastore.RecentAddressesDataSource
import org.meshtastic.core.model.util.anonymize 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.RadioPrefs
import org.meshtastic.core.repository.ServiceRepository import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.core.repository.UiPrefs 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.AndroidUsbDeviceData
import org.meshtastic.feature.connections.model.DeviceListEntry import org.meshtastic.feature.connections.model.DeviceListEntry
import org.meshtastic.feature.connections.model.GetDiscoveredDevicesUseCase import org.meshtastic.feature.connections.model.GetDiscoveredDevicesUseCase