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

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