From 5cc1e94a13b91d8ed2a0a757575a0aa1a88be95d Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Fri, 13 Mar 2026 11:33:30 -0500 Subject: [PATCH] fix(ble): implement scanning for unbonded devices in common connections ui (#4779) Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com> --- .../meshtastic/core/ble/AndroidBleScanner.kt | 13 ++++- .../org/meshtastic/core/ble/BleScanner.kt | 2 +- .../org/meshtastic/core/ble/BleScannerTest.kt | 6 +- .../connections/AndroidScannerViewModel.kt | 2 + .../feature/connections/ScannerViewModel.kt | 56 ++++++++++++++++++- .../connections/ui/components/BLEDevices.kt | 14 ++++- 6 files changed, 83 insertions(+), 10 deletions(-) diff --git a/core/ble/src/androidMain/kotlin/org/meshtastic/core/ble/AndroidBleScanner.kt b/core/ble/src/androidMain/kotlin/org/meshtastic/core/ble/AndroidBleScanner.kt index 8d1ff6008..755994f8c 100644 --- a/core/ble/src/androidMain/kotlin/org/meshtastic/core/ble/AndroidBleScanner.kt +++ b/core/ble/src/androidMain/kotlin/org/meshtastic/core/ble/AndroidBleScanner.kt @@ -22,15 +22,24 @@ import no.nordicsemi.kotlin.ble.client.android.CentralManager import no.nordicsemi.kotlin.ble.client.distinctByPeripheral import org.koin.core.annotation.Single import kotlin.time.Duration +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid /** * An Android implementation of [BleScanner] using Nordic's [CentralManager]. * * @param centralManager The Nordic [CentralManager] to use for scanning. */ +@OptIn(ExperimentalUuidApi::class) @Single class AndroidBleScanner(private val centralManager: CentralManager) : BleScanner { - override fun scan(timeout: Duration): Flow = - centralManager.scan(timeout).distinctByPeripheral().map { AndroidBleDevice(it.peripheral) } + override fun scan(timeout: Duration, serviceUuid: Uuid?): Flow = centralManager + .scan(timeout = timeout) { + if (serviceUuid != null) { + ServiceUuid(serviceUuid) + } + } + .distinctByPeripheral() + .map { AndroidBleDevice(it.peripheral) } } diff --git a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/BleScanner.kt b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/BleScanner.kt index d0b4b3ac2..75dcbe114 100644 --- a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/BleScanner.kt +++ b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/BleScanner.kt @@ -27,5 +27,5 @@ interface BleScanner { * @param timeout The duration of the scan. * @return A [Flow] of discovered [BleDevice]s. */ - fun scan(timeout: Duration): Flow + fun scan(timeout: Duration, serviceUuid: kotlin.uuid.Uuid? = null): Flow } diff --git a/core/ble/src/test/kotlin/org/meshtastic/core/ble/BleScannerTest.kt b/core/ble/src/test/kotlin/org/meshtastic/core/ble/BleScannerTest.kt index 4a4fa28a3..18685428e 100644 --- a/core/ble/src/test/kotlin/org/meshtastic/core/ble/BleScannerTest.kt +++ b/core/ble/src/test/kotlin/org/meshtastic/core/ble/BleScannerTest.kt @@ -45,7 +45,7 @@ class BleScannerTest { fun `scan returns peripherals`() = runTest(testDispatcher) { val mockEnvironment = MockAndroidEnvironment.Api31(isBluetoothEnabled = true) val centralManager = CentralManager.mock(mockEnvironment, backgroundScope) - val scanner = BleScanner(centralManager) + val scanner = AndroidBleScanner(centralManager) val peripheral = PeripheralSpec.simulatePeripheral( @@ -70,7 +70,7 @@ class BleScannerTest { fun `scan with filter returns only matching peripherals`() = runTest(testDispatcher) { val mockEnvironment = MockAndroidEnvironment.Api31(isBluetoothEnabled = true) val centralManager = CentralManager.mock(mockEnvironment, backgroundScope) - val scanner = BleScanner(centralManager) + val scanner = AndroidBleScanner(centralManager) val targetUuid = Uuid.parse("4FAFC201-1FB5-459E-8FCC-C5C9C331914B") @@ -92,7 +92,7 @@ class BleScannerTest { centralManager.simulatePeripherals(listOf(matchingPeripheral, nonMatchingPeripheral)) val scannedDevices = mutableListOf() - val job = launch { scanner.scan(5.seconds) { ServiceUuid(targetUuid) }.toList(scannedDevices) } + val job = launch { scanner.scan(5.seconds, targetUuid).toList(scannedDevices) } // Needs time to scan in mock environment advanceUntilIdle() 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 974198ddd..fd97362c8 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 @@ -44,12 +44,14 @@ class AndroidScannerViewModel( getDiscoveredDevicesUseCase: GetDiscoveredDevicesUseCase, private val bluetoothRepository: BluetoothRepository, private val usbRepository: UsbRepository, + bleScanner: org.meshtastic.core.ble.BleScanner? = null, ) : ScannerViewModel( serviceRepository, radioController, radioInterfaceService, recentAddressesDataSource, getDiscoveredDevicesUseCase, + bleScanner, ) { 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 08c410843..4f2ed0581 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 @@ -28,6 +28,7 @@ import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.koin.core.annotation.KoinViewModel import org.meshtastic.core.datastore.RecentAddressesDataSource @@ -48,21 +49,70 @@ open class ScannerViewModel( private val radioInterfaceService: RadioInterfaceService, private val recentAddressesDataSource: RecentAddressesDataSource, private val getDiscoveredDevicesUseCase: GetDiscoveredDevicesUseCase, + private val bleScanner: org.meshtastic.core.ble.BleScanner? = null, ) : ViewModel() { val showMockInterface: StateFlow = MutableStateFlow(radioInterfaceService.isMockInterface()).asStateFlow() private val _errorText = MutableStateFlow(null) val errorText: StateFlow = _errorText.asStateFlow() + private val isBleScanningState = MutableStateFlow(false) + val isBleScanning: StateFlow = isBleScanningState.asStateFlow() + + private val scannedBleDevices = MutableStateFlow>(emptyMap()) + + private var scanJob: kotlinx.coroutines.Job? = null + + fun startBleScan() { + if (isBleScanningState.value || bleScanner == null) return + + isBleScanningState.value = true + scannedBleDevices.value = emptyMap() + + scanJob = + viewModelScope.launch { + try { + bleScanner + .scan( + timeout = kotlin.time.Duration.INFINITE, + serviceUuid = org.meshtastic.core.ble.MeshtasticBleConstants.SERVICE_UUID, + ) + .collect { device -> + scannedBleDevices.update { current -> current + (device.address to device) } + } + } catch (@Suppress("TooGenericExceptionCaught") e: Exception) { + co.touchlab.kermit.Logger.w(e) { "BLE scan failed" } + } finally { + isBleScanningState.value = false + } + } + } + + fun stopBleScan() { + scanJob?.cancel() + scanJob = null + isBleScanningState.value = false + } + private val discoveredDevicesFlow = showMockInterface .flatMapLatest { showMock -> getDiscoveredDevicesUseCase.invoke(showMock) } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null) - /** A combined list of bonded BLE devices for the UI. */ + /** A combined list of bonded and scanned BLE devices for the UI. */ val bleDevicesForUi: StateFlow> = - discoveredDevicesFlow - .map { it?.bleDevices ?: emptyList() } + kotlinx.coroutines.flow + .combine(discoveredDevicesFlow, scannedBleDevices) { discovered, scannedMap -> + val bonded = discovered?.bleDevices?.filterIsInstance() ?: emptyList() + val bondedAddresses = bonded.map { it.address }.toSet() + + // Add scanned devices that aren't already in the bonded list + val unbondedScanned = + scannedMap.values.filter { it.address !in bondedAddresses }.map { DeviceListEntry.Ble(it) } + + // Sort by name + (bonded + unbondedScanned).sortedBy { it.name } + } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) /** UI StateFlow for USB devices. */ diff --git a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/BLEDevices.kt b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/BLEDevices.kt index d12f5d76d..40b3c9abb 100644 --- a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/BLEDevices.kt +++ b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/BLEDevices.kt @@ -23,9 +23,11 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults +import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp @@ -46,8 +48,14 @@ import org.meshtastic.feature.connections.ScannerViewModel @Composable fun BLEDevices(connectionState: ConnectionState, selectedDevice: String, scanModel: ScannerViewModel) { val bleDevices by scanModel.bleDevicesForUi.collectAsStateWithLifecycle() + val isScanning by scanModel.isBleScanning.collectAsStateWithLifecycle() - Column { + DisposableEffect(Unit) { + scanModel.startBleScan() + onDispose { scanModel.stopBleScan() } + } + + Column(modifier = Modifier.fillMaxWidth()) { Text( text = stringResource(Res.string.bluetooth_available_devices), modifier = Modifier.padding(horizontal = 8.dp).padding(bottom = 16.dp).fillMaxWidth(), @@ -55,6 +63,10 @@ fun BLEDevices(connectionState: ConnectionState, selectedDevice: String, scanMod color = MaterialTheme.colorScheme.primary, ) + if (isScanning) { + LinearProgressIndicator(modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp)) + } + LazyColumn(modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp)) { items(bleDevices, key = { it.fullAddress }) { device -> Card(