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 118adf234..21825126e 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 @@ -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> = - combine(discoveredDevicesFlow, scannedBleDevices, discoveryOrder) { discovered, scannedMap, order -> - val bonded = discovered.bleDevices.filterIsInstance() + 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().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 diff --git a/feature/connections/src/commonTest/kotlin/org/meshtastic/feature/connections/ScannerViewModelHarness.kt b/feature/connections/src/commonTest/kotlin/org/meshtastic/feature/connections/ScannerViewModelHarness.kt index 3bb1a1532..e9531f675 100644 --- a/feature/connections/src/commonTest/kotlin/org/meshtastic/feature/connections/ScannerViewModelHarness.kt +++ b/feature/connections/src/commonTest/kotlin/org/meshtastic/feature/connections/ScannerViewModelHarness.kt @@ -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>(emptyList()) + /** + * Currently-selected device address (the `fullAddress`), backing `radioInterfaceService.currentDeviceAddressFlow`. + */ + val currentDeviceAddressFlow = MutableStateFlow(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) diff --git a/feature/connections/src/commonTest/kotlin/org/meshtastic/feature/connections/ScannerViewModelTest.kt b/feature/connections/src/commonTest/kotlin/org/meshtastic/feature/connections/ScannerViewModelTest.kt index c9e401e9c..3cf94f0c9 100644 --- a/feature/connections/src/commonTest/kotlin/org/meshtastic/feature/connections/ScannerViewModelTest.kt +++ b/feature/connections/src/commonTest/kotlin/org/meshtastic/feature/connections/ScannerViewModelTest.kt @@ -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(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() }