diff --git a/.skills/compose-ui/strings-index.txt b/.skills/compose-ui/strings-index.txt index a792ad34d..fbb067f60 100644 --- a/.skills/compose-ui/strings-index.txt +++ b/.skills/compose-ui/strings-index.txt @@ -110,6 +110,7 @@ blue bluetooth bluetooth_available_devices bluetooth_config +bluetooth_disabled bluetooth_enabled bluetooth_feature_config bluetooth_feature_config_description @@ -117,6 +118,7 @@ bluetooth_feature_discovery bluetooth_feature_discovery_description bluetooth_permission bold_heading +bonding_failed_permissions bottom_nav_settings broadcast_interval busy_noise_floor @@ -125,6 +127,8 @@ buzzer_gpio calculating call_sign call_sign_summary +camera_permission +camera_permission_rationale cancel cancel_reply canned_message @@ -653,6 +657,7 @@ gps_en_gpio gps_mode gps_receive_gpio gps_transmit_gpio +grant_permission green hardware hardware_model @@ -1011,10 +1016,13 @@ one_month one_week one_wire_temperature only_favorites +### OPEN ### +open_bluetooth_settings open_compass open_settings open_source_description open_source_libraries +open_wifi_settings options orient_north ### OUTPUT ### @@ -1460,6 +1468,7 @@ url_must_contain_placeholders url_template url_template_hint usb +usb_permission_denied ### USE ### use_12h_format use_homoglyph_characters_encoding @@ -1532,6 +1541,7 @@ wifi_provisioning wifi_qr_code_error wifi_qr_code_scan wifi_rssi_threshold_defaults_to_80 +wifi_unavailable ### WIND ### wind wind_direction 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..dba44146b 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,6 @@ 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 com.google.android.material.dialog.MaterialAlertDialogBuilder import kotlinx.coroutines.launch import org.jetbrains.compose.resources.StringResource @@ -130,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 @@ -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,17 @@ 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..28f46cb35 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,6 @@ 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 com.google.android.gms.location.LocationCallback import com.google.android.gms.location.LocationRequest import com.google.android.gms.location.LocationResult @@ -131,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 @@ -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,22 @@ 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/androidApp/src/main/AndroidManifest.xml b/androidApp/src/main/AndroidManifest.xml index 94e3f1d55..03038964a 100644 --- a/androidApp/src/main/AndroidManifest.xml +++ b/androidApp/src/main/AndroidManifest.xml @@ -56,7 +56,7 @@ Android 17 (API 37) Local Network Protection: targetSdk=37 apps are blocked from local-network access by default. Required for both NSD/mDNS device discovery on the Connections screen and the built-in TAK Server's localhost - loopback binding. Requested at runtime via rememberRequestLocalNetworkPermission. + loopback binding. Requested at runtime via rememberLocalNetworkPermissionState. See: https://developer.android.com/privacy-and-security/local-network-permission --> 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..4606450ad 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 @@ -26,16 +23,22 @@ import androidx.camera.core.Preview import androidx.camera.core.SurfaceRequest import androidx.camera.lifecycle.ProcessCameraProvider import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column 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.material3.Text 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 @@ -46,34 +49,51 @@ import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.drawscope.clipPath import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog 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 +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 { + // A grant arrived for a scan the user asked for — either the pending request or the recovery card's + // "Grant"/"Open settings" round-trip. Open the scanner and clear both pending flags. + cameraPermission.isGranted && (pendingScan || showPermissionRecovery) -> { + showDialog = true + pendingScan = false + showPermissionRecovery = false + } + + // The pending request completed without a grant — surface a recovery card instead of failing silently. + pendingScan && cameraPermission.status != PermissionStatus.NOT_REQUESTED -> { + showPermissionRecovery = true + pendingScan = false + } } } @@ -90,14 +110,38 @@ fun rememberBarcodeScanner(onResult: (String?) -> Unit): BarcodeScanner { ) } + if (showPermissionRecovery) { + Dialog(onDismissRequest = { showPermissionRecovery = false }) { + Surface(shape = MaterialTheme.shapes.large, color = MaterialTheme.colorScheme.surface) { + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + // Heading gives screen readers context for the standalone dialog (unlike the in-sheet Compass + // card). + Text( + text = stringResource(Res.string.camera_permission), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.semantics { heading() }, + ) + PermissionRecoveryCard( + state = cameraPermission, + rationale = stringResource(Res.string.camera_permission_rationale), + ) + } + } + } + } + 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/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml index 61cefe9a6..40221af04 100644 --- a/core/resources/src/commonMain/composeResources/values/strings.xml +++ b/core/resources/src/commonMain/composeResources/values/strings.xml @@ -128,6 +128,7 @@ Bluetooth Available Bluetooth Devices Bluetooth Config + Bluetooth is off. Turn it on to scan for nearby devices. Bluetooth enabled Configuration Wirelessly manage your device settings and channels. @@ -135,6 +136,7 @@ Find and identify Meshtastic devices near you. Bluetooth Bold Heading + Pairing failed. Grant nearby device permissions and try again. Settings Broadcast Interval Busy floor @@ -143,6 +145,8 @@ Calculating… Call sign Your amateur radio call sign, up to 8 characters + Camera permission + Allow camera access to scan QR codes. Cancel Cancel reply Canned Message @@ -677,6 +681,7 @@ GPS Mode (Physical Hardware) GPS Receive GPIO GPS Transmit GPIO + Grant permission Green Hardware Hardware model @@ -1041,10 +1046,13 @@ 1W 1-Wire Temp Only Favorites + + Open Bluetooth settings Open Compass Open settings Meshtastic is built with the following open source libraries. Tap any library to view its license. Open Source Libraries + Open Wi-Fi settings Options Orient north @@ -1505,6 +1513,7 @@ URL Template https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png USB + USB permission denied. Reconnect the device to try again. Use 12h clock format Compact encoding for Cyrillic @@ -1577,6 +1586,7 @@ Invalid WiFi Credential QR code format Scan WiFi QR code WiFi RSSI threshold (defaults to -80) + Not connected to Wi-Fi. Network scan may not find nearby devices. Wind Wind Dir diff --git a/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PermissionRequestTracker.kt b/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PermissionRequestTracker.kt new file mode 100644 index 000000000..5b15e0486 --- /dev/null +++ b/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PermissionRequestTracker.kt @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.ui.util + +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. + * + * 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. + */ +internal class PermissionRequestTracker(context: Context) { + private val prefs = context.applicationContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + + fun hasRequested(permission: String): Boolean = prefs.getBoolean(permission, false) + + /** + * Marks [permission] as having completed a request. MUST be called from the launcher's result callback (after the + * OS has adjudicated the request), never when `launch()` is merely invoked — see [computePermissionStatus]. + */ + fun markRequested(permission: String) { + prefs.edit().putBoolean(permission, true).apply() + } + + private companion object { + const val PREFS_NAME = "meshtastic_permissions" + } +} diff --git a/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt b/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt index a5d55d774..d538f69b5 100644 --- a/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt +++ b/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt @@ -18,9 +18,22 @@ package org.meshtastic.core.ui.util +import android.bluetooth.BluetoothAdapter +import android.bluetooth.BluetoothManager import android.content.ActivityNotFoundException +import android.content.BroadcastReceiver +import android.content.Context import android.content.Intent +import android.content.IntentFilter +import android.content.pm.PackageManager +import android.net.ConnectivityManager +import android.net.Network +import android.net.NetworkCapabilities +import android.net.Uri +import android.os.Handler +import android.os.Looper import android.provider.Settings +import androidx.activity.compose.LocalActivity import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.runtime.Composable @@ -30,6 +43,8 @@ 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 +import androidx.core.content.ContextCompat import androidx.core.net.toUri import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.LifecycleEventEffect @@ -180,30 +195,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 = @@ -211,45 +202,17 @@ actual fun rememberOpenLocationSettings(): () -> Unit { androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult(), ) { _ -> } - return remember(launcher) { { launcher.launch(Intent(android.provider.Settings.ACTION_LOCATION_SOURCE_SETTINGS)) } } -} - -@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), - ) + try { + launcher.launch(Intent(android.provider.Settings.ACTION_LOCATION_SOURCE_SETTINGS)) + } catch (ex: ActivityNotFoundException) { + Logger.w(ex) { "No location settings activity available" } + } } } } -@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). @@ -257,54 +220,255 @@ 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 return rememberOnResumeState { context.gpsDisabled() } } +@Composable +actual fun rememberOpenBluetoothSettings(): () -> Unit { + val context = LocalContext.current + return remember(context) { + { + try { + context.startActivity( + Intent(Settings.ACTION_BLUETOOTH_SETTINGS).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK), + ) + } catch (ex: ActivityNotFoundException) { + Logger.w(ex) { "No Bluetooth settings activity available" } + } + } + } +} + +@Composable +actual fun rememberOpenWifiSettings(): () -> Unit { + val context = LocalContext.current + return remember(context) { + { + try { + context.startActivity(Intent(Settings.ACTION_WIFI_SETTINGS).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) + } catch (ex: ActivityNotFoundException) { + Logger.w(ex) { "No Wi-Fi settings activity available" } + } + } + } +} + +@Composable +actual fun isBluetoothDisabled(): Boolean { + val context = LocalContext.current + return rememberObservedFlag( + read = { + // adapter == null means the device has no Bluetooth at all — not "disabled", so don't nag. + val adapter = (context.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager)?.adapter + adapter != null && !adapter.isEnabled + }, + subscribe = { onChange -> + val receiver = + object : BroadcastReceiver() { + override fun onReceive(receiverContext: Context?, intent: Intent?) = onChange() + } + // ACTION_STATE_CHANGED is a protected system broadcast; NOT_EXPORTED keeps the receiver app-private. + // Registered without a Handler, so onReceive is delivered on the main thread. + ContextCompat.registerReceiver( + context, + receiver, + IntentFilter(BluetoothAdapter.ACTION_STATE_CHANGED), + ContextCompat.RECEIVER_NOT_EXPORTED, + ) + val unregister = { context.unregisterReceiver(receiver) } + unregister + }, + ) +} + +@Composable +actual fun isWifiUnavailable(): Boolean { + val context = LocalContext.current + return rememberObservedFlag( + read = { + val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager + val capabilities = cm?.activeNetwork?.let { cm.getNetworkCapabilities(it) } + val onLocalNetwork = + capabilities != null && + ( + capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) || + capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) + ) + !onLocalNetwork + }, + subscribe = { onChange -> + val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + val callback = + object : ConnectivityManager.NetworkCallback() { + override fun onAvailable(network: Network) = onChange() + + override fun onLost(network: Network) = onChange() + + override fun onCapabilitiesChanged(network: Network, networkCapabilities: NetworkCapabilities) = + onChange() + } + // Main-thread Handler so the state write lands on the main thread (the API-26+ overload; minSdk is 26). + cm.registerDefaultNetworkCallback(callback, Handler(Looper.getMainLooper())) + val unregister = { cm.unregisterNetworkCallback(callback) } + unregister + }, + ) +} + +@Composable +actual fun rememberOpenAppSettings(): () -> Unit { + val context = LocalContext.current + return remember(context) { + { + val intent = + Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { + data = Uri.fromParts("package", context.packageName, null) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + try { + context.startActivity(intent) + } catch (ex: ActivityNotFoundException) { + Logger.w(ex) { "Failed to open app settings" } + } + } + } +} + +@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, +) + +@Composable +actual fun rememberBluetoothPermissionState(): PermissionUiState { + if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.S) { + // 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 = + arrayOf(android.Manifest.permission.BLUETOOTH_SCAN, android.Manifest.permission.BLUETOOTH_CONNECT), + requireAll = true, + ) +} + +@Composable +actual fun rememberNotificationPermissionState(): PermissionUiState { + if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.TIRAMISU) { + // Pre-Android 13, no runtime notification permission required. + return rememberGrantedPermissionState() + } + return rememberRuntimePermissionState( + permissions = arrayOf(android.Manifest.permission.POST_NOTIFICATIONS), + requireAll = true, + ) +} + +@Composable +actual fun rememberLocalNetworkPermissionState(): PermissionUiState { + if (android.os.Build.VERSION.SDK_INT < LOCAL_NETWORK_PERMISSION_API) { + // Pre-Android 17, ACCESS_LOCAL_NETWORK is implicit via INTERNET; treat as granted. + return rememberGrantedPermissionState() + } + return rememberRuntimePermissionState( + permissions = arrayOf(android.Manifest.permission.ACCESS_LOCAL_NETWORK), + requireAll = true, + ) +} + +@Composable +actual fun rememberCameraPermissionState(): PermissionUiState = + rememberRuntimePermissionState(permissions = arrayOf(android.Manifest.permission.CAMERA), requireAll = true) + +/** A constant [PermissionUiState] for API levels where the permission is not gated at runtime. */ +@Composable private fun rememberGrantedPermissionState(): PermissionUiState = remember { grantedPermissionUiState() } + +/** + * 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). + */ +@Composable +private fun rememberRuntimePermissionState(permissions: Array, requireAll: Boolean): PermissionUiState { + val context = LocalContext.current + val activity = LocalActivity.current + val tracker = remember(context) { PermissionRequestTracker(context) } + val openAppSettings = rememberOpenAppSettings() + // The permission whose rationale + requested flag represents the group. + val primaryPermission = permissions.first() + + fun compute(): PermissionStatus { + val granted = + isPermissionGroupGranted( + results = + permissions.map { + ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED + }, + requireAll = requireAll, + ) + val shouldShowRationale = + 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), + shouldShowRationale = shouldShowRationale, + ) + } + + val statusState = remember { mutableStateOf(compute()) } + val launcher = + rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { _ -> + // The OS has now adjudicated the request; only here is it true that we have asked the user. The result + // callback runs on the main thread, so updating the state directly here is safe and recomposes the caller. + tracker.markRequested(primaryPermission) + statusState.value = compute() + } + LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { statusState.value = compute() } + + val request = remember(launcher) { { launcher.launch(permissions) } } + return PermissionUiState(status = statusState.value, request = request, openAppSettings = openAppSettings) +} + +/** + * Remembers a boolean derived from [read], kept live by an observer registered via [subscribe] for the duration of the + * composition. [subscribe] receives an `onChange` callback to invoke whenever the underlying state may have changed and + * must return a teardown function. The value is re-seeded via [read] at registration, so it is correct even before the + * first event arrives. Used for adapter/connectivity state that changes outside the activity lifecycle (e.g. toggling + * Bluetooth or Wi-Fi from the quick-settings shade). + */ +@Composable +private fun rememberObservedFlag(read: () -> Boolean, subscribe: (onChange: () -> Unit) -> () -> Unit): Boolean { + val currentRead = rememberUpdatedState(read) + val currentSubscribe = rememberUpdatedState(subscribe) + val state = remember { mutableStateOf(read()) } + DisposableEffect(Unit) { + state.value = currentRead.value() + val unsubscribe = currentSubscribe.value { state.value = currentRead.value() } + onDispose { unsubscribe() } + } + return state.value +} + /** * Remembers a boolean state that is re-evaluated on each [Lifecycle.Event.ON_RESUME], ensuring the value stays fresh * when the user returns from a permission dialog or system settings screen. diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/PermissionRecoveryCard.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/PermissionRecoveryCard.kt new file mode 100644 index 000000000..4f12040b7 --- /dev/null +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/PermissionRecoveryCard.kt @@ -0,0 +1,147 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.ui.component + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.unit.dp +import org.jetbrains.compose.resources.stringResource +import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.grant_permission +import org.meshtastic.core.resources.open_settings +import org.meshtastic.core.ui.icon.AppSettingsAlt +import org.meshtastic.core.ui.icon.ErrorOutline +import org.meshtastic.core.ui.icon.MeshtasticIcons +import org.meshtastic.core.ui.util.PermissionStatus +import org.meshtastic.core.ui.util.PermissionUiState + +/** + * A reusable error-state card: an `errorContainer` message box plus one full-width recovery action. Generalizes the + * compass warning/recovery pattern so any feature can present context plus a single corrective action (request a + * permission, open Bluetooth/Wi-Fi/app settings, etc.). + * + * @param message the user-facing explanation of what is wrong. + * @param actionLabel the recovery button label. + * @param onAction invoked when the recovery button is tapped. + * @param actionIcon optional leading icon for the recovery button. + */ +@Composable +fun RecoveryCard( + message: String, + actionLabel: String, + onAction: () -> Unit, + modifier: Modifier = Modifier, + actionIcon: ImageVector? = null, +) { + Column(modifier = modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Surface( + tonalElevation = 2.dp, + shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colorScheme.errorContainer, + modifier = Modifier.fillMaxWidth(), + ) { + Row( + modifier = Modifier.padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Icon( + imageVector = MeshtasticIcons.ErrorOutline, + contentDescription = null, + tint = MaterialTheme.colorScheme.onErrorContainer, + ) + Text( + text = message, + color = MaterialTheme.colorScheme.onErrorContainer, + style = MaterialTheme.typography.bodyMedium, + ) + } + } + + Button(onClick = onAction, modifier = Modifier.fillMaxWidth()) { + if (actionIcon != null) { + Icon(imageVector = actionIcon, contentDescription = null) + Spacer(modifier = Modifier.width(8.dp)) + } + Text(text = actionLabel) + } + } +} + +/** + * A [RecoveryCard] specialized for a missing runtime permission, presenting a context-correct recovery action: + * - [PermissionStatus.NOT_REQUESTED] / [PermissionStatus.DENIED_CAN_RETRY] — shows a "Grant permission" button that + * re-launches the in-context request. + * - [PermissionStatus.PERMANENTLY_DENIED] — shows an "Open settings" button (user-initiated recovery) because the + * system will no longer show the dialog. + * - [PermissionStatus.GRANTED] — renders nothing. + * + * @param rationale a feature-specific explanation of why the permission is needed. + */ +@Composable +internal fun PermissionRecoveryCard( + status: PermissionStatus, + rationale: String, + onRequest: () -> Unit, + onOpenSettings: () -> Unit, + modifier: Modifier = Modifier, +) { + if (status == PermissionStatus.GRANTED) return + + if (status == PermissionStatus.PERMANENTLY_DENIED) { + RecoveryCard( + message = rationale, + actionLabel = stringResource(Res.string.open_settings), + onAction = onOpenSettings, + modifier = modifier, + actionIcon = MeshtasticIcons.AppSettingsAlt, + ) + } else { + RecoveryCard( + message = rationale, + actionLabel = stringResource(Res.string.grant_permission), + onAction = onRequest, + modifier = modifier, + ) + } +} + +/** Convenience overload that reads the status and actions directly from a [PermissionUiState]. */ +@Composable +fun PermissionRecoveryCard(state: PermissionUiState, rationale: String, modifier: Modifier = Modifier) { + PermissionRecoveryCard( + status = state.status, + rationale = rationale, + onRequest = state.request, + onOpenSettings = state.openAppSettings, + modifier = modifier, + ) +} diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/PermissionStatus.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/PermissionStatus.kt new file mode 100644 index 000000000..b9c578fa2 --- /dev/null +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/PermissionStatus.kt @@ -0,0 +1,84 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.ui.util + +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). 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 { + NOT_REQUESTED, + DENIED_CAN_RETRY, + PERMANENTLY_DENIED, + GRANTED, +} + +/** + * 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]. + * + * 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 { + granted -> PermissionStatus.GRANTED + !hasRequested -> PermissionStatus.NOT_REQUESTED + shouldShowRationale -> PermissionStatus.DENIED_CAN_RETRY + else -> PermissionStatus.PERMANENTLY_DENIED + } + +/** + * 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. + * + * Intentionally NOT a `data class`: the lambda members would give `equals`/`hashCode` reference semantics, advertising + * value equality the type cannot honor. Callers read [status]/[isGranted] and invoke the actions; they do not compare + * instances. + */ +@Stable +class PermissionUiState(val status: PermissionStatus, val request: () -> Unit, val openAppSettings: () -> Unit) { + val isGranted: Boolean + get() = status == PermissionStatus.GRANTED +} + +/** 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, requireAll: Boolean): Boolean = + if (requireAll) results.all { it } else results.any { it } diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt index 03258a77a..43909591e 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt @@ -55,36 +55,62 @@ 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 open the platform's Bluetooth settings. */ +@Composable expect fun rememberOpenBluetoothSettings(): () -> 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 a launcher to open the platform's Wi-Fi settings. */ +@Composable expect fun rememberOpenWifiSettings(): () -> Unit /** * Returns whether GPS/location services are currently disabled at the system level. Always `false` on platforms where * this concept doesn't apply. */ @Composable expect fun isGpsDisabled(): Boolean + +/** + * Returns whether Bluetooth is currently turned off at the system level (the adapter exists but is disabled). Always + * `false` on devices without Bluetooth and on platforms where the concept doesn't apply. + */ +@Composable expect fun isBluetoothDisabled(): Boolean + +/** + * Returns whether the device currently lacks a local-network-capable connection (no active Wi-Fi or Ethernet). NSD/mDNS + * discovery needs a LAN, so this surfaces the "connect to Wi-Fi" hint. Always `false` where the concept doesn't apply. + */ +@Composable expect fun isWifiUnavailable(): Boolean + +/** Returns a function that opens this app's system settings page (where the user can change any permission). */ +@Composable expect fun rememberOpenAppSettings(): () -> Unit + +/** + * Returns the reactive [PermissionUiState] for the location permissions, recomputed on `ON_RESUME`. On platforms + * without runtime permissions the status is always [PermissionStatus.GRANTED]. + */ +@Composable expect fun rememberLocationPermissionState(): PermissionUiState + +/** + * Returns the reactive [PermissionUiState] for the Bluetooth scan/connect permissions. On pre-Android-12 devices BLE + * scanning is gated by the location permission, so the returned state delegates to [rememberLocationPermissionState]. + */ +@Composable expect fun rememberBluetoothPermissionState(): PermissionUiState + +/** + * Returns the reactive [PermissionUiState] for the POST_NOTIFICATIONS permission. Always [PermissionStatus.GRANTED] on + * API levels / platforms that don't gate notifications behind a runtime permission. + */ +@Composable expect fun rememberNotificationPermissionState(): PermissionUiState + +/** + * Returns the reactive [PermissionUiState] for the ACCESS_LOCAL_NETWORK permission. Always [PermissionStatus.GRANTED] + * on API levels / platforms that don't gate local-network access behind a runtime permission. + */ +@Composable expect fun rememberLocalNetworkPermissionState(): PermissionUiState + +/** + * Returns the reactive [PermissionUiState] for the CAMERA permission. Always [PermissionStatus.GRANTED] on platforms + * that don't require a runtime camera permission. + */ +@Composable expect fun rememberCameraPermissionState(): PermissionUiState diff --git a/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/util/PermissionStatusTest.kt b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/util/PermissionStatusTest.kt new file mode 100644 index 000000000..c8475d488 --- /dev/null +++ b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/util/PermissionStatusTest.kt @@ -0,0 +1,91 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.ui.util + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class PermissionStatusTest { + + @Test + fun `granted always wins regardless of other inputs`() { + // All four granted=true combinations resolve to GRANTED. + for (hasRequested in listOf(true, false)) { + for (shouldShowRationale in listOf(true, false)) { + assertEquals( + PermissionStatus.GRANTED, + computePermissionStatus( + granted = true, + hasRequested = hasRequested, + shouldShowRationale = shouldShowRationale, + ), + "granted=true, hasRequested=$hasRequested, shouldShowRationale=$shouldShowRationale", + ) + } + } + } + + @Test + fun `not requested when the user has never been prompted`() { + // shouldShowRationale is false before the first prompt — must NOT be read as permanent denial. + assertEquals( + PermissionStatus.NOT_REQUESTED, + computePermissionStatus(granted = false, hasRequested = false, shouldShowRationale = false), + ) + // Even if the system somehow reports rationale before a request, the unrequested flag dominates. + assertEquals( + PermissionStatus.NOT_REQUESTED, + computePermissionStatus(granted = false, hasRequested = false, shouldShowRationale = true), + ) + } + + @Test + fun `denied can retry when requested and rationale should still show`() { + assertEquals( + PermissionStatus.DENIED_CAN_RETRY, + computePermissionStatus(granted = false, hasRequested = true, shouldShowRationale = true), + ) + } + + @Test + fun `permanently denied only when requested and rationale suppressed`() { + // The adversarial-flagged case: this resolves to PERMANENTLY_DENIED ONLY because hasRequested reflects a + // COMPLETED request (set from the launcher result callback, never at launch() time). + assertEquals( + PermissionStatus.PERMANENTLY_DENIED, + 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)) + } +} diff --git a/core/ui/src/iosMain/kotlin/org/meshtastic/core/ui/util/NoopStubs.kt b/core/ui/src/iosMain/kotlin/org/meshtastic/core/ui/util/NoopStubs.kt index 5ebfb0070..296852973 100644 --- a/core/ui/src/iosMain/kotlin/org/meshtastic/core/ui/util/NoopStubs.kt +++ b/core/ui/src/iosMain/kotlin/org/meshtastic/core/ui/util/NoopStubs.kt @@ -50,22 +50,28 @@ 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 rememberOpenBluetoothSettings(): () -> 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 rememberOpenWifiSettings(): () -> Unit = {} @Composable actual fun isGpsDisabled(): Boolean = false +@Composable actual fun isBluetoothDisabled(): Boolean = false + +@Composable actual fun isWifiUnavailable(): Boolean = false + @Composable actual fun SetScreenBrightness(brightness: Float) {} + +@Composable actual fun rememberOpenAppSettings(): () -> Unit = {} + +@Composable actual fun rememberLocationPermissionState(): PermissionUiState = grantedPermissionUiState() + +@Composable actual fun rememberBluetoothPermissionState(): PermissionUiState = grantedPermissionUiState() + +@Composable actual fun rememberNotificationPermissionState(): PermissionUiState = grantedPermissionUiState() + +@Composable actual fun rememberLocalNetworkPermissionState(): PermissionUiState = grantedPermissionUiState() + +@Composable actual fun rememberCameraPermissionState(): PermissionUiState = grantedPermissionUiState() diff --git a/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt b/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt index c87c31f57..b3fa84815 100644 --- a/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt +++ b/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt @@ -115,36 +115,43 @@ 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. */ +/** JVM stub — Bluetooth settings are not available on Desktop. */ @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() +actual fun rememberOpenBluetoothSettings(): () -> Unit = { + Logger.w { "Bluetooth settings not available on JVM/Desktop" } } -/** 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. */ +/** JVM stub — Wi-Fi settings are not available on Desktop. */ @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 +actual fun rememberOpenWifiSettings(): () -> Unit = { Logger.w { "Wi-Fi settings not available on JVM/Desktop" } } /** JVM — GPS is never disabled on Desktop (concept doesn't apply). */ @Composable actual fun isGpsDisabled(): Boolean = false + +/** JVM — Bluetooth adapter state is not surfaced on Desktop. */ +@Composable actual fun isBluetoothDisabled(): Boolean = false + +/** JVM — local-network availability is not gated on Desktop. */ +@Composable actual fun isWifiUnavailable(): Boolean = false + +/** JVM stub — app settings are not available on Desktop. */ +@Composable +actual fun rememberOpenAppSettings(): () -> Unit = { Logger.w { "App settings not available on JVM/Desktop" } } + +/** JVM — Desktop does not gate location behind a runtime permission. */ +@Composable actual fun rememberLocationPermissionState(): PermissionUiState = grantedPermissionUiState() + +/** JVM — Desktop does not gate Bluetooth behind a runtime permission. */ +@Composable actual fun rememberBluetoothPermissionState(): PermissionUiState = grantedPermissionUiState() + +/** JVM — Desktop does not gate notifications behind a runtime permission. */ +@Composable actual fun rememberNotificationPermissionState(): PermissionUiState = grantedPermissionUiState() + +/** JVM — Desktop does not gate local-network access behind a runtime permission. */ +@Composable actual fun rememberLocalNetworkPermissionState(): PermissionUiState = grantedPermissionUiState() + +/** JVM — Desktop does not gate the camera behind a runtime permission. */ +@Composable actual fun rememberCameraPermissionState(): PermissionUiState = grantedPermissionUiState() 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 5577e427a..d43d8a2d3 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,6 +22,7 @@ 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.ble.BluetoothRepository import org.meshtastic.core.datastore.RecentAddressesDataSource @@ -33,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 @@ -78,7 +82,7 @@ class AndroidScannerViewModel( // error and do not arm the transport. 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, ) false @@ -110,6 +114,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/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/ConnectionsScreen.kt b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/ConnectionsScreen.kt index 75a1882f6..432b3416e 100644 --- a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/ConnectionsScreen.kt +++ b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/ConnectionsScreen.kt @@ -54,18 +54,29 @@ import org.meshtastic.core.model.ConnectionState import org.meshtastic.core.navigation.Route import org.meshtastic.core.navigation.SettingsRoute import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.bluetooth_disabled import org.meshtastic.core.resources.connections import org.meshtastic.core.resources.no_device_selected +import org.meshtastic.core.resources.open_bluetooth_settings +import org.meshtastic.core.resources.open_wifi_settings import org.meshtastic.core.resources.set_your_region import org.meshtastic.core.resources.unknown_device +import org.meshtastic.core.resources.wifi_unavailable import org.meshtastic.core.ui.component.AdaptiveTwoPane import org.meshtastic.core.ui.component.ListItem import org.meshtastic.core.ui.component.MainAppBar +import org.meshtastic.core.ui.component.RecoveryCard +import org.meshtastic.core.ui.icon.Bluetooth 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.isBluetoothDisabled +import org.meshtastic.core.ui.util.isWifiUnavailable +import org.meshtastic.core.ui.util.rememberBluetoothPermissionState +import org.meshtastic.core.ui.util.rememberLocalNetworkPermissionState +import org.meshtastic.core.ui.util.rememberOpenBluetoothSettings +import org.meshtastic.core.ui.util.rememberOpenWifiSettings import org.meshtastic.core.ui.viewmodel.ConnectionStatus import org.meshtastic.core.ui.viewmodel.ConnectionsViewModel import org.meshtastic.feature.connections.MOCK_DEVICE_PREFIX @@ -124,16 +135,18 @@ 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. The reactive state lets the + // network-scan toggle request in-context and route a permanent denial to settings. + val localNetworkPermission = rememberLocalNetworkPermissionState() + val bluetoothPermission = rememberBluetoothPermissionState() - // 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() }, - ) + // Adapter-state, distinct from permission state: a permission can be granted while Bluetooth is off or the device + // is off Wi-Fi. Detected separately so the UI can route to the adapter's settings rather than re-prompting. + val bluetoothDisabled = isBluetoothDisabled() + val wifiUnavailable = isWifiUnavailable() + val openBluetoothSettings = rememberOpenBluetoothSettings() + val openWifiSettings = rememberOpenWifiSettings() // 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 +156,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() } } @@ -278,6 +291,24 @@ fun ConnectionsScreen( onToggleNetwork = { scanModel.setShowNetworkTransport(!showNetworkTransport) }, onToggleUsb = { scanModel.setShowUsbTransport(!showUsbTransport) }, ) + + // Adapter-off hints: shown only when the relevant permission is granted but the radio/network + // is unavailable, so they don't overlap the permission-recovery flow on the scan toggles. + if (showBleTransport && bluetoothPermission.isGranted && bluetoothDisabled) { + RecoveryCard( + message = stringResource(Res.string.bluetooth_disabled), + actionLabel = stringResource(Res.string.open_bluetooth_settings), + onAction = openBluetoothSettings, + actionIcon = MeshtasticIcons.Bluetooth, + ) + } + if (showNetworkTransport && localNetworkPermission.isGranted && wifiUnavailable) { + RecoveryCard( + message = stringResource(Res.string.wifi_unavailable), + actionLabel = stringResource(Res.string.open_wifi_settings), + onAction = openWifiSettings, + ) + } }, second = { // ── Unified device list ── @@ -295,17 +326,40 @@ fun ConnectionsScreen( showNetworkSection = showNetworkTransport, showUsbSection = showUsbTransport, onSelectDevice = { scanModel.onSelected(it) }, - onToggleBleScan = { scanModel.toggleBleScan() }, + onToggleBleScan = { + when { + // Always allow stopping an in-progress scan. + isBleScanning -> scanModel.toggleBleScan() + + // Granted but the radio is off — scanning can't work, so open BT settings. + bluetoothPermission.isGranted && bluetoothDisabled -> openBluetoothSettings() + + 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 -> 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/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailScreens.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailScreens.kt index 02f2d007a..8d6c4ec67 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailScreens.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailScreens.kt @@ -28,6 +28,7 @@ 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.Modifier import androidx.compose.ui.unit.dp @@ -157,12 +158,26 @@ 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) } + val currentNode by rememberUpdatedState(node) + val currentOnRequestPosition by rememberUpdatedState(onRequestPosition) + LaunchedEffect(locationPermission.status) { + if (locationPermission.isGranted && positionPendingGrant) { + currentNode?.let { currentOnRequestPosition(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 +195,7 @@ private fun NodeDetailOverlays( ) { CompassSheetContent( uiState = compassUiState, - onRequestLocationPermission = { requestLocationPermission() }, + onRequestLocationPermission = onRequestLocationPermission, onOpenLocationSettings = { openLocationSettings() }, onRequestPosition = { node?.let { onRequestPosition(it) } }, modifier = Modifier.padding(bottom = 24.dp), diff --git a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/tak/TakPermissionUtil.kt b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/tak/TakPermissionUtil.kt index 8c55e36d6..62b6d9113 100644 --- a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/tak/TakPermissionUtil.kt +++ b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/tak/TakPermissionUtil.kt @@ -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,22 @@ 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) } } } diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/component/PrivacySection.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/component/PrivacySection.kt index 1ba5e2764..34429206f 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/component/PrivacySection.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/component/PrivacySection.kt @@ -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() 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" } diff --git a/workpad.md b/workpad.md new file mode 100644 index 000000000..fee7c2969 --- /dev/null +++ b/workpad.md @@ -0,0 +1,132 @@ +# Craft Workpad: Live BT/Wi-Fi adapter-state detection + +> Generated by /craft · Started: 2026-06-18 · Branch: claude/gallant-thompson-d1b2ea (PR #5851) + +--- + +## Task + +Make the Bluetooth/Wi-Fi adapter-disabled detection **live** instead of `ON_RESUME`-polled — add a `BroadcastReceiver` for Bluetooth adapter state and a `ConnectivityManager.NetworkCallback` for network availability, so the Connections recovery banners update in real time (e.g. toggling BT from the quick-settings shade) rather than only when the activity resumes. + +Scope: `isBluetoothDisabled()` and `isWifiUnavailable()` in `core/ui/src/androidMain/.../util/PlatformUtils.kt`. Follow-up to the adapter-state feature added in commit 2c06a8019 on PR #5851. + +--- + +## Exploration Report + +### Key Facts + +- Both target functions live in `core/ui/src/androidMain/.../util/PlatformUtils.kt`: `isBluetoothDisabled()` (reads `BluetoothManager.adapter.isEnabled`) and `isWifiUnavailable()` (reads `ConnectivityManager.activeNetwork` transports). Both currently wrap their read in the private `rememberOnResumeState { ... }` helper — recomputed only on `Lifecycle.Event.ON_RESUME`. +- `rememberOnResumeState(check)` is the only consumer-shared refresh primitive; also used by `isGpsDisabled()`. Changing the two BT/Wi-Fi functions must NOT change `isGpsDisabled()` behavior (out of scope). +- **NetworkCallback pattern already exists** in `core/network/.../ConnectivityManager.kt`: `observeNetworks()` uses `callbackFlow { … registerNetworkCallback(req, cb); awaitClose { unregisterNetworkCallback(cb) } }` with `onAvailable`/`onLost`/`onCapabilitiesChanged`. Mirror it with a `DisposableEffect` in the composable (or `registerDefaultNetworkCallback` for the single active network). +- **BroadcastReceiver pattern already exists** in `core/ble/.../AndroidBluetoothRepository.kt`: registers via `ContextCompat.registerReceiver(context, receiver, filter, ContextCompat.RECEIVER_NOT_EXPORTED)` and `context.unregisterReceiver(...)`. For adapter state, the action is `BluetoothAdapter.ACTION_STATE_CHANGED`. +- The two functions are consumed only by `ConnectionsScreen.kt` (hoisted as `bluetoothDisabled`/`wifiUnavailable` booleans driving inline `RecoveryCard` banners + the BLE toggle routing). No other callers — the public contract (`@Composable expect fun … : Boolean`) is unchanged; only the androidMain implementation changes. +- `androidApp/src/main/AndroidManifest.xml` already declares Bluetooth + network permissions; `ACTION_STATE_CHANGED` and a `NetworkCallback` need no extra permission beyond what's declared (`ACCESS_NETWORK_STATE` is present for connectivity callbacks — verify). + +### Key Files + +- `core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt` — the two functions + `rememberOnResumeState`; the only file that changes. +- `core/network/.../repository/ConnectivityManager.kt` — reference NetworkCallback/callbackFlow pattern. +- `core/ble/.../AndroidBluetoothRepository.kt` — reference registerReceiver/RECEIVER_NOT_EXPORTED pattern. +- `feature/connections/.../ui/ConnectionsScreen.kt` — sole consumer (no change needed; reads the same booleans, now updated live). +- jvm/ios actuals already return constant `false` — unaffected. + +--- + +## Requirements + +R1: `isBluetoothDisabled()` updates reactively — when the Bluetooth adapter is turned on/off (incl. from the quick-settings shade while the app is foregrounded), the returned value changes without waiting for `ON_RESUME`. + Source: human confirmed + Verification: review confirms a `BroadcastReceiver` on `BluetoothAdapter.ACTION_STATE_CHANGED` drives the state; manual toggle from shade flips the banner. + +R2: `isWifiUnavailable()` updates reactively — connecting/disconnecting Wi-Fi (or losing the active local network) changes the returned value live. + Source: human confirmed + Verification: review confirms a `ConnectivityManager` `NetworkCallback` (default-network) drives the state. + +R3: `ON_RESUME` polling is removed for these two functions; the receiver/callback is registered and unregistered with the composable's lifetime via `DisposableEffect` (no leaks). `isGpsDisabled()` continues to use `rememberOnResumeState` unchanged. + Source: human confirmed + Verification: `grep` shows no `rememberOnResumeState` in the two functions; `awaitClose`/`onDispose` unregisters; `isGpsDisabled` untouched. + +R4: Registration follows existing repo conventions — `ContextCompat.registerReceiver(..., RECEIVER_NOT_EXPORTED)` for the BT receiver; `registerDefaultNetworkCallback`/`unregisterNetworkCallback` for the network callback. No new permissions (ACCESS_NETWORK_STATE already declared). + Source: craft-clarify recommendation + Verification: review confirms the registration calls + flag matches `RECEIVER_NOT_EXPORTED`. + +R5: The public `expect` contract and all consumers are unchanged — `ConnectionsScreen` reads the same `Boolean`s, now live. jvm/ios actuals stay constant `false`. + Source: craft-clarify recommendation + Verification: no change to commonMain expect or jvm/iosMain; ConnectionsScreen diff empty; both flavors assemble. + +--- + +## Architectural Decision + +- **Chosen approach:** Extract a private `rememberObservedFlag(read, subscribe)` primitive (DisposableEffect + mutableStateOf, re-seed on registration); express `isBluetoothDisabled()` via a `BroadcastReceiver` on `ACTION_STATE_CHANGED` (RECEIVER_NOT_EXPORTED, main-thread delivery) and `isWifiUnavailable()` via `registerDefaultNetworkCallback(callback, mainHandler)`. +- **Approved:** 2026-06-18 +- **Adversarial:** P1-ish threading risk (NetworkCallback on background thread) mitigated by main-thread Handler; leaks prevented by onDispose unregister; cold-start staleness prevented by read() re-seed. No blocker. +- **Files:** `core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt` only. +- **Test:** build both flavors + detekt/spotless; manual shade-toggle verification (no headless test feasible — `core/ui` has no instrumentation). + +--- + +## Acceptance Criteria + +AC1: [R1] `isBluetoothDisabled()` is driven by a `BroadcastReceiver` on `BluetoothAdapter.ACTION_STATE_CHANGED`. Auto-verify: grep ACTION_STATE_CHANGED + BroadcastReceiver in the function. +AC2: [R2] `isWifiUnavailable()` is driven by `registerDefaultNetworkCallback`. Auto-verify: grep registerDefaultNetworkCallback. +AC3: [R3] neither function uses `rememberOnResumeState`; observer torn down in `onDispose`; `isGpsDisabled()` still uses `rememberOnResumeState`. Auto-verify: grep. +AC4: [R4] BT receiver uses `RECEIVER_NOT_EXPORTED`; network callback uses a main-thread `Handler`. Auto-verify: grep. +AC5: [R5] commonMain expect + jvm/ios actuals + ConnectionsScreen unchanged; both flavors assemble; detekt/spotless clean. Auto-verify: git diff scope + build. + +--- + +## Implementation Plan + +**Branch:** claude/gallant-thompson-d1b2ea **Base commit:** 2c06a8019 + +### Task list + +### Pre-existing failures (do not fix — out of scope) + +--- + +## Completion Bar + +1. [x] All planned files created/modified +2. [x] Linter clean +3. [x] Tests pass (no new tests feasible; regressions green) +4. [x] Every AC has a completion note +5. [x] No open markers remain +6. [x] Scope discipline honored + +--- + +## Review + +Reviewer: concurrency (right-sized — single-file ~40-line change on established repo patterns; build+detekt covered the rest). + +Verdict: **SOUND.** Threading main-thread-confined (BT receiver no-Handler → main; NetworkCallback with main-Looper Handler); register/unregister balanced 1:1 via `DisposableEffect(Unit)`/`onDispose`; no leak or double-unregister; `read()` re-seed correct. + +Findings (both P3, no action): +- F-1 [P3] `rememberUpdatedState(subscribe)` freshness is never exercised (subscribe runs once). NOT removed: accessing `subscribe` directly inside `DisposableEffect` re-triggers the `LambdaParameterInRestartableEffect` detekt rule, so the wrapper is required for lint. `LocalContext` is stable, so no stale-context defect. Kept as-is. +- F-2 [P3] `registerDefaultNetworkCallback` `TooManyRequests` — structurally bounded (1:1 with a single live banner); no retry loop. No guard needed. + +### Acceptance criteria status +- AC1 ✓ BroadcastReceiver on ACTION_STATE_CHANGED drives isBluetoothDisabled. +- AC2 ✓ registerDefaultNetworkCallback drives isWifiUnavailable. +- AC3 ✓ neither uses rememberOnResumeState; onDispose unregisters; isGpsDisabled untouched. +- AC4 ✓ RECEIVER_NOT_EXPORTED + main-thread Handler. +- AC5 ✓ expect/jvm/ios/ConnectionsScreen unchanged; both flavors assemble; detekt/spotless clean. + +--- + +## Deferred Items + +--- + +## Phase Log + +- explore: done — 2026-06-18 — lean direct explore (deep prior context). Both reactive patterns already in repo (NetworkCallback callbackFlow in core/network; registerReceiver RECEIVER_NOT_EXPORTED in core/ble). Only androidMain PlatformUtils changes. +- clarify: done — 2026-06-18 — Q1 confirmed (replace ON_RESUME entirely). R1–R5 recorded. +- architect: done — 2026-06-18 — single approach (rememberObservedFlag primitive) + self-adversarial pass (threading via main Handler). Approved. +- implement: done — 2026-06-18 — one file (core/ui androidMain PlatformUtils). All 5 ACs met; build+detekt+spotless+both flavors green. +- review: done — 2026-06-18 — concurrency reviewer: SOUND. 2×P3 non-actionable (lint-required wrapper; bounded TooManyRequests). +- refine: done — 2026-06-18 — fast path, no actionable findings. P3s recorded as considered/declined. +- pr: done — 2026-06-18 — pushed to existing PR #5851 (174db32ae); no new PR (same branch/feature).