feat(connections): detect & inform about disabled Bluetooth / Wi-Fi

Mirror the GPS-disabled-vs-permission-denied distinction for the BLE and
network transports:

- core/ui: add isBluetoothDisabled() (adapter off) + isWifiUnavailable()
  (no Wi-Fi/Ethernet), plus rememberOpenBluetoothSettings()/rememberOpenWifiSettings()
  — alongside the existing isGpsDisabled()/rememberOpenLocationSettings()
- extract a reusable RecoveryCard from PermissionRecoveryCard (errorContainer
  message box + one recovery action); PermissionRecoveryCard now delegates to it
- ConnectionsScreen: when a transport's permission is granted but its adapter is
  off, show an inline recovery banner. The BLE scan toggle routes to Bluetooth
  settings when the radio is off (scanning can't work); the network banner is
  informational (manual TCP can still work off-Wi-Fi)
- detection refreshes on ON_RESUME, so the banner clears after the user returns
  from the adapter settings screen

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
James Rich
2026-06-18 13:41:43 -05:00
parent a3b5e7d245
commit 2c06a8019e
8 changed files with 210 additions and 25 deletions

View File

@@ -110,6 +110,7 @@ blue
bluetooth bluetooth
bluetooth_available_devices bluetooth_available_devices
bluetooth_config bluetooth_config
bluetooth_disabled
bluetooth_enabled bluetooth_enabled
bluetooth_feature_config bluetooth_feature_config
bluetooth_feature_config_description bluetooth_feature_config_description
@@ -1015,10 +1016,13 @@ one_month
one_week one_week
one_wire_temperature one_wire_temperature
only_favorites only_favorites
### OPEN ###
open_bluetooth_settings
open_compass open_compass
open_settings open_settings
open_source_description open_source_description
open_source_libraries open_source_libraries
open_wifi_settings
options options
orient_north orient_north
### OUTPUT ### ### OUTPUT ###
@@ -1537,6 +1541,7 @@ wifi_provisioning
wifi_qr_code_error wifi_qr_code_error
wifi_qr_code_scan wifi_qr_code_scan
wifi_rssi_threshold_defaults_to_80 wifi_rssi_threshold_defaults_to_80
wifi_unavailable
### WIND ### ### WIND ###
wind wind
wind_direction wind_direction

View File

@@ -128,6 +128,7 @@
<string name="bluetooth">Bluetooth</string> <string name="bluetooth">Bluetooth</string>
<string name="bluetooth_available_devices">Available Bluetooth Devices</string> <string name="bluetooth_available_devices">Available Bluetooth Devices</string>
<string name="bluetooth_config">Bluetooth Config</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_enabled">Bluetooth enabled</string>
<string name="bluetooth_feature_config">Configuration</string> <string name="bluetooth_feature_config">Configuration</string>
<string name="bluetooth_feature_config_description">Wirelessly manage your device settings and channels.</string> <string name="bluetooth_feature_config_description">Wirelessly manage your device settings and channels.</string>
@@ -1045,10 +1046,13 @@
<string name="one_week">1W</string> <string name="one_week">1W</string>
<string name="one_wire_temperature">1-Wire Temp</string> <string name="one_wire_temperature">1-Wire Temp</string>
<string name="only_favorites">Only Favorites</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_compass">Open Compass</string>
<string name="open_settings">Open settings</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_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_source_libraries">Open Source Libraries</string>
<string name="open_wifi_settings">Open Wi-Fi settings</string>
<string name="options">Options</string> <string name="options">Options</string>
<string name="orient_north">Orient north</string> <string name="orient_north">Orient north</string>
<!-- OUTPUT --> <!-- OUTPUT -->
@@ -1582,6 +1586,7 @@
<string name="wifi_qr_code_error">Invalid WiFi Credential QR code format</string> <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_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_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 --> <!-- WIND -->
<string name="wind">Wind</string> <string name="wind">Wind</string>
<string name="wind_direction">Wind Dir</string> <string name="wind_direction">Wind Dir</string>

View File

@@ -18,9 +18,13 @@
package org.meshtastic.core.ui.util package org.meshtastic.core.ui.util
import android.bluetooth.BluetoothManager
import android.content.ActivityNotFoundException import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.net.Uri import android.net.Uri
import android.provider.Settings import android.provider.Settings
import androidx.activity.compose.LocalActivity import androidx.activity.compose.LocalActivity
@@ -215,6 +219,62 @@ actual fun isGpsDisabled(): Boolean {
return rememberOnResumeState { context.gpsDisabled() } 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 @Composable
actual fun rememberOpenAppSettings(): () -> Unit { actual fun rememberOpenAppSettings(): () -> Unit {
val context = LocalContext.current val context = LocalContext.current

View File

@@ -31,6 +31,7 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.Res
@@ -43,26 +44,23 @@ import org.meshtastic.core.ui.util.PermissionStatus
import org.meshtastic.core.ui.util.PermissionUiState import org.meshtastic.core.ui.util.PermissionUiState
/** /**
* A reusable error-state card for a missing runtime permission. Generalizes the compass warning/recovery pattern so * A reusable error-state card: an `errorContainer` message box plus one full-width recovery action. Generalizes the
* every feature presents context plus a single, context-correct recovery action: * compass warning/recovery pattern so any feature can present context plus a single corrective action (request a
* - [PermissionStatus.NOT_REQUESTED] / [PermissionStatus.DENIED_CAN_RETRY] — shows a "Grant permission" button that * permission, open Bluetooth/Wi-Fi/app settings, etc.).
* 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. * @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 @Composable
internal fun PermissionRecoveryCard( fun RecoveryCard(
status: PermissionStatus, message: String,
rationale: String, actionLabel: String,
onRequest: () -> Unit, onAction: () -> Unit,
onOpenSettings: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
actionIcon: ImageVector? = null,
) { ) {
if (status == PermissionStatus.GRANTED) return
Column(modifier = modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) { Column(modifier = modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Surface( Surface(
tonalElevation = 2.dp, tonalElevation = 2.dp,
@@ -81,27 +79,61 @@ internal fun PermissionRecoveryCard(
tint = MaterialTheme.colorScheme.onErrorContainer, tint = MaterialTheme.colorScheme.onErrorContainer,
) )
Text( Text(
text = rationale, text = message,
color = MaterialTheme.colorScheme.onErrorContainer, color = MaterialTheme.colorScheme.onErrorContainer,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
) )
} }
} }
if (status == PermissionStatus.PERMANENTLY_DENIED) { Button(onClick = onAction, modifier = Modifier.fillMaxWidth()) {
Button(onClick = onOpenSettings, modifier = Modifier.fillMaxWidth()) { if (actionIcon != null) {
Icon(imageVector = MeshtasticIcons.AppSettingsAlt, contentDescription = null) Icon(imageVector = actionIcon, contentDescription = null)
Spacer(modifier = Modifier.width(8.dp)) 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]. */ /** Convenience overload that reads the status and actions directly from a [PermissionUiState]. */
@Composable @Composable
fun PermissionRecoveryCard(state: PermissionUiState, rationale: String, modifier: Modifier = Modifier) { fun PermissionRecoveryCard(state: PermissionUiState, rationale: String, modifier: Modifier = Modifier) {

View File

@@ -58,12 +58,30 @@ expect fun rememberSaveFileLauncher(
/** Returns a launcher to open the platform's location settings. */ /** Returns a launcher to open the platform's location settings. */
@Composable expect fun rememberOpenLocationSettings(): () -> Unit @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 * Returns whether GPS/location services are currently disabled at the system level. Always `false` on platforms where
* this concept doesn't apply. * this concept doesn't apply.
*/ */
@Composable expect fun isGpsDisabled(): Boolean @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). */ /** Returns a function that opens this app's system settings page (where the user can change any permission). */
@Composable expect fun rememberOpenAppSettings(): () -> Unit @Composable expect fun rememberOpenAppSettings(): () -> Unit

View File

@@ -52,8 +52,16 @@ actual fun rememberOpenFileLauncher(onUriReceived: (CommonUri?) -> Unit): (mimeT
@Composable actual fun rememberOpenLocationSettings(): () -> Unit = {} @Composable actual fun rememberOpenLocationSettings(): () -> Unit = {}
@Composable actual fun rememberOpenBluetoothSettings(): () -> Unit = {}
@Composable actual fun rememberOpenWifiSettings(): () -> Unit = {}
@Composable actual fun isGpsDisabled(): Boolean = false @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 SetScreenBrightness(brightness: Float) {}
@Composable actual fun rememberOpenAppSettings(): () -> Unit = {} @Composable actual fun rememberOpenAppSettings(): () -> Unit = {}

View File

@@ -118,9 +118,25 @@ actual fun KeepScreenOn(enabled: Boolean) {
@Composable @Composable
actual fun rememberOpenLocationSettings(): () -> Unit = { Logger.w { "Location settings not implemented on Desktop" } } 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). */ /** JVM — GPS is never disabled on Desktop (concept doesn't apply). */
@Composable actual fun isGpsDisabled(): Boolean = false @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. */ /** JVM stub — app settings are not available on Desktop. */
@Composable @Composable
actual fun rememberOpenAppSettings(): () -> Unit = { Logger.w { "App settings not available on JVM/Desktop" } } actual fun rememberOpenAppSettings(): () -> Unit = { Logger.w { "App settings not available on JVM/Desktop" } }

View File

@@ -54,19 +54,29 @@ import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.navigation.Route import org.meshtastic.core.navigation.Route
import org.meshtastic.core.navigation.SettingsRoute import org.meshtastic.core.navigation.SettingsRoute
import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.bluetooth_disabled
import org.meshtastic.core.resources.connections import org.meshtastic.core.resources.connections
import org.meshtastic.core.resources.no_device_selected 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.set_your_region
import org.meshtastic.core.resources.unknown_device 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.AdaptiveTwoPane
import org.meshtastic.core.ui.component.ListItem import org.meshtastic.core.ui.component.ListItem
import org.meshtastic.core.ui.component.MainAppBar 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.Language
import org.meshtastic.core.ui.icon.MeshtasticIcons import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.NoDevice import org.meshtastic.core.ui.icon.NoDevice
import org.meshtastic.core.ui.util.PermissionStatus 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.rememberBluetoothPermissionState
import org.meshtastic.core.ui.util.rememberLocalNetworkPermissionState 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.ConnectionStatus
import org.meshtastic.core.ui.viewmodel.ConnectionsViewModel import org.meshtastic.core.ui.viewmodel.ConnectionsViewModel
import org.meshtastic.feature.connections.MOCK_DEVICE_PREFIX import org.meshtastic.feature.connections.MOCK_DEVICE_PREFIX
@@ -131,6 +141,13 @@ fun ConnectionsScreen(
val localNetworkPermission = rememberLocalNetworkPermissionState() val localNetworkPermission = rememberLocalNetworkPermissionState()
val bluetoothPermission = rememberBluetoothPermissionState() 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. // 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 // LifecycleStartEffect stops scanning on ON_STOP (app backgrounded) and restarts on ON_START — preventing
// continuous background BLE radio usage that drains the battery. // continuous background BLE radio usage that drains the battery.
@@ -274,6 +291,24 @@ fun ConnectionsScreen(
onToggleNetwork = { scanModel.setShowNetworkTransport(!showNetworkTransport) }, onToggleNetwork = { scanModel.setShowNetworkTransport(!showNetworkTransport) },
onToggleUsb = { scanModel.setShowUsbTransport(!showUsbTransport) }, 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 = { second = {
// ── Unified device list ── // ── Unified device list ──
@@ -293,7 +328,13 @@ fun ConnectionsScreen(
onSelectDevice = { scanModel.onSelected(it) }, onSelectDevice = { scanModel.onSelected(it) },
onToggleBleScan = { onToggleBleScan = {
when { 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. // Permanently denied: the system won't prompt again, so send to settings.
bluetoothPermission.status == PermissionStatus.PERMANENTLY_DENIED -> bluetoothPermission.status == PermissionStatus.PERMANENTLY_DENIED ->