From 6c3b4b78680db129849b69fe05fd71a058203cc2 Mon Sep 17 00:00:00 2001 From: James Rich Date: Thu, 18 Jun 2026 11:57:49 -0500 Subject: [PATCH] feat(permissions): migrate all call sites off Accompanist + add denial recovery Migrate intro, both map flavors, and the barcode scanner to the native rememberXxxPermissionState() helpers; remove accompanist-permissions from the version catalog, feature convention plugin, and module build files. Add user-facing denial recovery where it was previously silent: barcode camera shows a PermissionRecoveryCard, USB permission denial surfaces an error message, and the map location button routes permanent denial to app settings. Convert the Bluetooth bonding error to a string resource. Co-Authored-By: Claude Opus 4.8 --- androidApp/build.gradle.kts | 1 - .../kotlin/org/meshtastic/app/map/MapView.kt | 29 ++++----- .../kotlin/org/meshtastic/app/map/MapView.kt | 39 ++++++------ .../main/kotlin/KmpFeatureConventionPlugin.kt | 1 - core/barcode/build.gradle.kts | 1 - .../core/barcode/BarcodeScannerProvider.kt | 59 ++++++++++++++----- .../connections/AndroidScannerViewModel.kt | 10 +++- .../feature/intro/AndroidIntroPermissions.kt | 24 ++++---- .../feature/intro/AppIntroductionScreen.kt | 33 ++++------- gradle/libs.versions.toml | 2 - 10 files changed, 108 insertions(+), 91 deletions(-) diff --git a/androidApp/build.gradle.kts b/androidApp/build.gradle.kts index ab72f7429..6d357ddbf 100644 --- a/androidApp/build.gradle.kts +++ b/androidApp/build.gradle.kts @@ -265,7 +265,6 @@ dependencies { implementation(libs.koin.compose.viewmodel) implementation(libs.koin.androidx.workmanager) implementation(libs.koin.annotations) - implementation(libs.accompanist.permissions) implementation(libs.kermit) implementation(libs.kotlinx.datetime) diff --git a/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/MapView.kt b/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/MapView.kt index 339dd574b..16b50c1da 100644 --- a/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/MapView.kt +++ b/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/MapView.kt @@ -16,7 +16,6 @@ */ package org.meshtastic.app.map -import android.Manifest import androidx.appcompat.content.res.AppCompatResources import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box @@ -68,8 +67,8 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import androidx.lifecycle.compose.collectAsStateWithLifecycle import co.touchlab.kermit.Logger -import com.google.accompanist.permissions.ExperimentalPermissionsApi -import com.google.accompanist.permissions.rememberMultiplePermissionsState +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 @@ -208,7 +207,6 @@ private fun cacheManagerCallback(onTaskComplete: () -> Unit, onTaskFailed: (Int) * @param mapViewModel The [MapViewModel] providing data and state for the map. * @param navigateToNodeDetails Callback to navigate to the details screen of a selected node. */ -@OptIn(ExperimentalPermissionsApi::class) // Added for Accompanist @Suppress("CyclomaticComplexMethod", "LongMethod") @Composable fun MapView( @@ -246,9 +244,8 @@ fun MapView( val unknownText = stringResource(Res.string.unknown) val nowText = stringResource(Res.string.now) - // Accompanist permissions state for location - val locationPermissionsState = - rememberMultiplePermissionsState(permissions = listOf(Manifest.permission.ACCESS_FINE_LOCATION)) + // Location permission state (native; recomputed on resume). + val locationPermission = rememberLocationPermissionState() var triggerLocationToggleAfterPermission by remember { mutableStateOf(false) } fun loadOnlineTileSourceBase(): ITileSource { @@ -309,8 +306,8 @@ fun MapView( } // Effect to toggle MyLocation after permission is granted - LaunchedEffect(locationPermissionsState.allPermissionsGranted) { - if (locationPermissionsState.allPermissionsGranted && triggerLocationToggleAfterPermission) { + LaunchedEffect(locationPermission.isGranted) { + if (locationPermission.isGranted && triggerLocationToggleAfterPermission) { map.toggleMyLocation() triggerLocationToggleAfterPermission = false } @@ -637,11 +634,15 @@ fun MapView( }, isLocationTrackingEnabled = myLocationOverlay != null, onToggleLocationTracking = { - if (locationPermissionsState.allPermissionsGranted) { - map.toggleMyLocation() - } else { - triggerLocationToggleAfterPermission = true - locationPermissionsState.launchMultiplePermissionRequest() + 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() + } } }, ) diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt index 6ca71eb6a..be2c3fb73 100644 --- a/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt +++ b/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt @@ -18,7 +18,6 @@ package org.meshtastic.app.map -import android.Manifest import android.app.Activity import android.content.Intent import android.net.Uri @@ -57,8 +56,8 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import co.touchlab.kermit.Logger -import com.google.accompanist.permissions.ExperimentalPermissionsApi -import com.google.accompanist.permissions.rememberMultiplePermissionsState +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 @@ -177,7 +176,7 @@ private const val TRACEROUTE_OFFSET_METERS = 100.0 private const val TRACEROUTE_BOUNDS_PADDING_PX = 120 @Suppress("CyclomaticComplexMethod", "LongMethod") -@OptIn(MapsComposeExperimentalApi::class, ExperimentalMaterial3Api::class, ExperimentalPermissionsApi::class) +@OptIn(MapsComposeExperimentalApi::class, ExperimentalMaterial3Api::class) @Composable fun MapView( modifier: Modifier = Modifier, @@ -190,16 +189,15 @@ fun MapView( val mapLayers by mapViewModel.mapLayers.collectAsStateWithLifecycle() // --- Location permissions --- - val locationPermissionsState = - rememberMultiplePermissionsState(permissions = listOf(Manifest.permission.ACCESS_FINE_LOCATION)) + val locationPermission = rememberLocationPermissionState() var triggerLocationToggleAfterPermission by remember { mutableStateOf(false) } // --- Location tracking --- var isLocationTrackingEnabled by remember { mutableStateOf(false) } var followPhoneBearing by remember { mutableStateOf(false) } - LaunchedEffect(locationPermissionsState.allPermissionsGranted) { - if (locationPermissionsState.allPermissionsGranted && triggerLocationToggleAfterPermission) { + LaunchedEffect(locationPermission.isGranted) { + if (locationPermission.isGranted && triggerLocationToggleAfterPermission) { isLocationTrackingEnabled = true triggerLocationToggleAfterPermission = false } @@ -280,8 +278,8 @@ fun MapView( } } - LaunchedEffect(isLocationTrackingEnabled, locationPermissionsState.allPermissionsGranted) { - if (isLocationTrackingEnabled && locationPermissionsState.allPermissionsGranted) { + LaunchedEffect(isLocationTrackingEnabled, locationPermission.isGranted) { + if (isLocationTrackingEnabled && locationPermission.isGranted) { val locationRequest = LocationRequest.Builder(Priority.PRIORITY_HIGH_ACCURACY, 5000L) .setMinUpdateIntervalMillis(2000L) @@ -529,7 +527,7 @@ fun MapView( properties = MapProperties( mapType = effectiveGoogleMapType, - isMyLocationEnabled = isLocationTrackingEnabled && locationPermissionsState.allPermissionsGranted, + isMyLocationEnabled = isLocationTrackingEnabled && locationPermission.isGranted, ), onMapLongClick = { latLng -> if (isMainMode && isConnected) { @@ -695,14 +693,19 @@ fun MapView( }, isLocationTrackingEnabled = isLocationTrackingEnabled, onToggleLocationTracking = { - if (locationPermissionsState.allPermissionsGranted) { - isLocationTrackingEnabled = !isLocationTrackingEnabled - if (!isLocationTrackingEnabled) { - followPhoneBearing = false + when { + locationPermission.isGranted -> { + isLocationTrackingEnabled = !isLocationTrackingEnabled + if (!isLocationTrackingEnabled) { + 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() + else -> { + triggerLocationToggleAfterPermission = true + locationPermission.request() } - } else { - triggerLocationToggleAfterPermission = true - locationPermissionsState.launchMultiplePermissionRequest() } }, bearing = cameraPositionState.position.bearing, diff --git a/build-logic/convention/src/main/kotlin/KmpFeatureConventionPlugin.kt b/build-logic/convention/src/main/kotlin/KmpFeatureConventionPlugin.kt index f64b47787..012c98e36 100644 --- a/build-logic/convention/src/main/kotlin/KmpFeatureConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/KmpFeatureConventionPlugin.kt @@ -65,7 +65,6 @@ class KmpFeatureConventionPlugin : Plugin { } sourceSets.getByName("androidMain").dependencies { - implementation(libs.library("accompanist-permissions")) implementation(libs.library("androidx-activity-compose")) implementation(libs.library("compose-multiplatform-ui")) diff --git a/core/barcode/build.gradle.kts b/core/barcode/build.gradle.kts index 240852102..a700ecce9 100644 --- a/core/barcode/build.gradle.kts +++ b/core/barcode/build.gradle.kts @@ -36,7 +36,6 @@ dependencies { implementation(libs.compose.multiplatform.material3) implementation(libs.compose.multiplatform.runtime) implementation(libs.compose.multiplatform.ui) - implementation(libs.accompanist.permissions) implementation(libs.kermit) // ML Kit is used for the Google flavor, while ZXing is used for F-Droid to avoid GMS dependencies. diff --git a/core/barcode/src/main/kotlin/org/meshtastic/core/barcode/BarcodeScannerProvider.kt b/core/barcode/src/main/kotlin/org/meshtastic/core/barcode/BarcodeScannerProvider.kt index 09f980977..e3d850176 100644 --- a/core/barcode/src/main/kotlin/org/meshtastic/core/barcode/BarcodeScannerProvider.kt +++ b/core/barcode/src/main/kotlin/org/meshtastic/core/barcode/BarcodeScannerProvider.kt @@ -14,11 +14,8 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -@file:OptIn(ExperimentalPermissionsApi::class) - package org.meshtastic.core.barcode -import android.Manifest import androidx.camera.compose.CameraXViewfinder import androidx.camera.core.CameraSelector import androidx.camera.core.ImageAnalysis @@ -31,11 +28,14 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -52,29 +52,42 @@ import androidx.compose.ui.window.DialogProperties import androidx.core.content.ContextCompat import androidx.lifecycle.compose.LocalLifecycleOwner import co.touchlab.kermit.Logger -import com.google.accompanist.permissions.ExperimentalPermissionsApi -import com.google.accompanist.permissions.isGranted -import com.google.accompanist.permissions.rememberPermissionState import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.asExecutor import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.camera_permission_rationale import org.meshtastic.core.resources.close +import org.meshtastic.core.ui.component.PermissionRecoveryCard import org.meshtastic.core.ui.icon.Close import org.meshtastic.core.ui.icon.MeshtasticIcons import org.meshtastic.core.ui.util.BarcodeScanner +import org.meshtastic.core.ui.util.PermissionStatus +import org.meshtastic.core.ui.util.rememberCameraPermissionState @Composable fun rememberBarcodeScanner(onResult: (String?) -> Unit): BarcodeScanner { var showDialog by remember { mutableStateOf(false) } var pendingScan by remember { mutableStateOf(false) } - val cameraPermissionState = rememberPermissionState(Manifest.permission.CAMERA) + var showPermissionRecovery by remember { mutableStateOf(false) } + val cameraPermission = rememberCameraPermissionState() + val currentStatus = rememberUpdatedState(cameraPermission.status) - LaunchedEffect(cameraPermissionState.status.isGranted) { - if (cameraPermissionState.status.isGranted && pendingScan) { - showDialog = true - pendingScan = false + 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 + pendingScan = false + } } + // Dismiss the recovery card once the permission is granted (e.g. user returned from settings). + if (cameraPermission.isGranted) showPermissionRecovery = false } if (showDialog) { @@ -90,14 +103,28 @@ fun rememberBarcodeScanner(onResult: (String?) -> Unit): BarcodeScanner { ) } + if (showPermissionRecovery) { + Dialog(onDismissRequest = { showPermissionRecovery = false }) { + Surface(shape = MaterialTheme.shapes.large, color = MaterialTheme.colorScheme.surface) { + PermissionRecoveryCard( + state = cameraPermission, + rationale = stringResource(Res.string.camera_permission_rationale), + modifier = Modifier.padding(16.dp), + ) + } + } + } + return remember { object : BarcodeScanner { override fun startScan() { - if (cameraPermissionState.status.isGranted) { - showDialog = true - } else { - pendingScan = true - cameraPermissionState.launchPermissionRequest() + when (currentStatus.value) { + PermissionStatus.GRANTED -> showDialog = true + PermissionStatus.PERMANENTLY_DENIED -> showPermissionRecovery = true + else -> { + pendingScan = true + cameraPermission.request() + } } } } diff --git a/feature/connections/src/androidMain/kotlin/org/meshtastic/feature/connections/AndroidScannerViewModel.kt b/feature/connections/src/androidMain/kotlin/org/meshtastic/feature/connections/AndroidScannerViewModel.kt index 0754671d4..4b89a34db 100644 --- a/feature/connections/src/androidMain/kotlin/org/meshtastic/feature/connections/AndroidScannerViewModel.kt +++ b/feature/connections/src/androidMain/kotlin/org/meshtastic/feature/connections/AndroidScannerViewModel.kt @@ -22,7 +22,11 @@ import co.touchlab.kermit.Severity import kotlinx.coroutines.flow.launchIn 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 @@ -75,7 +79,7 @@ class AndroidScannerViewModel( } catch (ex: SecurityException) { Logger.w(ex) { "Bonding failed for ${entry.device.address.anonymize} Permissions not granted" } serviceRepository.setErrorMessage( - text = "Bonding failed: ${ex.message} Permissions not granted", + text = getString(Res.string.bonding_failed_permissions), severity = Severity.Warn, ) } catch (ex: Exception) { @@ -102,6 +106,10 @@ class AndroidScannerViewModel( changeDeviceAddress(entry.fullAddress) } else { Logger.e { "USB permission denied for device ${entry.address}" } + serviceRepository.setErrorMessage( + text = getString(Res.string.usb_permission_denied), + severity = Severity.Warn, + ) } } .launchIn(viewModelScope) diff --git a/feature/intro/src/androidMain/kotlin/org/meshtastic/feature/intro/AndroidIntroPermissions.kt b/feature/intro/src/androidMain/kotlin/org/meshtastic/feature/intro/AndroidIntroPermissions.kt index 282f42095..34b0821e3 100644 --- a/feature/intro/src/androidMain/kotlin/org/meshtastic/feature/intro/AndroidIntroPermissions.kt +++ b/feature/intro/src/androidMain/kotlin/org/meshtastic/feature/intro/AndroidIntroPermissions.kt @@ -16,40 +16,36 @@ */ package org.meshtastic.feature.intro -import com.google.accompanist.permissions.ExperimentalPermissionsApi -import com.google.accompanist.permissions.MultiplePermissionsState -import com.google.accompanist.permissions.PermissionState -import com.google.accompanist.permissions.isGranted +import org.meshtastic.core.ui.util.PermissionUiState -@OptIn(ExperimentalPermissionsApi::class) internal class AndroidIntroPermissions( - private val bluetoothState: MultiplePermissionsState, - private val locationState: MultiplePermissionsState, - private val notificationState: PermissionState?, + private val bluetoothState: PermissionUiState, + private val locationState: PermissionUiState, + private val notificationState: PermissionUiState?, ) : IntroPermissions { override val bluetooth: IntroPermissionState = object : IntroPermissionState { override val isGranted: Boolean - get() = bluetoothState.allPermissionsGranted + get() = bluetoothState.isGranted - override fun launchRequest() = bluetoothState.launchMultiplePermissionRequest() + override fun launchRequest() = bluetoothState.request() } override val location: IntroPermissionState = object : IntroPermissionState { override val isGranted: Boolean - get() = locationState.allPermissionsGranted + get() = locationState.isGranted - override fun launchRequest() = locationState.launchMultiplePermissionRequest() + override fun launchRequest() = locationState.request() } override val notification: IntroPermissionState? = notificationState?.let { state -> object : IntroPermissionState { override val isGranted: Boolean - get() = state.status.isGranted + get() = state.isGranted - override fun launchRequest() = state.launchPermissionRequest() + override fun launchRequest() = state.request() } } } diff --git a/feature/intro/src/androidMain/kotlin/org/meshtastic/feature/intro/AppIntroductionScreen.kt b/feature/intro/src/androidMain/kotlin/org/meshtastic/feature/intro/AppIntroductionScreen.kt index 1e2d1d0e7..518f12227 100644 --- a/feature/intro/src/androidMain/kotlin/org/meshtastic/feature/intro/AppIntroductionScreen.kt +++ b/feature/intro/src/androidMain/kotlin/org/meshtastic/feature/intro/AppIntroductionScreen.kt @@ -16,7 +16,6 @@ */ package org.meshtastic.feature.intro -import android.Manifest import android.os.Build import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider @@ -24,11 +23,10 @@ import androidx.compose.runtime.remember import androidx.compose.ui.platform.LocalContext import androidx.navigation3.runtime.entryProvider import androidx.navigation3.runtime.rememberNavBackStack -import com.google.accompanist.permissions.ExperimentalPermissionsApi -import com.google.accompanist.permissions.PermissionState -import com.google.accompanist.permissions.rememberMultiplePermissionsState -import com.google.accompanist.permissions.rememberPermissionState import org.meshtastic.core.ui.component.MeshtasticNavDisplay +import org.meshtastic.core.ui.util.rememberBluetoothPermissionState +import org.meshtastic.core.ui.util.rememberLocationPermissionState +import org.meshtastic.core.ui.util.rememberNotificationPermissionState /** * Main application introduction screen. This Composable hosts the navigation flow and hoists the permission states. @@ -36,29 +34,18 @@ import org.meshtastic.core.ui.component.MeshtasticNavDisplay * @param onDone Callback invoked when the introduction flow is completed. * @param viewModel ViewModel for tracking the introduction flow state. */ -@OptIn(ExperimentalPermissionsApi::class) @Composable fun AppIntroductionScreen(onDone: () -> Unit, viewModel: IntroViewModel) { val context = LocalContext.current - val notificationPermissionState: PermissionState? = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - rememberPermissionState(Manifest.permission.POST_NOTIFICATIONS) - } else { - null - } + // Pre-Android 13 has no runtime notification permission, so there is nothing to configure — keep it null so the + // intro flow can skip the notification screen entirely. SDK_INT is constant per process, so the conditional call + // is recomposition-safe. + val notificationPermissionState = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) rememberNotificationPermissionState() else null - val locationPermissions = - listOf(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION) - val locationPermissionState = rememberMultiplePermissionsState(permissions = locationPermissions) - - val bluetoothPermissions = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - listOf(Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_CONNECT) - } else { - emptyList() - } - val bluetoothPermissionState = rememberMultiplePermissionsState(permissions = bluetoothPermissions) + val locationPermissionState = rememberLocationPermissionState() + val bluetoothPermissionState = rememberBluetoothPermissionState() val permissions = remember(notificationPermissionState, locationPermissionState, bluetoothPermissionState) { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 94edf4179..6f7069b90 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -4,7 +4,6 @@ xmlutil = "0.91.3" # Android agp = "9.2.1" appcompat = "1.7.1" -accompanist = "0.37.3" car-app = "1.9.0-alpha01" appfunctions = "1.0.0-alpha09" @@ -245,7 +244,6 @@ turbine = { module = "app.cash.turbine:turbine", version.ref = "turbine" } # Other aboutlibraries-core = { module = "com.mikepenz:aboutlibraries-core", version.ref = "aboutlibraries" } aboutlibraries-compose-m3 = { module = "com.mikepenz:aboutlibraries-compose-m3", version.ref = "aboutlibraries" } -accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanist" } coil = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" } coil-network-ktor3 = { module = "io.coil-kt.coil3:coil-network-ktor3", version.ref = "coil" } coil-svg = { module = "io.coil-kt.coil3:coil-svg", version.ref = "coil" }