feat(connections): handle hardware without Bluetooth for Android XR (#7318)

This commit is contained in:
James Rich authored and GitHub committed 2026-09-23 18:10:05 +00:00
1 parent 8038a19e95
commit f62885df2b
27 files changed
+183 -43

No files matched your search

+9
View File
@@ -85,10 +85,19 @@
android:name="android.hardware.camera"
android:required="false" />
<!-- The legacy BLUETOOTH permissions imply this feature as required, which hides the app on Android XR. -->
<uses-feature
android:name="android.hardware.bluetooth"
android:required="false" />
<uses-feature
android:name="android.hardware.bluetooth_le"
android:required="false" />
<uses-feature
android:name="android.hardware.touchscreen"
android:required="false" />
<uses-feature
android:name="android.hardware.nfc"
android:required="false" />
@@ -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<BluetoothState> = _state.asStateFlow()
@@ -23,6 +23,10 @@ interface BluetoothRepository {
/** The current state of Bluetooth on the device. */
val state: StateFlow<BluetoothState>
/** 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()
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
+1
View File
@@ -9,6 +9,7 @@
<ID>PreviewPublic:ConnectionsPreviews.kt:@PreviewLightDark @Composable fun DeviceSectionHeaderPreview</ID>
<ID>PreviewPublic:ConnectionsPreviews.kt:@PreviewLightDark @Composable fun DisconnectButtonPreview</ID>
<ID>PreviewPublic:ConnectionsPreviews.kt:@PreviewLightDark @Composable fun EmptyStateContentPreview</ID>
<ID>PreviewPublic:ConnectionsPreviews.kt:@PreviewLightDark @Composable fun TransportSelectorNoBluetoothPreview</ID>
<ID>PreviewPublic:ConnectionsPreviews.kt:@PreviewLightDark @Composable fun TransportSelectorPreview</ID>
</CurrentIssues>
</SmellBaseline>
@@ -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}" }
@@ -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)
@@ -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() {
@@ -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
@@ -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(
@@ -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,
)
/**
@@ -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 {
@@ -30,4 +30,5 @@ internal class AndroidIntroPermissions(
override val location: PermissionUiState,
override val notification: PermissionUiState?,
override val bluetoothRequiresLocation: Boolean,
override val bluetoothSupported: Boolean,
) : IntroPermissions
@@ -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)
@@ -33,8 +33,9 @@ internal fun EntryProviderScope<NavKey>.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<NavKey>.introGraph(
}
}
entry<Welcome> { WelcomeScreen(onGetStarted = { navigateToNext(Welcome) }) }
entry<Welcome> {
val permissions = LocalIntroPermissions.current
WelcomeScreen(onGetStarted = { navigateToNext(Welcome, bluetoothSupported = permissions.bluetoothSupported) })
}
entry<Bluetooth> {
val permissions = LocalIntroPermissions.current
@@ -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. */
@@ -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
@@ -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)
@@ -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. */
@@ -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<PermissionRow> = 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,
@@ -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()
}