mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
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:
parent
afe1356430
commit
5cc1e94a13
6 changed files with 83 additions and 10 deletions
|
|
@ -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) }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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}" }
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue