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 <noreply@anthropic.com>
This commit is contained in:
James Rich
2026-06-18 11:57:49 -05:00
parent 27dea7c938
commit 6c3b4b7868
10 changed files with 108 additions and 91 deletions

View File

@@ -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)

View File

@@ -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()
}
}
},
)

View File

@@ -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,

View File

@@ -65,7 +65,6 @@ class KmpFeatureConventionPlugin : Plugin<Project> {
}
sourceSets.getByName("androidMain").dependencies {
implementation(libs.library("accompanist-permissions"))
implementation(libs.library("androidx-activity-compose"))
implementation(libs.library("compose-multiplatform-ui"))

View File

@@ -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.

View File

@@ -14,11 +14,8 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@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()
}
}
}
}

View File

@@ -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)

View File

@@ -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()
}
}
}

View File

@@ -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) {

View File

@@ -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" }