diff --git a/.skills/compose-ui/strings-index.txt b/.skills/compose-ui/strings-index.txt index 25bfcc55b..fbb067f60 100644 --- a/.skills/compose-ui/strings-index.txt +++ b/.skills/compose-ui/strings-index.txt @@ -110,6 +110,7 @@ blue bluetooth bluetooth_available_devices bluetooth_config +bluetooth_disabled bluetooth_enabled bluetooth_feature_config bluetooth_feature_config_description @@ -1015,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 ### @@ -1537,6 +1541,7 @@ wifi_provisioning wifi_qr_code_error wifi_qr_code_scan wifi_rssi_threshold_defaults_to_80 +wifi_unavailable ### WIND ### wind wind_direction diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml index 1b555f7fe..40221af04 100644 --- a/core/resources/src/commonMain/composeResources/values/strings.xml +++ b/core/resources/src/commonMain/composeResources/values/strings.xml @@ -128,6 +128,7 @@ Bluetooth Available Bluetooth Devices Bluetooth Config + Bluetooth is off. Turn it on to scan for nearby devices. Bluetooth enabled Configuration Wirelessly manage your device settings and channels. @@ -1045,10 +1046,13 @@ 1W 1-Wire Temp Only Favorites + + Open Bluetooth settings Open Compass Open settings Meshtastic is built with the following open source libraries. Tap any library to view its license. Open Source Libraries + Open Wi-Fi settings Options Orient north @@ -1582,6 +1586,7 @@ Invalid WiFi Credential QR code format Scan WiFi QR code WiFi RSSI threshold (defaults to -80) + Not connected to Wi-Fi. Network scan may not find nearby devices. Wind Wind Dir diff --git a/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt b/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt index 6ae95362d..41285f373 100644 --- a/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt +++ b/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt @@ -18,9 +18,13 @@ package org.meshtastic.core.ui.util +import android.bluetooth.BluetoothManager import android.content.ActivityNotFoundException +import android.content.Context import android.content.Intent import android.content.pm.PackageManager +import android.net.ConnectivityManager +import android.net.NetworkCapabilities import android.net.Uri import android.provider.Settings import androidx.activity.compose.LocalActivity @@ -215,6 +219,62 @@ actual fun isGpsDisabled(): Boolean { 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 rememberOnResumeState { + // 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 + } +} + +@Composable +actual fun isWifiUnavailable(): Boolean { + val context = LocalContext.current + return rememberOnResumeState { + 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 + } +} + @Composable actual fun rememberOpenAppSettings(): () -> Unit { val context = LocalContext.current diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/PermissionRecoveryCard.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/PermissionRecoveryCard.kt index 3a686026e..4f12040b7 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/PermissionRecoveryCard.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/PermissionRecoveryCard.kt @@ -31,6 +31,7 @@ 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 @@ -43,26 +44,23 @@ import org.meshtastic.core.ui.util.PermissionStatus import org.meshtastic.core.ui.util.PermissionUiState /** - * A reusable error-state card for a missing runtime permission. Generalizes the compass warning/recovery pattern so - * every feature presents context plus a single, context-correct recovery action: - * - [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. + * 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 rationale a feature-specific explanation of why the permission is needed. + * @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 -internal fun PermissionRecoveryCard( - status: PermissionStatus, - rationale: String, - onRequest: () -> Unit, - onOpenSettings: () -> Unit, +fun RecoveryCard( + message: String, + actionLabel: String, + onAction: () -> Unit, modifier: Modifier = Modifier, + actionIcon: ImageVector? = null, ) { - if (status == PermissionStatus.GRANTED) return - Column(modifier = modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) { Surface( tonalElevation = 2.dp, @@ -81,27 +79,61 @@ internal fun PermissionRecoveryCard( tint = MaterialTheme.colorScheme.onErrorContainer, ) Text( - text = rationale, + text = message, color = MaterialTheme.colorScheme.onErrorContainer, style = MaterialTheme.typography.bodyMedium, ) } } - if (status == PermissionStatus.PERMANENTLY_DENIED) { - Button(onClick = onOpenSettings, modifier = Modifier.fillMaxWidth()) { - Icon(imageVector = MeshtasticIcons.AppSettingsAlt, contentDescription = null) + Button(onClick = onAction, modifier = Modifier.fillMaxWidth()) { + if (actionIcon != null) { + Icon(imageVector = actionIcon, contentDescription = null) Spacer(modifier = Modifier.width(8.dp)) - Text(text = stringResource(Res.string.open_settings)) - } - } else { - Button(onClick = onRequest, modifier = Modifier.fillMaxWidth()) { - Text(text = stringResource(Res.string.grant_permission)) } + 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) { diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt index f9fa8f223..43909591e 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt @@ -58,12 +58,30 @@ expect fun rememberSaveFileLauncher( /** Returns a launcher to open the platform's location settings. */ @Composable expect fun rememberOpenLocationSettings(): () -> Unit +/** Returns a launcher to open the platform's Bluetooth settings. */ +@Composable expect fun rememberOpenBluetoothSettings(): () -> Unit + +/** 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 diff --git a/core/ui/src/iosMain/kotlin/org/meshtastic/core/ui/util/NoopStubs.kt b/core/ui/src/iosMain/kotlin/org/meshtastic/core/ui/util/NoopStubs.kt index e83d377e7..296852973 100644 --- a/core/ui/src/iosMain/kotlin/org/meshtastic/core/ui/util/NoopStubs.kt +++ b/core/ui/src/iosMain/kotlin/org/meshtastic/core/ui/util/NoopStubs.kt @@ -52,8 +52,16 @@ actual fun rememberOpenFileLauncher(onUriReceived: (CommonUri?) -> Unit): (mimeT @Composable actual fun rememberOpenLocationSettings(): () -> Unit = {} +@Composable actual fun rememberOpenBluetoothSettings(): () -> Unit = {} + +@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 = {} diff --git a/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt b/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt index cf730c1b8..b3fa84815 100644 --- a/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt +++ b/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt @@ -118,9 +118,25 @@ actual fun KeepScreenOn(enabled: Boolean) { @Composable actual fun rememberOpenLocationSettings(): () -> Unit = { Logger.w { "Location settings not implemented on Desktop" } } +/** JVM stub — Bluetooth settings are not available on Desktop. */ +@Composable +actual fun rememberOpenBluetoothSettings(): () -> Unit = { + Logger.w { "Bluetooth settings not available on JVM/Desktop" } +} + +/** JVM stub — Wi-Fi settings are not available on Desktop. */ +@Composable +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" } } diff --git a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/ConnectionsScreen.kt b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/ConnectionsScreen.kt index fc4cfde55..432b3416e 100644 --- a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/ConnectionsScreen.kt +++ b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/ConnectionsScreen.kt @@ -54,19 +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.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 @@ -131,6 +141,13 @@ fun ConnectionsScreen( val localNetworkPermission = rememberLocalNetworkPermissionState() val bluetoothPermission = rememberBluetoothPermissionState() + // 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 // continuous background BLE radio usage that drains the battery. @@ -274,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 ── @@ -293,7 +328,13 @@ fun ConnectionsScreen( onSelectDevice = { scanModel.onSelected(it) }, onToggleBleScan = { when { - isBleScanning || bluetoothPermission.isGranted -> scanModel.toggleBleScan() + // 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 ->