diff --git a/androidApp/src/main/AndroidManifest.xml b/androidApp/src/main/AndroidManifest.xml
index 58c60f1c41..0eea31e77f 100644
--- a/androidApp/src/main/AndroidManifest.xml
+++ b/androidApp/src/main/AndroidManifest.xml
@@ -85,10 +85,19 @@
android:name="android.hardware.camera"
android:required="false" />
+
+
+
+
+
diff --git a/core/ble/src/androidMain/kotlin/org/meshtastic/core/ble/AndroidBluetoothRepository.kt b/core/ble/src/androidMain/kotlin/org/meshtastic/core/ble/AndroidBluetoothRepository.kt
index 93eab8e684..486c899fa1 100644
--- a/core/ble/src/androidMain/kotlin/org/meshtastic/core/ble/AndroidBluetoothRepository.kt
+++ b/core/ble/src/androidMain/kotlin/org/meshtastic/core/ble/AndroidBluetoothRepository.kt
@@ -36,6 +36,7 @@ import kotlinx.coroutines.withTimeoutOrNull
import org.koin.core.annotation.Named
import org.koin.core.annotation.Single
import org.meshtastic.core.common.di.PROCESS_LIFECYCLE
+import org.meshtastic.core.common.hasBluetoothLe
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.util.anonymize
import kotlin.time.Duration
@@ -58,8 +59,10 @@ class AndroidBluetoothRepository(
private val dispatchers: CoroutineDispatchers,
@Named(PROCESS_LIFECYCLE) private val processLifecycle: Lifecycle,
) : BluetoothRepository {
- private val bluetoothManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
- private val bluetoothAdapter: BluetoothAdapter? = bluetoothManager.adapter
+ private val bluetoothAdapter: BluetoothAdapter? =
+ (context.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager)?.adapter
+
+ override val isSupported: Boolean = context.hasBluetoothLe()
private val _state = MutableStateFlow(BluetoothState(hasPermissions = hasBluetoothPermissions()))
override val state: StateFlow = _state.asStateFlow()
diff --git a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/BluetoothRepository.kt b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/BluetoothRepository.kt
index feb61f54db..fdf7ea97f4 100644
--- a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/BluetoothRepository.kt
+++ b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/BluetoothRepository.kt
@@ -23,6 +23,10 @@ interface BluetoothRepository {
/** The current state of Bluetooth on the device. */
val state: StateFlow
+ /** False when the device has no Bluetooth LE hardware, so no BLE transport can ever work (e.g. Android XR). */
+ val isSupported: Boolean
+ get() = true
+
/** Refreshes the Bluetooth state. */
fun refreshState()
diff --git a/core/common/src/androidMain/kotlin/org/meshtastic/core/common/ContextServices.kt b/core/common/src/androidMain/kotlin/org/meshtastic/core/common/ContextServices.kt
index 3f69e18bec..5b6c16749d 100644
--- a/core/common/src/androidMain/kotlin/org/meshtastic/core/common/ContextServices.kt
+++ b/core/common/src/androidMain/kotlin/org/meshtastic/core/common/ContextServices.kt
@@ -35,6 +35,9 @@ fun Context.hasGps(): Boolean {
return lm?.allProviders?.contains(LocationManager.GPS_PROVIDER) == true
}
+/** Checks if the device has Bluetooth LE hardware at all, as distinct from Bluetooth being switched off. */
+fun Context.hasBluetoothLe(): Boolean = packageManager.hasSystemFeature(PackageManager.FEATURE_BLUETOOTH_LE)
+
/** Checks if the device has a GPS receiver and it is currently disabled. */
fun Context.gpsDisabled(): Boolean {
val lm = getSystemService(Context.LOCATION_SERVICE) as? LocationManager ?: return false
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 d013ad19bc..41c0e9f54d 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
@@ -56,6 +56,7 @@ import kotlinx.coroutines.withContext
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.getString
import org.meshtastic.core.common.gpsDisabled
+import org.meshtastic.core.common.hasBluetoothLe
import org.meshtastic.core.common.util.CommonUri
import org.meshtastic.core.common.util.ioDispatcher
import java.net.URLEncoder
@@ -291,6 +292,12 @@ actual fun rememberOpenWifiSettings(): () -> Unit {
actual val bleScanRequiresLocationServices: Boolean =
android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.S
+@Composable
+actual fun isBluetoothSupported(): Boolean {
+ val context = LocalContext.current
+ return remember(context) { context.hasBluetoothLe() }
+}
+
@Composable
actual fun isBluetoothDisabled(): Boolean {
val context = LocalContext.current
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 7277545907..57fd505086 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
@@ -102,6 +102,9 @@ expect val bleScanRequiresLocationServices: Boolean
*/
@Composable expect fun isBluetoothDisabled(): Boolean
+/** Returns whether the device has Bluetooth LE hardware at all, so a BLE surface is worth offering. */
+@Composable expect fun isBluetoothSupported(): Boolean
+
/**
* Returns whether the device currently lacks any transport that can back the network-scan discovery (no active Wi-Fi,
* Ethernet, or VPN). Cellular alone is **not** sufficient — a carrier uplink does not place the device on the same
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 e9d775da6f..8af2081f0f 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
@@ -70,6 +70,8 @@ actual val bleScanRequiresLocationServices: Boolean = false
@Composable actual fun isBluetoothDisabled(): Boolean = false
+@Composable actual fun isBluetoothSupported(): Boolean = true
+
@Composable actual fun isWifiUnavailable(): Boolean = false
@Composable
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 7b7781361c..6e18664f82 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
@@ -162,6 +162,8 @@ actual val bleScanRequiresLocationServices: Boolean = false
/** JVM — Bluetooth adapter state is not surfaced on Desktop. */
@Composable actual fun isBluetoothDisabled(): Boolean = false
+@Composable actual fun isBluetoothSupported(): Boolean = true
+
/** JVM — local-network availability is not gated on Desktop. */
@Composable actual fun isWifiUnavailable(): Boolean = false
diff --git a/feature/connections/detekt-baseline.xml b/feature/connections/detekt-baseline.xml
index b1806ff2a9..304886a71d 100644
--- a/feature/connections/detekt-baseline.xml
+++ b/feature/connections/detekt-baseline.xml
@@ -9,6 +9,7 @@
PreviewPublic:ConnectionsPreviews.kt:@PreviewLightDark @Composable fun DeviceSectionHeaderPreview
PreviewPublic:ConnectionsPreviews.kt:@PreviewLightDark @Composable fun DisconnectButtonPreview
PreviewPublic:ConnectionsPreviews.kt:@PreviewLightDark @Composable fun EmptyStateContentPreview
+ PreviewPublic:ConnectionsPreviews.kt:@PreviewLightDark @Composable fun TransportSelectorNoBluetoothPreview
PreviewPublic:ConnectionsPreviews.kt:@PreviewLightDark @Composable fun TransportSelectorPreview
diff --git a/feature/connections/src/androidMain/kotlin/org/meshtastic/feature/connections/AndroidScannerViewModel.kt b/feature/connections/src/androidMain/kotlin/org/meshtastic/feature/connections/AndroidScannerViewModel.kt
index 9e4473cf3b..0e0fc124f6 100644
--- a/feature/connections/src/androidMain/kotlin/org/meshtastic/feature/connections/AndroidScannerViewModel.kt
+++ b/feature/connections/src/androidMain/kotlin/org/meshtastic/feature/connections/AndroidScannerViewModel.kt
@@ -71,6 +71,7 @@ class AndroidScannerViewModel(
uiPrefs,
firmwareRecoveryDataSource,
bleScanner,
+ bluetoothSupported = bluetoothRepository.isSupported,
) {
override fun requestBonding(entry: DeviceListEntry.Ble) {
Logger.i { "Starting bonding for ${entry.device.address.anonymize}" }
diff --git a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ScannerViewModel.kt b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ScannerViewModel.kt
index 2fa7e8750f..612dd72752 100644
--- a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ScannerViewModel.kt
+++ b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ScannerViewModel.kt
@@ -150,6 +150,8 @@ open class ScannerViewModel(
private val uiPrefs: UiPrefs,
private val firmwareRecoveryDataSource: FirmwareRecoveryDataSource,
private val bleScanner: BleScanner? = null,
+ /** False on hardware with no Bluetooth LE: the BLE pane is hidden and never selected or scanned. */
+ val bluetoothSupported: Boolean = true,
) : ViewModel() {
// ── Mock / demo transport ─────────────────────────────────────────────────────────────────
@@ -377,6 +379,7 @@ open class ScannerViewModel(
/** Selects one Connections transport pane and stops scans that cannot belong to that pane. */
fun selectTransport(type: DeviceType) {
+ if (type == DeviceType.BLE && !bluetoothSupported) return
when (type) {
DeviceType.BLE -> stopNetworkScan()
DeviceType.TCP -> stopBleScan()
@@ -395,7 +398,9 @@ open class ScannerViewModel(
* prior scan cannot reset the flag on this new scan's state.
*/
fun startBleScan() {
- if (_isBleScanning.value || bleScanner == null || scanStartFailureCooldownActive.value) return
+ if (_isBleScanning.value || bleScanner == null || !bluetoothSupported || scanStartFailureCooldownActive.value) {
+ return
+ }
// Cancel the other scan first so only one flag is ever true. Both stop methods are idempotent.
stopNetworkScan()
@@ -759,8 +764,11 @@ open class ScannerViewModel(
}
}
- private fun resolveActiveTransport(preferred: DeviceType?, selectedAddress: String?): DeviceType =
- preferred ?: selectedAddress?.let(DeviceType::fromAddress) ?: DeviceType.BLE
+ private fun resolveActiveTransport(preferred: DeviceType?, selectedAddress: String?): DeviceType {
+ // A persisted BLE pane or a restored BLE address can still resolve here on a device with no Bluetooth.
+ val resolved = preferred ?: selectedAddress?.let(DeviceType::fromAddress) ?: DeviceType.BLE
+ return if (resolved == DeviceType.BLE && !bluetoothSupported) DeviceType.TCP else resolved
+ }
private fun recordSelectedTransport(fullAddress: String) {
DeviceType.fromAddress(fullAddress)?.let(uiPrefs::setSelectedConnectionTransport)
diff --git a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/component/ConnectionsPreviews.kt b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/component/ConnectionsPreviews.kt
index d0938354fc..e223a2adbf 100644
--- a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/component/ConnectionsPreviews.kt
+++ b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/component/ConnectionsPreviews.kt
@@ -160,6 +160,18 @@ fun TransportSelectorPreview() {
}
}
+@PreviewLightDark
+@Composable
+fun TransportSelectorNoBluetoothPreview() {
+ AppTheme {
+ Surface {
+ Box(modifier = Modifier.width(360.dp).padding(16.dp)) {
+ TransportSelector(activeTransport = DeviceType.TCP, onSelectTransport = {}, showBluetooth = false)
+ }
+ }
+ }
+}
+
@PreviewLightDark
@Composable
private fun BluetoothPanePreview() {
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 8f60ddee24..ca6f85b3cd 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
@@ -595,6 +595,7 @@ fun ConnectionsScreen(
TransportSelector(
activeTransport = activeTransport,
onSelectTransport = scanModel::selectTransport,
+ showBluetooth = scanModel.bluetoothSupported,
)
// Adapter-off hints: shown only when the relevant permission is granted but the radio/network
diff --git a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/TransportSelector.kt b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/TransportSelector.kt
index dd7b2310df..ad3549519d 100644
--- a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/TransportSelector.kt
+++ b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/TransportSelector.kt
@@ -40,13 +40,13 @@ import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.Usb
import org.meshtastic.core.ui.icon.Wifi
-private const val TRANSPORT_COUNT = 3
-
/**
* Single-choice transport selector rendered below the connection card. A Material 3 [SingleChoiceSegmentedButtonRow]
* makes the mutually-exclusive choice explicit: the segments read as one grouped control and the selected transport
* shows a check, rather than three independent chips whose filled state was read as "enabled/available" instead of
* "selected".
+ *
+ * @param showBluetooth false on hardware with no Bluetooth LE, where the BLE segment could never find anything.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -54,34 +54,42 @@ fun TransportSelector(
activeTransport: DeviceType,
onSelectTransport: (DeviceType) -> Unit,
modifier: Modifier = Modifier,
+ showBluetooth: Boolean = true,
) {
+ val transports = if (showBluetooth) DeviceType.entries else DeviceType.entries - DeviceType.BLE
// Fill the width so the control reads as one deliberate group spanning the same width as the connection card
- // above; each SegmentedButton carries an internal weight(1f), so the three segments divide the row evenly.
+ // above; each SegmentedButton carries an internal weight(1f), so the segments divide the row evenly.
SingleChoiceSegmentedButtonRow(modifier = modifier.fillMaxWidth()) {
- TransportSegment(
- selected = activeTransport == DeviceType.BLE,
- index = 0,
- label = Res.string.bluetooth,
- icon = MeshtasticIcons.Bluetooth,
- onClick = { onSelectTransport(DeviceType.BLE) },
- )
- TransportSegment(
- selected = activeTransport == DeviceType.TCP,
- index = 1,
- label = Res.string.network,
- icon = MeshtasticIcons.Wifi,
- onClick = { onSelectTransport(DeviceType.TCP) },
- )
- TransportSegment(
- selected = activeTransport == DeviceType.USB,
- index = 2,
- label = Res.string.usb,
- icon = MeshtasticIcons.Usb,
- onClick = { onSelectTransport(DeviceType.USB) },
- )
+ transports.forEachIndexed { index, transport ->
+ TransportSegment(
+ selected = activeTransport == transport,
+ index = index,
+ count = transports.size,
+ label = transport.label,
+ icon = transport.icon,
+ onClick = { onSelectTransport(transport) },
+ )
+ }
}
}
+private val DeviceType.label: StringResource
+ get() =
+ when (this) {
+ DeviceType.BLE -> Res.string.bluetooth
+ DeviceType.TCP -> Res.string.network
+ DeviceType.USB -> Res.string.usb
+ }
+
+private val DeviceType.icon: ImageVector
+ @Composable
+ get() =
+ when (this) {
+ DeviceType.BLE -> MeshtasticIcons.Bluetooth
+ DeviceType.TCP -> MeshtasticIcons.Wifi
+ DeviceType.USB -> MeshtasticIcons.Usb
+ }
+
/**
* A single transport segment: shows a check when [selected] and the transport [icon] otherwise, so selection is
* unambiguous while the unselected segments still communicate which transport they represent.
@@ -91,6 +99,7 @@ fun TransportSelector(
private fun SingleChoiceSegmentedButtonRowScope.TransportSegment(
selected: Boolean,
index: Int,
+ count: Int,
label: StringResource,
icon: ImageVector,
onClick: () -> Unit,
@@ -98,7 +107,7 @@ private fun SingleChoiceSegmentedButtonRowScope.TransportSegment(
SegmentedButton(
selected = selected,
onClick = onClick,
- shape = SegmentedButtonDefaults.itemShape(index = index, count = TRANSPORT_COUNT),
+ shape = SegmentedButtonDefaults.itemShape(index = index, count = count),
icon = {
SegmentedButtonDefaults.Icon(active = selected) {
Icon(
diff --git a/feature/connections/src/commonTest/kotlin/org/meshtastic/feature/connections/ScannerViewModelHarness.kt b/feature/connections/src/commonTest/kotlin/org/meshtastic/feature/connections/ScannerViewModelHarness.kt
index 55351bdb36..1f4b59d4f0 100644
--- a/feature/connections/src/commonTest/kotlin/org/meshtastic/feature/connections/ScannerViewModelHarness.kt
+++ b/feature/connections/src/commonTest/kotlin/org/meshtastic/feature/connections/ScannerViewModelHarness.kt
@@ -143,7 +143,7 @@ class ScannerViewModelHarness(val testDispatcher: TestDispatcher = UnconfinedTes
* Build the platform-neutral [ScannerViewModel]. Call only after `Dispatchers.setMain(testDispatcher)` because the
* ViewModel's `init` launches work on `viewModelScope` (Main).
*/
- fun buildBase(): ScannerViewModel = ScannerViewModel(
+ fun buildBase(bluetoothSupported: Boolean = true): ScannerViewModel = ScannerViewModel(
serviceRepository = serviceRepository,
radioController = radioController,
radioInterfaceService = radioInterfaceService,
@@ -155,6 +155,7 @@ class ScannerViewModelHarness(val testDispatcher: TestDispatcher = UnconfinedTes
uiPrefs = uiPrefs,
firmwareRecoveryDataSource = firmwareRecoveryDataSource,
bleScanner = bleScanner,
+ bluetoothSupported = bluetoothSupported,
)
/**
diff --git a/feature/connections/src/commonTest/kotlin/org/meshtastic/feature/connections/ScannerViewModelTest.kt b/feature/connections/src/commonTest/kotlin/org/meshtastic/feature/connections/ScannerViewModelTest.kt
index 843c3ff814..14f0545f6c 100644
--- a/feature/connections/src/commonTest/kotlin/org/meshtastic/feature/connections/ScannerViewModelTest.kt
+++ b/feature/connections/src/commonTest/kotlin/org/meshtastic/feature/connections/ScannerViewModelTest.kt
@@ -20,6 +20,8 @@ import app.cash.turbine.test
import dev.mokkery.answering.returns
import dev.mokkery.every
import dev.mokkery.matcher.any
+import dev.mokkery.verify
+import dev.mokkery.verify.VerifyMode
import dev.mokkery.verifySuspend
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
@@ -420,6 +422,44 @@ class ScannerViewModelTest {
assertEquals(DeviceType.BLE, viewModel.activeTransport.value)
}
+ @Test
+ fun `active transport falls back to Network on hardware without Bluetooth`() {
+ harness.uiPrefs.setSelectedConnectionTransport(DeviceType.BLE)
+ val noBluetooth = harness.buildBase(bluetoothSupported = false)
+ try {
+ assertEquals(DeviceType.TCP, noBluetooth.activeTransport.value)
+
+ noBluetooth.selectTransport(DeviceType.BLE)
+ assertEquals(DeviceType.TCP, noBluetooth.activeTransport.value)
+ } finally {
+ harness.clearViewModel(noBluetooth)
+ }
+ }
+
+ @Test
+ fun `a restored BLE address does not select the BLE pane on hardware without Bluetooth`() {
+ harness.currentDeviceAddressFlow.value = "xAA:BB:CC:DD:EE:FF"
+ val noBluetooth = harness.buildBase(bluetoothSupported = false)
+ try {
+ assertEquals(DeviceType.TCP, noBluetooth.activeTransport.value)
+ } finally {
+ harness.clearViewModel(noBluetooth)
+ }
+ }
+
+ @Test
+ fun `startBleScan never scans on hardware without Bluetooth`() {
+ val noBluetooth = harness.buildBase(bluetoothSupported = false)
+ try {
+ noBluetooth.startBleScan()
+
+ assertEquals(false, noBluetooth.isBleScanning.value)
+ verify(mode = VerifyMode.not) { bleScanner.scan(any(), any()) }
+ } finally {
+ harness.clearViewModel(noBluetooth)
+ }
+ }
+
@Test
fun `startNetworkScan updates isNetworkScanning`() = runTest {
viewModel.isNetworkScanning.test {
diff --git a/feature/intro/src/androidMain/kotlin/org/meshtastic/feature/intro/AndroidIntroPermissions.kt b/feature/intro/src/androidMain/kotlin/org/meshtastic/feature/intro/AndroidIntroPermissions.kt
index 66dfaf668e..b41520b638 100644
--- a/feature/intro/src/androidMain/kotlin/org/meshtastic/feature/intro/AndroidIntroPermissions.kt
+++ b/feature/intro/src/androidMain/kotlin/org/meshtastic/feature/intro/AndroidIntroPermissions.kt
@@ -30,4 +30,5 @@ internal class AndroidIntroPermissions(
override val location: PermissionUiState,
override val notification: PermissionUiState?,
override val bluetoothRequiresLocation: Boolean,
+ override val bluetoothSupported: Boolean,
) : IntroPermissions
diff --git a/feature/intro/src/androidMain/kotlin/org/meshtastic/feature/intro/AppIntroductionScreen.kt b/feature/intro/src/androidMain/kotlin/org/meshtastic/feature/intro/AppIntroductionScreen.kt
index 6b82223dfd..fa5c8bc261 100644
--- a/feature/intro/src/androidMain/kotlin/org/meshtastic/feature/intro/AppIntroductionScreen.kt
+++ b/feature/intro/src/androidMain/kotlin/org/meshtastic/feature/intro/AppIntroductionScreen.kt
@@ -24,6 +24,7 @@ import androidx.compose.ui.platform.LocalContext
import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.rememberNavBackStack
import org.meshtastic.core.ui.component.MeshtasticNavDisplay
+import org.meshtastic.core.ui.util.isBluetoothSupported
import org.meshtastic.core.ui.util.rememberBluetoothPermissionState
import org.meshtastic.core.ui.util.rememberLocationPermissionState
import org.meshtastic.core.ui.util.rememberNotificationPermissionState
@@ -59,6 +60,7 @@ fun AppIntroductionScreen(onDone: () -> Unit, viewModel: IntroViewModel) {
location = locationPermissionState,
notification = notificationPermissionState,
bluetoothRequiresLocation = bluetoothRequiresLocation,
+ bluetoothSupported = isBluetoothSupported(),
)
val settingsNavigator = remember(context) { AndroidIntroSettingsNavigator(context) }
val backStack = rememberNavBackStack(Welcome)
diff --git a/feature/intro/src/commonMain/kotlin/org/meshtastic/feature/intro/IntroNavGraph.kt b/feature/intro/src/commonMain/kotlin/org/meshtastic/feature/intro/IntroNavGraph.kt
index 329311c6fd..2927f2003f 100644
--- a/feature/intro/src/commonMain/kotlin/org/meshtastic/feature/intro/IntroNavGraph.kt
+++ b/feature/intro/src/commonMain/kotlin/org/meshtastic/feature/intro/IntroNavGraph.kt
@@ -33,8 +33,9 @@ internal fun EntryProviderScope.introGraph(
current: NavKey,
permissionsGranted: Boolean = true,
bluetoothRequiresLocation: Boolean = false,
+ bluetoothSupported: Boolean = true,
) {
- val next = viewModel.getNextKey(current, permissionsGranted, bluetoothRequiresLocation)
+ val next = viewModel.getNextKey(current, permissionsGranted, bluetoothRequiresLocation, bluetoothSupported)
if (next != null) {
backStack.add(next)
} else {
@@ -54,7 +55,10 @@ internal fun EntryProviderScope.introGraph(
}
}
- entry { WelcomeScreen(onGetStarted = { navigateToNext(Welcome) }) }
+ entry {
+ val permissions = LocalIntroPermissions.current
+ WelcomeScreen(onGetStarted = { navigateToNext(Welcome, bluetoothSupported = permissions.bluetoothSupported) })
+ }
entry {
val permissions = LocalIntroPermissions.current
diff --git a/feature/intro/src/commonMain/kotlin/org/meshtastic/feature/intro/IntroPermissions.kt b/feature/intro/src/commonMain/kotlin/org/meshtastic/feature/intro/IntroPermissions.kt
index 89300903b0..ba0b6e2660 100644
--- a/feature/intro/src/commonMain/kotlin/org/meshtastic/feature/intro/IntroPermissions.kt
+++ b/feature/intro/src/commonMain/kotlin/org/meshtastic/feature/intro/IntroPermissions.kt
@@ -42,6 +42,11 @@ interface IntroPermissions {
* rather than naming a permission the user will never see.
*/
val bluetoothRequiresLocation: Boolean
+
+ /**
+ * False on hardware with no Bluetooth LE (e.g. Android XR), where there is nothing for the Bluetooth screen to ask.
+ */
+ val bluetoothSupported: Boolean
}
/** Provides platform-specific permission states to the intro nav graph. */
diff --git a/feature/intro/src/commonMain/kotlin/org/meshtastic/feature/intro/IntroViewModel.kt b/feature/intro/src/commonMain/kotlin/org/meshtastic/feature/intro/IntroViewModel.kt
index 539ba6d121..e7e3429a18 100644
--- a/feature/intro/src/commonMain/kotlin/org/meshtastic/feature/intro/IntroViewModel.kt
+++ b/feature/intro/src/commonMain/kotlin/org/meshtastic/feature/intro/IntroViewModel.kt
@@ -34,13 +34,15 @@ class IntroViewModel : ViewModel() {
* in a row — and a user who declines both has spent both of Android's allowed denials before ever seeing the app,
* landing on USER_FIXED with no dialog available again. The Bluetooth screen covers both uses on those releases,
* so the second ask is dropped rather than duplicated.
+ * @param bluetoothSupported false on hardware with no Bluetooth LE, where the Bluetooth screen is skipped.
*/
fun getNextKey(
currentKey: NavKey,
allPermissionsGranted: Boolean,
bluetoothRequiresLocation: Boolean = false,
+ bluetoothSupported: Boolean = true,
): NavKey? = when (currentKey) {
- is Welcome -> Bluetooth
+ is Welcome -> if (bluetoothSupported) Bluetooth else Location
is Bluetooth -> if (bluetoothRequiresLocation) Notifications else Location
is Location -> Notifications
is Notifications -> if (allPermissionsGranted) CriticalAlerts else null
diff --git a/feature/intro/src/commonTest/kotlin/org/meshtastic/feature/intro/IntroViewModelTest.kt b/feature/intro/src/commonTest/kotlin/org/meshtastic/feature/intro/IntroViewModelTest.kt
index 19a91ced9c..edc6915f93 100644
--- a/feature/intro/src/commonTest/kotlin/org/meshtastic/feature/intro/IntroViewModelTest.kt
+++ b/feature/intro/src/commonTest/kotlin/org/meshtastic/feature/intro/IntroViewModelTest.kt
@@ -41,6 +41,12 @@ class IntroViewModelTest {
assertEquals(Bluetooth, next)
}
+ @Test
+ fun testWelcomeSkipsBluetoothOnHardwareWithoutIt() {
+ val next = viewModel.getNextKey(Welcome, allPermissionsGranted = false, bluetoothSupported = false)
+ assertEquals(Location, next)
+ }
+
@Test
fun testBluetoothNavigatesToLocation() {
val next = viewModel.getNextKey(Bluetooth, allPermissionsGranted = false)
diff --git a/feature/intro/src/jvmMain/kotlin/org/meshtastic/feature/intro/JvmIntroDefaults.kt b/feature/intro/src/jvmMain/kotlin/org/meshtastic/feature/intro/JvmIntroDefaults.kt
index 92d34dafb5..7ba084f9ca 100644
--- a/feature/intro/src/jvmMain/kotlin/org/meshtastic/feature/intro/JvmIntroDefaults.kt
+++ b/feature/intro/src/jvmMain/kotlin/org/meshtastic/feature/intro/JvmIntroDefaults.kt
@@ -27,6 +27,7 @@ internal object JvmIntroPermissions : IntroPermissions {
override val location: PermissionUiState = granted
override val notification: PermissionUiState = granted
override val bluetoothRequiresLocation: Boolean = false
+ override val bluetoothSupported: Boolean = true
}
/** JVM/Desktop stub: settings navigation is a no-op. */
diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/component/PermissionsSection.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/component/PermissionsSection.kt
index 8ad16a2c17..5c61191c4f 100644
--- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/component/PermissionsSection.kt
+++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/component/PermissionsSection.kt
@@ -71,6 +71,7 @@ import org.meshtastic.core.ui.util.PermissionGateAction
import org.meshtastic.core.ui.util.PermissionStatus
import org.meshtastic.core.ui.util.PermissionUiState
import org.meshtastic.core.ui.util.bleScanRequiresLocationServices
+import org.meshtastic.core.ui.util.isBluetoothSupported
import org.meshtastic.core.ui.util.permissionGateAction
import org.meshtastic.core.ui.util.rememberBluetoothPermissionState
import org.meshtastic.core.ui.util.rememberCameraPermissionState
@@ -125,6 +126,7 @@ internal fun ColumnScope.PermissionsSettingsContent() {
camera = camera,
localNetwork = localNetwork,
bluetoothIsLocation = bluetoothIsLocation,
+ bluetoothSupported = isBluetoothSupported(),
)
// Rows that need nothing from the user are collapsed by default. Five rows reading "Allowed" is a wall every
@@ -199,6 +201,7 @@ private fun permissionRows(
camera: PermissionUiState,
localNetwork: PermissionUiState,
bluetoothIsLocation: Boolean,
+ bluetoothSupported: Boolean,
): List = buildList {
// Pre-Android-12 the Bluetooth gate *is* ACCESS_FINE_LOCATION. Two rows there would offer two controls for
// one system grant and let them contradict each other on screen, so a single Location row stands for both.
@@ -213,15 +216,17 @@ private fun permissionRows(
),
)
} else {
- add(
- PermissionRow(
- titleRes = Res.string.nearby_devices_permission,
- summaryRes = Res.string.permission_nearby_devices_summary,
- rationaleRes = Res.string.bluetooth_permission_rationale,
- icon = MeshtasticIcons.Bluetooth,
- state = bluetooth,
- ),
- )
+ if (bluetoothSupported) {
+ add(
+ PermissionRow(
+ titleRes = Res.string.nearby_devices_permission,
+ summaryRes = Res.string.permission_nearby_devices_summary,
+ rationaleRes = Res.string.bluetooth_permission_rationale,
+ icon = MeshtasticIcons.Bluetooth,
+ state = bluetooth,
+ ),
+ )
+ }
add(
PermissionRow(
titleRes = Res.string.location_permission,
diff --git a/screenshot-tests/src/screenshotTest/kotlin/org/meshtastic/screenshots/feature/ConnectionsScreenshotTests.kt b/screenshot-tests/src/screenshotTest/kotlin/org/meshtastic/screenshots/feature/ConnectionsScreenshotTests.kt
index f4d5198d00..daebe586c3 100644
--- a/screenshot-tests/src/screenshotTest/kotlin/org/meshtastic/screenshots/feature/ConnectionsScreenshotTests.kt
+++ b/screenshot-tests/src/screenshotTest/kotlin/org/meshtastic/screenshots/feature/ConnectionsScreenshotTests.kt
@@ -23,6 +23,7 @@ import org.meshtastic.feature.connections.component.ConnectingDeviceInfoPreview
import org.meshtastic.feature.connections.component.DeviceListItemPreview
import org.meshtastic.feature.connections.component.DeviceSectionHeaderPreview
import org.meshtastic.feature.connections.component.DisconnectButtonPreview
+import org.meshtastic.feature.connections.component.TransportSelectorNoBluetoothPreview
import org.meshtastic.feature.connections.component.TransportSelectorPreview
@PreviewTest
@@ -59,3 +60,10 @@ fun ScreenshotDeviceSectionHeader() {
fun ScreenshotTransportSelector() {
TransportSelectorPreview()
}
+
+@PreviewTest
+@PreviewLightDark
+@Composable
+fun ScreenshotTransportSelectorNoBluetooth() {
+ TransportSelectorNoBluetoothPreview()
+}
diff --git a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/ConnectionsScreenshotTestsKt/ScreenshotTransportSelectorNoBluetooth_Dark_d19fbf1f_0.png b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/ConnectionsScreenshotTestsKt/ScreenshotTransportSelectorNoBluetooth_Dark_d19fbf1f_0.png
new file mode 100644
index 0000000000..229f93ee1e
Binary files /dev/null and b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/ConnectionsScreenshotTestsKt/ScreenshotTransportSelectorNoBluetooth_Dark_d19fbf1f_0.png differ
diff --git a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/ConnectionsScreenshotTestsKt/ScreenshotTransportSelectorNoBluetooth_Light_b29dc7a7_0.png b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/ConnectionsScreenshotTestsKt/ScreenshotTransportSelectorNoBluetooth_Light_b29dc7a7_0.png
new file mode 100644
index 0000000000..eb36ac651e
Binary files /dev/null and b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/ConnectionsScreenshotTestsKt/ScreenshotTransportSelectorNoBluetooth_Light_b29dc7a7_0.png differ