mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-06-25 22:15:33 -04:00
feat(permissions): runtime-permission + adapter-state recovery UX; remove Accompanist (#5851)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
10
.skills/compose-ui/strings-index.txt
generated
10
.skills/compose-ui/strings-index.txt
generated
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
-->
|
||||
<uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" />
|
||||
|
||||
@@ -65,7 +65,6 @@ class KmpFeatureConventionPlugin : Plugin<Project> {
|
||||
}
|
||||
|
||||
sourceSets.getByName("androidMain").dependencies {
|
||||
implementation(libs.library("accompanist-permissions"))
|
||||
implementation(libs.library("androidx-activity-compose"))
|
||||
|
||||
implementation(libs.library("compose-multiplatform-ui"))
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -14,11 +14,8 @@
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
@file:OptIn(ExperimentalPermissionsApi::class)
|
||||
|
||||
package org.meshtastic.core.barcode
|
||||
|
||||
import android.Manifest
|
||||
import androidx.camera.compose.CameraXViewfinder
|
||||
import androidx.camera.core.CameraSelector
|
||||
import androidx.camera.core.ImageAnalysis
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,6 +128,7 @@
|
||||
<string name="bluetooth">Bluetooth</string>
|
||||
<string name="bluetooth_available_devices">Available Bluetooth Devices</string>
|
||||
<string name="bluetooth_config">Bluetooth Config</string>
|
||||
<string name="bluetooth_disabled">Bluetooth is off. Turn it on to scan for nearby devices.</string>
|
||||
<string name="bluetooth_enabled">Bluetooth enabled</string>
|
||||
<string name="bluetooth_feature_config">Configuration</string>
|
||||
<string name="bluetooth_feature_config_description">Wirelessly manage your device settings and channels.</string>
|
||||
@@ -135,6 +136,7 @@
|
||||
<string name="bluetooth_feature_discovery_description">Find and identify Meshtastic devices near you.</string>
|
||||
<string name="bluetooth_permission">Bluetooth</string>
|
||||
<string name="bold_heading">Bold Heading</string>
|
||||
<string name="bonding_failed_permissions">Pairing failed. Grant nearby device permissions and try again.</string>
|
||||
<string name="bottom_nav_settings">Settings</string>
|
||||
<string name="broadcast_interval">Broadcast Interval</string>
|
||||
<string name="busy_noise_floor">Busy floor</string>
|
||||
@@ -143,6 +145,8 @@
|
||||
<string name="calculating">Calculating…</string>
|
||||
<string name="call_sign">Call sign</string>
|
||||
<string name="call_sign_summary">Your amateur radio call sign, up to 8 characters</string>
|
||||
<string name="camera_permission">Camera permission</string>
|
||||
<string name="camera_permission_rationale">Allow camera access to scan QR codes.</string>
|
||||
<string name="cancel">Cancel</string>
|
||||
<string name="cancel_reply">Cancel reply</string>
|
||||
<string name="canned_message">Canned Message</string>
|
||||
@@ -677,6 +681,7 @@
|
||||
<string name="gps_mode">GPS Mode (Physical Hardware)</string>
|
||||
<string name="gps_receive_gpio">GPS Receive GPIO</string>
|
||||
<string name="gps_transmit_gpio">GPS Transmit GPIO</string>
|
||||
<string name="grant_permission">Grant permission</string>
|
||||
<string name="green">Green</string>
|
||||
<string name="hardware">Hardware</string>
|
||||
<string name="hardware_model">Hardware model</string>
|
||||
@@ -1041,10 +1046,13 @@
|
||||
<string name="one_week">1W</string>
|
||||
<string name="one_wire_temperature">1-Wire Temp</string>
|
||||
<string name="only_favorites">Only Favorites</string>
|
||||
<!-- OPEN -->
|
||||
<string name="open_bluetooth_settings">Open Bluetooth settings</string>
|
||||
<string name="open_compass">Open Compass</string>
|
||||
<string name="open_settings">Open settings</string>
|
||||
<string name="open_source_description">Meshtastic is built with the following open source libraries. Tap any library to view its license.</string>
|
||||
<string name="open_source_libraries">Open Source Libraries</string>
|
||||
<string name="open_wifi_settings">Open Wi-Fi settings</string>
|
||||
<string name="options">Options</string>
|
||||
<string name="orient_north">Orient north</string>
|
||||
<!-- OUTPUT -->
|
||||
@@ -1505,6 +1513,7 @@
|
||||
<string name="url_template">URL Template</string>
|
||||
<string name="url_template_hint" translatable="false">https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png</string>
|
||||
<string name="usb">USB</string>
|
||||
<string name="usb_permission_denied">USB permission denied. Reconnect the device to try again.</string>
|
||||
<!-- USE -->
|
||||
<string name="use_12h_format">Use 12h clock format</string>
|
||||
<string name="use_homoglyph_characters_encoding">Compact encoding for Cyrillic</string>
|
||||
@@ -1577,6 +1586,7 @@
|
||||
<string name="wifi_qr_code_error">Invalid WiFi Credential QR code format</string>
|
||||
<string name="wifi_qr_code_scan">Scan WiFi QR code</string>
|
||||
<string name="wifi_rssi_threshold_defaults_to_80">WiFi RSSI threshold (defaults to -80)</string>
|
||||
<string name="wifi_unavailable">Not connected to Wi-Fi. Network scan may not find nearby devices.</string>
|
||||
<!-- WIND -->
|
||||
<string name="wind">Wind</string>
|
||||
<string name="wind_direction">Wind Dir</string>
|
||||
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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"
|
||||
}
|
||||
}
|
||||
@@ -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<String>, 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.
|
||||
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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,
|
||||
)
|
||||
}
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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<Boolean>, requireAll: Boolean): Boolean =
|
||||
if (requireAll) results.all { it } else results.any { it }
|
||||
@@ -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
|
||||
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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))
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 ->
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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" }
|
||||
|
||||
132
workpad.md
Normal file
132
workpad.md
Normal file
@@ -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).
|
||||
Reference in New Issue
Block a user