From f62885df2bc2ed94d2633dc8a0ab1d28470fc3bc Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Wed, 23 Sep 2026 18:10:05 +0000 Subject: [PATCH] feat(connections): handle hardware without Bluetooth for Android XR (#7318) --- androidApp/src/main/AndroidManifest.xml | 9 +++ .../core/ble/AndroidBluetoothRepository.kt | 7 ++- .../core/ble/BluetoothRepository.kt | 4 ++ .../meshtastic/core/common/ContextServices.kt | 3 + .../meshtastic/core/ui/util/PlatformUtils.kt | 7 +++ .../meshtastic/core/ui/util/PlatformUtils.kt | 3 + .../org/meshtastic/core/ui/util/NoopStubs.kt | 2 + .../meshtastic/core/ui/util/PlatformUtils.kt | 2 + feature/connections/detekt-baseline.xml | 1 + .../connections/AndroidScannerViewModel.kt | 1 + .../feature/connections/ScannerViewModel.kt | 14 ++++- .../component/ConnectionsPreviews.kt | 12 ++++ .../connections/ui/ConnectionsScreen.kt | 1 + .../ui/components/TransportSelector.kt | 59 ++++++++++-------- .../connections/ScannerViewModelHarness.kt | 3 +- .../connections/ScannerViewModelTest.kt | 40 ++++++++++++ .../feature/intro/AndroidIntroPermissions.kt | 1 + .../feature/intro/AppIntroductionScreen.kt | 2 + .../meshtastic/feature/intro/IntroNavGraph.kt | 8 ++- .../feature/intro/IntroPermissions.kt | 5 ++ .../feature/intro/IntroViewModel.kt | 4 +- .../feature/intro/IntroViewModelTest.kt | 6 ++ .../feature/intro/JvmIntroDefaults.kt | 1 + .../settings/component/PermissionsSection.kt | 23 ++++--- .../feature/ConnectionsScreenshotTests.kt | 8 +++ ...ortSelectorNoBluetooth_Dark_d19fbf1f_0.png | Bin 0 -> 10611 bytes ...rtSelectorNoBluetooth_Light_b29dc7a7_0.png | Bin 0 -> 10681 bytes 27 files changed, 183 insertions(+), 43 deletions(-) create mode 100644 screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/ConnectionsScreenshotTestsKt/ScreenshotTransportSelectorNoBluetooth_Dark_d19fbf1f_0.png create mode 100644 screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/ConnectionsScreenshotTestsKt/ScreenshotTransportSelectorNoBluetooth_Light_b29dc7a7_0.png 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 0000000000000000000000000000000000000000..229f93ee1e2b6613459b045f644e05125db81f54 GIT binary patch literal 10611 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 S2DEg5KxQVEm#d6!J^UYUh0vA& literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..eb36ac651e36c375b071f99dc9a627e1e2ae4094 GIT binary patch literal 10681 zcmeI2S6EYRu&5&_3P@Ezx`2YHfKmkn6a+-6LJ~R>dgx$4x~Md1KhmV5ASKk$6+#d~ zuPUL1-g`hGlu*u!|K9uA``n-BITxD?*1A|(Yi7Qg`M#NVB6PLY>1a4;Kp+sE#?vP+ zKp;vKaR2KP74Sd3Nc|oNB>YC>$zy}p<{J}-7T{s@*)~bXA2Zb+u!~p!{pwkoG`CAy zjmB@=?;2w_def`W?7psTc^VQ`?$T85%IP2>E=JWK=eR(v>=)vGP+!n{B*2mXU=EZj zZDgS&G*vm_7tj~?IHZ!kj5m{kcCAt-EI|AFa9Z`S@fmSc)d~;-1akX<8J+rjcOAk6 zJ4e3jyN;zGU(+9lUm#x}ykn##U)`wqSjpGv%gT4j*CzJ&hUDuTiA!X)Kp;ED|9!## zOB?uQWGInzD!uzH^&#d~e`tqOqEdVWZ%Va9aY=PvX3vBxYCe??ATdbs?w(#EFHFl3 z-8DEfb9`@ju7QN@y0wJ+i@eT0BWp124z|T~#zAUfWLFOw#d#ObV*-H!f^I`j%w^ds>o% ztu@Oh)eAPZ8=+>lbBltN4kSx`^xFH;(bq%kvToEM#{A>`^Q1ruP<R()ZYdQQ`Gg z-QjBjpT0?bd$9@B(!-OsH&-LYcIzL#+jmBCU93h~JxdQi$|>;+U~6WU<80#$dN`dOvbTfORj~j{c#`n`)X_ZHbrT>rP0JDbNP& z97m2e?wdK4`gzuccg5WX!UrDWBSkC_VU5% z=Rt2`_yL2I=Iaafr9{MwQ|>39Qii2$;OfE7IsdGST=ItcWN zmHZiWXTka^j&qPTyR~%zDLuJYz?fF=W;0Ev!zI^e;G&Q%#|r|P(gN!=tv0&9%_s5d zV5AA1S5RPxYpvvAg4vMLM@J8XW#oK%uIkq=6|cSq@?8npQk;c{uZlD{l@#1CM&;SM zxYnXN22`>!=3a_XB&F-t`&}p843HCj`2exMH}6KRH!0Yi9#ep?#Lg;WMLTE}l^;5{ zDgUlJ`?Bf=yc11cHM1F5H=CfmpWbw%zG0m)t~ITW9@Slz}pkkl1Ot&gBx z{S5Tui3tXUsjr!9`5-kvw=r@0cJqbWzy=~i0q8w|TkJDqt~+xYeNhCTRW%JA2@QQ0 z{5Ai#pzZdh`=IRZzhBeh5h3=+B|faetb`*YFS@hAbh6<0dFgl-&^|eB+ERNYV}py7 zfT|%tqF<&LSvs?6)2XX~qnuiSVETgeG#d{YsiKxZ%Qz1#+^erd86iYLpt?A+c{cg+ zgd6nS8xvvT;vNRkYqPhpq9V8VCX3Ajn0wl^NCxc`z>A~FFV^OW z-E~o3Kh3lHJk1f9!@B6In*sRr_rFR_K*|~`^doy>6?_j8knZ5|_ai^8@_k)jbiFr1 zs+NlZ50Le!vZ5;C{%md@I4#iu=`MieK@8p!v3H9n_04e%ACHXPOyem zVHkUa&O?3Sf46{aZ%soi5(No_s{? zw&$d3a!7`kv~*D)B?vST!5C?{yXqLEKZ7Ie$$a9a-97*3ViDkD%KNtthg+3i%CfOjjFE;)x9=Lv@-HkPz?Ssy{Aps%C4-#fYN<&dkvhF+zIzZM?uEd(H4&#>LH zYgtIaBv}?%t+VT{+y$#PEocT+Hu^@>q<^;<<+-_1Ql=ilj8CZS#+KX@xNjyh($=xp z9nla48bd7mz8}eq1nidND_cdS>@;QozOr_Dx_=rxHM0PndLi|5cP*Lm0r7CjLQg7i zsoz}u8!b%_C4(aCgFa%r(uqz%>0UEtQ1*DJR7AgB&4bh*WW^IW@37NMTb9}waHZ$N z!kThft0M1!k#O=Eo?)uUi!hcyW|gAP=k<8<89}+f*Q`X2?vdYt`pcHneyFR2l~50~ z+Klf-_{pL-K{C;Vp>XW!cL{6~WK*IWLfGmU_R`W(3*Y#52^DSwzaia=jvFbZL4jFt zi;W~+y>mP8)8i$XQaN$_BqL|U`4y2g_@HcX->qAQ3ll|2qGmaNzb)bv*n=Utoz z`RD8-dxE3Cg6{B#7;ewrL4%!dOt8bI))d6IJQp16^MnMjMAP?%uabWY^`~ip@x6_) z423oKs7p>?N(MIYL`)dgj3yM9-CG+@ym;z7GLZ|fSAo7a^wB(6bR9>&wf1{>M6&Bi z(PtZ!ZLNN9xZUeIWn1G?2wu7%hpbub(l0HX%P%AvE^3A+s!KHQSo-a2Nz!SRAb*2Q zwTh#gLRD!|{PNytWGxfSijA*ki68r=C_~{lT=C%L%j@go`&qG1F3XEw9I~)e(jLbI zeWB3~q}!X|wRU|Ku7H?LAJgA2vLMBDjaIojpC@U}`b!3cmi!_h+9jolXeoq$KUQ^jbbykMk)-_&3ui6}~Xz*Cw z|EvbzyRFlI9*$4FLLyEn>Mm{L8yv!p4$U^y!bVhSlMAQ(R(|1-qU%^x<>J?5b7=cI z>7X1LeHA8v3&~LM{Mm38sD#i|J%oU1Uk!HXU1=cU;#%?z{E9sUu@D#Jj_B5W;f_Jr zxp2X;OY3zJ6TB~5amvNZS@#}uw;oBiwVr8fjTEDl#|4LYdPjU}=uS4_Yj zZ*{Xnhv%KgkIy zm}PM;7eukkPM#ob)!)Hm!GL|5ZEj9p=*z^sKwNwC6y{D}TvEb#2U3dv8biD06z)78 zbpO@l{kf%-eNvQo*(u^5_6M&&rhNR?W8kdxWwj;GPGp~`5HQ~frBs485($yFb2>O> zcrW?89w(@-rd!H$_EEtX4C@7*v^S3)ZB$4}Aq9&AEuJsOvRnOHOvUVTTA2rWX0Pm6 zA~l{{*bgNPg_VW`L}j6;9JkvPJ`l?$nOHh(pKtB`d5^DLGP>fWO?!3&Tak*+N)|js*I&LI$ z>%lf>kWa1t(v z%Idsp0I5*$Uw)EJG**XMSw<#va>Hys-z4q#SyU+F5NdYH#Xa8=_kRWTygfWb(l6f{ z<(2lwq$(B#N6FRp~KQVC;_9 z;k^{C_UxZZe5T(_Mw9Fj{WIDSiS0^xUb?}q;TdVZGA$r0d8lP;H`Z9B9gJqg7Gz5}(nL*!@k#SA-Sf&bFYQJ0ji%(sB3Ib)e-f_L6ggB6X|oUg09f z`bFkpQTM{iILB4PRiW{?h%Dcu!$%!)1Nt?V@ks70X(?SH3lcs@gqfwraft`fSL&6c z;$+nlxaT+ZYR6mU$F?!_!lq_8w-nqz+Z^&t(HEPc`kJv`QP!=e-a_lsr_cX-u;H)Q z1PUlY?z7zGaE3qjB`4^ZTc$?NE=M)=Fcim2)!g=oo7gVt7dj`~B~AMR8{EJabVmSL zJMQJ3t=&A{g+NTE519FheL9SkNhKUl%lz2s6@x5Y*)SV%(D<43Xu?yhp$I;dD21F- zc|wyIq4Aqiig;=H!)(Ut!=R&`sb1t>Jtd~-;amSR*DQv?PN}gMes|pz<)cD1rZu@z zm^ic&)!!zUm7rJMPZ{qJP{-R%Q>Qt2UhxGJMLmqzxJtOH_dfxqCy$qK!>4)hCnhAU zG;qGj&2?WK_Ex%D5YIH$8E;##g9rnBB18ekO`LY+l>zp9&EJhJ&Z5Mt8h*;_tx5e_ zC!2zm4Fw|&JKC;brGO(Rgn71vqJr;Oy+t&SyvAQYacZ8vKY5|l`9Y4{Ww$AdNsY^h z`JCuq)veV`Bjxsdkg*{b`+FAX!~9_Bl>SwvMl@f|@EiBwREy)I?E?J1W@;vHLE?q9 zb&j8a4TJS~4(WzF&r0#0in;=i2Tv8SX#_#!PUmttMy zc>>)d*uV;opXqh^83NYc=W!Klb}}5Jra@UMZ5YH zVQkHCiPAwzimazgezOE)XW^A4GznKeGxzU~MC85KQ{S}5YoW_cRVTvrvPI39?~4ek zfx*e7lohaX+3!jD8U;Q`bCS}N57hc>MZ>c=nRIY0YqE3WpVa0tPFToYgZZ**b&<2J zS(15L5T79Rr}}`i-scB0 z$f@5Tx--KT#>RF&EbJ|^7$E0nL;-c*DKL||id>60EMxDD(aMLQkGo8QnZd<)t_4bWN9YSWm6X2{>C{Of9POMRi<<09Iy-`Eq%4AXXAm%>r}eOv9~y4^Y(H z8N*+J(L$Z{rJQ@~t9Wf{)ZeJ8D?7n`&9fnm^tE8ZL#9ia6@SVw5Xd$9;jV`-g779m zysQVZf5OW#W~?tiI@)7+lzei>f}$>oJ-fMn?{<)3c|TJ`z<8L-FWfCu$0xKhH!W~( z`pG@`bp1};y9|zqYGMT4+KK=wQ2flG858yXC^q^@^Lppp(x7a!94D+L(5D@p=WCK7 zCpKUtI#~&5_Qmz=^JMEpwamda{_)!H*0qPkeZq?jQjOXRvyqE6BF-U(maYl{&3^4Q zLRjq#qpSAGs$bR>G@Oa74;|v!ki(O4jP-d{in^pDJhgdR&o{at3s&!sK1HYMp(mai zHm=Y!AM1L~v;Mf&#N&-Wej{bAf&#cxtM{fmZuQ)1@|>^@+4APIi_};KHu>{z7Kd#j z!Q}#;#x^dFFJ3&Gp0djfxUajv?kT2PT)BjWBmf~9;B-I(R4B8`~@_8+MK-l7kGR~zHdA(np6cmh+R63a12=+Zp4asm*I8cXO zyv&vNH}w%)-dhtWhSwFplA}5GmoJO;+5Xwd0s8^7rp?S3w~}IJE*HOA=*RsNhND=zfPP-Jli2;S;UDIgge_lh zZLd9zv{Z$aRjo6$dtKXb3Xh&|R|>i%$?VMg?-IsvE0i?<+`Dn-I-aZ`G<>h#ZL@Nq zv7FF|S2ru#@)77Y1CEIsAsJ3#in66|ojg2Jk(6@JYFufi%9z8VARDNz6vng%cU$Y5 zOyIjooZ$@O{G@2YB>VEbI!%S>1|>d;7-~2QZ<~~du(H8*!Z$i5%YQzhaRF4A&0r{f z57smOCMKB$UaStq2;rBWI%~AHBO#4qo^M;GNP8#!$gKM>x3Ep_Vi;Ln+!Ow#KfFSb zCL#FsdmJmqPhh-kT7DJ+_gfpB5aQt5zxcXlj6;*w0uKlwti@z-TnT8HHc}0 z9?nY;`$1UWmMSJrwqp4E1cTf1G&Lbm$|F}k=$gAAHs)#Ove`7!`66)q-d3qmkGN{x zLd(?JeaXqtPo?;2*6010#P$yxa~r^U_96+t5NAf(o~h}OmL;yO*oP->cpUHj*{i_G zHRx&Gkerv+jnnHQ$HN2kIl~+IAgeNj33VZ8JS;MIwo6%*Ms`HD=K9=TF8v zez>Exg_GS_X1u`1)03As8Etz_I3FlJgs<6&T}QM}=oE0^iDvl}?cu7=iyFM-qiSTZ zT{%5OADhI*)KCd{knt!iA4VIL9mxjNd@?EZMnV5D_ zv6i@sZO)W&OsjQ!C#S|UMPKVJjU8+6gn?(`7Nx%K!S7_-ovv*RJhUS{bYlS8G@2#a zTsB!FhSFkodehzqtHX^}z@~1D8?|QQ^s7(b-AxHjEiOI`CZ=KBiyRau8snBfx2Ro= ziN5*uHey85p+#b^??t>S1x>-K<6 zZ4PCl(hy7e*fi1+jkha}?N?L8AS54i>oj%QItlFrM^iZcM zBzWQ49c{M1@}9MG2U}$Z5LLlK!P}_zEZV_N`{Lp!PQynfhK<@Gn(VmhH8~EL zSdG7nT8t*R{dl2w-#}zQPd~qpxCu3p9tPTDfR@ho4}iS#SzrW;%HJkj4( zMx#J=cix|P--CyA-(YkUse8rqxTjULjVNG z6@%;g<7|7db)VdwB#g4LHU1-19PFUDw4M=rGtWz!; zCJWw^nAHx0kVp4LbC*)9y+4Vh}n z-MzKrOwe5OGf_l9f;tei|IvztP zQ+!ppXb1uJ?9GCxY5YvNNCng>w>5G7p09#RaXHkPRlLi2_y<`_WUsWuwmV^jDE48&JW>y-_X|9LyGy~S7Gwc-c|;fXXjYQye?COZbC(!N9Tbe zyd^Z>hwWQkl{SKd@vLU!;pfJs?~c@{IARRTPvznci^l4S8ErL9o9@)25^JsvcMGNs zCUeqH+fk22xRMG)ggA^j8h(rvWTvhlhC4}CWav~~$OZ&O1A+#rqsq6sXgdFT<7yIt zicN}!L=;hK82^keST(y&%X6|Bmp@3j+Dplxz=}9OBFseX-Ya&Mxf_D@3;GkQLEM z%`Ag|i3=&T{vphcuC~bfM~R{Xq6qc#AEwWd;m1M<4?k1X`io4}I+D8p;i@4QiVSZ3 zF)^=#aL>`O>luH4g|Ocipq?iZ1Y@TfNiMwcS(smvSq|WKi|8JW#A)H!%Ts%67YDz! z{-S9j>2x1!#W*u!^h{~)U&4_C2P8x!FwVP!eJk~}#WaLAaF+n+&W4KZK z#+zm9UH!N4*YrjIwENm98Q0f{;1EKTOL6MSc;ETjI~!wue3Ig8LD4{=BEMHp;SBWc zS$orJ4MPMl4dWDus7Pb6<2#aUh~d&$6_Wuj*;jwQD+mflDt0bAy;dw9X>?AOeLZbh z&|7*DHae21>4#;QTH!;44`f})ft4((i% zPK$a@Gvv$Wuk2g1`T#&E6Xa%Xh1j|o#;3{ugtIo)Z}TKWL#N+B3N(waTJdZk4R$4d zMJfydJ^ffN**UYV$$*P#8FY3AOXmRK^aOyBSmfewksa(Oujog^E3ysGB%e zI_sM~0L&N7j(>Ii7a0LKQ=AhMOQRAUm*nwxXC)jCKp`-4vzqQ{+NZB_i~xaTBjaCI zt(1=o6InF0&>}A=z;R-E0gf}$$w@QirTd>82LMcm^G*4a=t3RLk3|4WA=_B`LJjW= z2~GJ4{L1fz{x>FM+m}w5NCP&WLI4Z>CtfmeIeCHr*y=KzOsnpaNjRZ#u-^5ptj{>w z8F8FWkAit=2k(ah@U~Qr8IVniEE~iWe$pJpq42X_OvKnA7j8P|543}kt-HJr#{KkZ zlAwis=RqK|2!PUsvwZ!U)<8yJH~z9e_vjryNU_M|I{H4dy*Gek=mFeqMh1o97XWNd zCP@Sh78blgbmS~Q*4*iEJnEY44?j;q%{~Y>+0Ha%!bJ-IoQL&+?PQhm#cumvgNDH55{wKQWhrODRQJc zX=gmhUo~a(=7X${pkxMuKRtAAbQllPLx=+yl!+`=sUYcxt!>}VrV{$d-Lc)z11Bt{!;~By*Mg5R3JXy!3C8l=~SWw2=m84G|>9 zJK~jZUDy97mR43%)1J>mGSFi)O1L)UL6^N?wr*{2S zp*4NmQ?9elVk1Q>_kpE11oOPkxe^v50l=4nK7is4zXcY*P7l}*sn;p*bji4Y5!=P; zA%=S9MvlJw-0SF>fTNqKz*Gm4k^=O|j=Tmk&8bp0x4R9XgcpeI()JL;p95|KH*Unx zzY_l?Xq!R~?f-`Ue+!9y+7+&(*k@ypPSi81uMt{~#3hs>OJcPkpL^6%Q`GZr$(svw$ap}& z8{}A32Pzz?D?&ENyZoLOwJs(4dkXA)LmsE=c5t21^7)_onwENB|F5Bt#cG$kcvu^N z0Y_CjCd&XWUl_BtatWo!r7-5SXuVW)SV8Vb*uvE^g$wNsxY>Yl62KSjMXNa~{e~O6 zjjTSe$h@?FUwj`szbGozuH+19pMCy*M4IblH@!2Zc5rt^=1D*kd2T}xF?&}+#yeVA zv_t|CZ3nia0u5A>qw?=;Om7U8zJUQ(>e|tay2fq&OVm}7Y+^D(I_c1*M~r+m8L2uJ zlC3vQdPfFVRw?Z`6Yh0IX?06VRM7OyHj7t!wTM=_d&ZAeos<_&BAVB4EK@u(1;$<; z^}7*!R6UPJj6Aod-FLPpRq|@QVvTA`o97ofA;#gLY`)N`roFR zxO@#>`|WKGESF>m58Qu0vbKIa8kE}Brjl3O&^FJ;n)dH#mB3ZEu**79yn;8vSVJq6 z#>rnB=if2B@?S$$^8>81qq31bA6RbMb}we+t<1F)i~3WNZ3q1Q?=0Q_*EVQ7qx33z VVmoR+6-8bx4OQ(YrOGeg{vXNl;pzYY literal 0 HcmV?d00001