mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-06-28 07:25:42 -04:00
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:
5
.skills/compose-ui/strings-index.txt
generated
5
.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
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 = {}
|
||||
|
||||
@@ -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" } }
|
||||
|
||||
@@ -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 ->
|
||||
|
||||
Reference in New Issue
Block a user