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