feat(connections): list only BLE devices visible via scan (#5877)

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
James Rich
2026-06-20 07:53:51 -05:00
committed by GitHub
parent ea47e01c87
commit 53cb1568c7
3 changed files with 77 additions and 32 deletions

View File

@@ -155,15 +155,29 @@ open class ScannerViewModel(
// ── Device lists for UI ──────────────────────────────────────────────────────────────────
/**
* Combined bonded + scanned BLE devices for the UI.
* BLE devices for the UI — restricted to those currently visible via an active scan.
*
* Sorted for stability to prevent "shifting" as advertisements arrive: bonded devices always appear first (sorted
* by name), followed by unbonded scanned devices in the order they were first discovered. RSSI updates are
* reflected on the cards but do not trigger a re-sort.
* Previously bonded / system-paired peripherals that aren't advertising right now are intentionally excluded so the
* list reflects what's actually nearby. The currently-selected device is the one exception: it's always kept so the
* active connection stays visible (a connected radio stops advertising and would otherwise drop out).
*
* Sorted for stability to prevent "shifting" as advertisements arrive: bonded devices appear first (sorted by
* name), followed by unbonded scanned devices in the order they were first discovered. RSSI updates are reflected
* on the cards but do not trigger a re-sort.
*/
val bleDevicesForUi: StateFlow<List<DeviceListEntry>> =
combine(discoveredDevicesFlow, scannedBleDevices, discoveryOrder) { discovered, scannedMap, order ->
val bonded = discovered.bleDevices.filterIsInstance<DeviceListEntry.Ble>()
combine(
discoveredDevicesFlow,
scannedBleDevices,
discoveryOrder,
radioInterfaceService.currentDeviceAddressFlow,
) { discovered, scannedMap, order, selectedAddress ->
// Surface a bonded device only when it's currently visible via scan (advertising) or it's the selected
// device — this hides stale system-bonded peripherals that aren't nearby.
val bonded =
discovered.bleDevices.filterIsInstance<DeviceListEntry.Ble>().filter {
it.address in scannedMap || it.fullAddress == selectedAddress
}
val bondedAddresses = bonded.mapTo(mutableSetOf()) { it.address }
// Scanned-but-not-bonded devices are explicitly flagged unbonded so the UI routes through

View File

@@ -75,6 +75,11 @@ class ScannerViewModelHarness(val testDispatcher: TestDispatcher = UnconfinedTes
/** NSD-resolved services, gated by the network-scan flag in the ViewModel. */
val resolvedServicesFlow = MutableStateFlow<List<DiscoveredService>>(emptyList())
/**
* Currently-selected device address (the `fullAddress`), backing `radioInterfaceService.currentDeviceAddressFlow`.
*/
val currentDeviceAddressFlow = MutableStateFlow<String?>(null)
val dispatchers = CoroutineDispatchers(io = testDispatcher, main = testDispatcher, default = testDispatcher)
/**
@@ -95,7 +100,7 @@ class ScannerViewModelHarness(val testDispatcher: TestDispatcher = UnconfinedTes
init {
every { radioInterfaceService.isMockTransport() } returns false
every { radioInterfaceService.currentDeviceAddressFlow } returns MutableStateFlow(null)
every { radioInterfaceService.currentDeviceAddressFlow } returns currentDeviceAddressFlow
every { recentAddressesDataSource.recentAddresses } returns MutableStateFlow(emptyList())
every { networkRepository.resolvedList } returns resolvedServicesFlow
every { networkRepository.networkAvailable } returns flowOf(true)

View File

@@ -177,14 +177,11 @@ class ScannerViewModelTest {
}
@Test
fun `bleDevicesForUi sorts by bonded then discovery order`() = runTest {
fun `bleDevicesForUi shows bonded devices only once they are visible via scan`() = runTest {
val device1 = FakeBleDevice(address = "01:02:03:04:05:06", name = "Node B", rssi = -50)
val device2 = FakeBleDevice(address = "07:08:09:0A:0B:0C", name = "Node A", rssi = -30)
val bondedDevice =
DeviceListEntry.Ble(
device = FakeBleDevice(address = "0D:0E:0F:10:11:12", name = "Bonded C", rssi = null),
bonded = true,
)
val bondedBle = FakeBleDevice(address = "0D:0E:0F:10:11:12", name = "Bonded C", rssi = null)
val bondedDevice = DeviceListEntry.Ble(device = bondedBle, bonded = true)
val scanFlow = MutableStateFlow<org.meshtastic.core.ble.BleDevice?>(null)
every { bleScanner.scan(any(), any()) } returns scanFlow.filterNotNull()
@@ -192,33 +189,62 @@ class ScannerViewModelTest {
viewModel.bleDevicesForUi.test {
assertEquals(emptyList(), awaitItem())
// 1. Bonded device appears (via use case)
// A system-bonded device that isn't advertising stays hidden — the list only shows what's nearby.
baseDevicesFlow.value = DiscoveredDevices(bleDevices = listOf(bondedDevice))
assertEquals(listOf(bondedDevice), awaitItem())
expectNoEvents()
// 2. Scan finds Device 1 (Node B, -50dBm)
// 1. Scan finds Device 1 (Node B) — unbonded, appears and routes through bonding when tapped.
viewModel.startBleScan()
scanFlow.value = device1
val itemsAfterDevice1 = awaitItem()
assertEquals(2, itemsAfterDevice1.size)
assertEquals(bondedDevice.address, (itemsAfterDevice1[0] as DeviceListEntry.Ble).address)
assertEquals(device1.address, (itemsAfterDevice1[1] as DeviceListEntry.Ble).address)
val afterDevice1 = awaitItem()
assertEquals(1, afterDevice1.size)
assertEquals(device1.address, (afterDevice1[0] as DeviceListEntry.Ble).address)
assertEquals(false, afterDevice1[0].bonded)
// 3. Scan finds Device 2 (Node A, -30dBm) - stronger signal but should be AFTER Device 1 per discovery
// order
// 2. Scan finds Device 2 (Node A, -30dBm) - stronger signal but kept AFTER Device 1 per discovery order.
scanFlow.value = device2
val itemsAfterDevice2 = awaitItem()
assertEquals(3, itemsAfterDevice2.size)
assertEquals(bondedDevice.address, (itemsAfterDevice2[0] as DeviceListEntry.Ble).address)
assertEquals(device1.address, (itemsAfterDevice1[1] as DeviceListEntry.Ble).address)
assertEquals(device2.address, (itemsAfterDevice2[2] as DeviceListEntry.Ble).address)
val afterDevice2 = awaitItem()
assertEquals(2, afterDevice2.size)
assertEquals(device1.address, (afterDevice2[0] as DeviceListEntry.Ble).address)
assertEquals(device2.address, (afterDevice2[1] as DeviceListEntry.Ble).address)
// 4. Device 1 RSSI updates to -20dBm (strongest) - should NOT re-sort
// 3. The bonded device starts advertising — now it appears, flagged bonded and sorted first by name.
scanFlow.value = bondedBle
val afterBonded = awaitItem()
assertEquals(3, afterBonded.size)
assertEquals(bondedDevice.address, (afterBonded[0] as DeviceListEntry.Ble).address)
assertEquals(true, afterBonded[0].bonded)
assertEquals(device1.address, (afterBonded[1] as DeviceListEntry.Ble).address)
assertEquals(device2.address, (afterBonded[2] as DeviceListEntry.Ble).address)
// 4. Device 1 RSSI updates to -20dBm (strongest) - should NOT re-sort.
scanFlow.value = FakeBleDevice(address = device1.address, name = device1.name, rssi = -20)
val itemsAfterRssiUpdate = awaitItem()
assertEquals(3, itemsAfterRssiUpdate.size)
assertEquals(device1.address, (itemsAfterRssiUpdate[1] as DeviceListEntry.Ble).address)
assertEquals(-20, (itemsAfterRssiUpdate[1] as DeviceListEntry.Ble).device.rssi)
val afterRssiUpdate = awaitItem()
assertEquals(3, afterRssiUpdate.size)
assertEquals(device1.address, (afterRssiUpdate[1] as DeviceListEntry.Ble).address)
assertEquals(-20, (afterRssiUpdate[1] as DeviceListEntry.Ble).device.rssi)
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `bleDevicesForUi keeps the selected device visible even when not seen via scan`() = runTest {
val bondedBle = FakeBleDevice(address = "0D:0E:0F:10:11:12", name = "Bonded C", rssi = null)
val bondedDevice = DeviceListEntry.Ble(device = bondedBle, bonded = true)
viewModel.bleDevicesForUi.test {
assertEquals(emptyList(), awaitItem())
// The device is bonded and selected (e.g. auto-reconnect on launch); while connected it stops
// advertising, so a scan never sees it — but it must stay visible so the user can disconnect.
harness.currentDeviceAddressFlow.value = bondedDevice.fullAddress
baseDevicesFlow.value = DiscoveredDevices(bleDevices = listOf(bondedDevice))
val items = awaitItem()
assertEquals(1, items.size)
assertEquals(bondedDevice.fullAddress, items[0].fullAddress)
assertEquals(true, items[0].bonded)
cancelAndIgnoreRemainingEvents()
}