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