fix(ble): implement scanning for unbonded devices in common connections ui (#4779)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich 2026-03-13 11:33:30 -05:00 committed by GitHub
parent afe1356430
commit 5cc1e94a13
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 83 additions and 10 deletions

View file

@ -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<BleDevice> =
centralManager.scan(timeout).distinctByPeripheral().map { AndroidBleDevice(it.peripheral) }
override fun scan(timeout: Duration, serviceUuid: Uuid?): Flow<BleDevice> = centralManager
.scan(timeout = timeout) {
if (serviceUuid != null) {
ServiceUuid(serviceUuid)
}
}
.distinctByPeripheral()
.map { AndroidBleDevice(it.peripheral) }
}

View file

@ -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<BleDevice>
fun scan(timeout: Duration, serviceUuid: kotlin.uuid.Uuid? = null): Flow<BleDevice>
}

View file

@ -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<no.nordicsemi.kotlin.ble.client.android.Peripheral>()
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()

View file

@ -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}" }

View file

@ -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<Boolean> = MutableStateFlow(radioInterfaceService.isMockInterface()).asStateFlow()
private val _errorText = MutableStateFlow<String?>(null)
val errorText: StateFlow<String?> = _errorText.asStateFlow()
private val isBleScanningState = MutableStateFlow(false)
val isBleScanning: StateFlow<Boolean> = isBleScanningState.asStateFlow()
private val scannedBleDevices = MutableStateFlow<Map<String, org.meshtastic.core.ble.BleDevice>>(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<List<DeviceListEntry>> =
discoveredDevicesFlow
.map { it?.bleDevices ?: emptyList() }
kotlinx.coroutines.flow
.combine(discoveredDevicesFlow, scannedBleDevices) { discovered, scannedMap ->
val bonded = discovered?.bleDevices?.filterIsInstance<DeviceListEntry.Ble>() ?: 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. */

View file

@ -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(