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_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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 = {}

View File

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

View File

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