mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-09-24 04:35:10 -04:00
feat(connections): handle hardware without Bluetooth for Android XR (#7318)
This commit is contained in:
1 parent
8038a19e95
commit
f62885df2b
27 files changed
+183
-43
No files matched your search
@@ -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" />
|
||||
|
||||
+5
-2
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
+1
@@ -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}" }
|
||||
|
||||
+11
-3
@@ -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)
|
||||
|
||||
+12
@@ -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() {
|
||||
|
||||
+1
@@ -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
|
||||
|
||||
+34
-25
@@ -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(
|
||||
|
||||
+2
-1
@@ -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,
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
+40
@@ -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 {
|
||||
|
||||
+1
@@ -30,4 +30,5 @@ internal class AndroidIntroPermissions(
|
||||
override val location: PermissionUiState,
|
||||
override val notification: PermissionUiState?,
|
||||
override val bluetoothRequiresLocation: Boolean,
|
||||
override val bluetoothSupported: Boolean,
|
||||
) : IntroPermissions
|
||||
+2
@@ -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. */
|
||||
|
||||
+14
-9
@@ -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,
|
||||
|
||||
+8
@@ -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()
|
||||
}
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
Reference in new issue
Block a user