feat(connections): connection sorting & conversation empty channel ranking (#5295)

This commit is contained in:
James Rich
2026-04-29 12:18:57 -05:00
committed by GitHub
parent 3d2b21843e
commit 1966889c2d
4 changed files with 102 additions and 32 deletions

View File

@@ -42,6 +42,7 @@ class FakeBleDevice(
override val address: String,
override val name: String? = "Fake Device",
initialState: BleConnectionState = BleConnectionState.Disconnected(),
override val rssi: Int? = null,
) : BaseFake(),
BleDevice {
private val _state = mutableStateFlow(initialState)

View File

@@ -97,6 +97,7 @@ open class ScannerViewModel(
val bleAutoScan: StateFlow<Boolean> = uiPrefs.bleAutoScan
private val scannedBleDevices = MutableStateFlow<Map<String, BleDevice>>(emptyMap())
private val discoveryOrder = MutableStateFlow<List<String>>(emptyList())
private var scanJob: Job? = null
// ── Network scanning (NSD gating) ─────────────────────────────────────────────────────────
@@ -156,37 +157,36 @@ open class ScannerViewModel(
/**
* Combined bonded + scanned BLE devices for the UI.
*
* Sorted by signal strength — scanned devices with a known RSSI appear first in descending order (strongest signal
* at the top), followed by bonded-only devices without a scan RSSI, sorted by name.
* 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.
*/
val bleDevicesForUi: StateFlow<List<DeviceListEntry>> =
combine(discoveredDevicesFlow, scannedBleDevices) { discovered, scannedMap ->
combine(discoveredDevicesFlow, scannedBleDevices, discoveryOrder) { discovered, scannedMap, order ->
val bonded = discovered.bleDevices.filterIsInstance<DeviceListEntry.Ble>()
val bondedAddresses = bonded.mapTo(mutableSetOf()) { it.address }
// Scanned-but-not-bonded devices are explicitly flagged unbonded so the UI routes through
// requestBonding() — which on Android triggers createBond() for the pairing dialog before connecting.
// Preserves discovery order to prevent items jumping around during the scan burst.
val unbondedScanned =
scannedMap.values
.asSequence()
.filter { it.address !in bondedAddresses }
.map { DeviceListEntry.Ble(device = it, bonded = false) }
order
.filter { it !in bondedAddresses }
.mapNotNull { address ->
scannedMap[address]?.let { DeviceListEntry.Ble(device = it, bonded = false) }
}
// For bonded devices, attach the latest scan RSSI (if we've seen an advertisement this session) so they
// sort alongside unbonded entries by signal strength.
val bondedWithRssi =
bonded.asSequence().map { entry ->
val scanned = scannedMap[entry.address]
if (scanned != null && scanned.rssi != null) entry.copy(device = scanned) else entry
}
// For bonded devices, attach the latest scan RSSI (if we've seen an advertisement this session) so the
// UI can show the signal indicator, but keep them sorted by name for stability.
val bondedForUi =
bonded
.map { entry ->
val scanned = scannedMap[entry.address]
if (scanned != null && scanned.rssi != null) entry.copy(device = scanned) else entry
}
.sortedBy { it.name }
(bondedWithRssi + unbondedScanned)
.sortedWith(
compareByDescending<DeviceListEntry.Ble> { it.device.rssi != null }
.thenByDescending { it.device.rssi ?: Int.MIN_VALUE }
.thenBy { it.name },
)
.toList()
bondedForUi + unbondedScanned
}
.flowOn(dispatchers.default)
.distinctUntilChanged()
@@ -220,7 +220,6 @@ open class ScannerViewModel(
if (_isBleScanning.value || bleScanner == null) return
_isBleScanning.value = true
scannedBleDevices.value = emptyMap()
scanJob =
safeLaunch(tag = "startBleScan") {
@@ -239,6 +238,9 @@ open class ScannerViewModel(
current + (device.address to device)
}
}
if (device.address !in discoveryOrder.value) {
discoveryOrder.update { it + device.address }
}
}
} finally {
_isBleScanning.value = false
@@ -250,8 +252,6 @@ open class ScannerViewModel(
scanJob?.cancel()
scanJob = null
_isBleScanning.value = false
// Drop cached advertisements so stale RSSI values don't linger in the UI after the scan ends.
scannedBleDevices.value = emptyMap()
}
/** Convenience command: start scanning if idle, stop otherwise. Persists the resulting state to prefs. */
@@ -307,6 +307,7 @@ open class ScannerViewModel(
*/
fun onSelected(entry: DeviceListEntry): Boolean {
radioPrefs.setDevName(entry.name)
addRecentAddress(entry.fullAddress, entry.name)
return when (entry) {
is DeviceListEntry.Ble -> {
if (entry.bonded) {
@@ -327,10 +328,7 @@ open class ScannerViewModel(
}
}
is DeviceListEntry.Tcp -> {
safeLaunch(tag = "onSelectedTcp") {
addRecentAddress(entry.fullAddress, entry.name)
changeDeviceAddress(entry.fullAddress)
}
safeLaunch(tag = "onSelectedTcp") { changeDeviceAddress(entry.fullAddress) }
true
}
is DeviceListEntry.Mock -> {

View File

@@ -25,6 +25,7 @@ import dev.mokkery.mock
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
@@ -33,6 +34,7 @@ import org.meshtastic.core.network.repository.DiscoveredService
import org.meshtastic.core.network.repository.NetworkRepository
import org.meshtastic.core.repository.RadioInterfaceService
import org.meshtastic.core.repository.RadioPrefs
import org.meshtastic.core.testing.FakeBleDevice
import org.meshtastic.core.testing.FakeRadioController
import org.meshtastic.core.testing.FakeServiceRepository
import org.meshtastic.feature.connections.model.DeviceListEntry
@@ -208,4 +210,73 @@ class ScannerViewModelTest {
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `bleDevicesForUi sorts by bonded then discovery order`() = 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 scanFlow = MutableStateFlow<org.meshtastic.core.ble.BleDevice?>(null)
every { bleScanner.scan(any(), any()) } returns scanFlow.filterNotNull()
viewModel.bleDevicesForUi.test {
assertEquals(emptyList(), awaitItem())
// 1. Bonded device appears (via use case)
baseDevicesFlow.value = DiscoveredDevices(bleDevices = listOf(bondedDevice))
assertEquals(listOf(bondedDevice), awaitItem())
// 2. Scan finds Device 1 (Node B, -50dBm)
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)
// 3. Scan finds Device 2 (Node A, -30dBm) - stronger signal but should be 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)
// 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)
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `stopBleScan does not clear scanned devices`() = runTest {
val device = FakeBleDevice(address = "01:02:03:04:05:06", name = "Node", rssi = -50)
val scanFlow = MutableStateFlow<org.meshtastic.core.ble.BleDevice?>(null)
every { bleScanner.scan(any(), any()) } returns scanFlow.filterNotNull()
viewModel.bleDevicesForUi.test {
assertEquals(emptyList(), awaitItem())
viewModel.startBleScan()
scanFlow.value = device
assertEquals(1, awaitItem().size)
viewModel.stopBleScan()
// Should not emit a new empty list
expectNoEvents()
cancelAndIgnoreRemainingEvents()
}
}
}

View File

@@ -530,8 +530,8 @@ private fun ContactListContentInternal(
val visiblePlaceholders = rememberVisiblePlaceholders(contacts, channelPlaceholders)
LazyColumn(state = listState, modifier = modifier.fillMaxSize()) {
contactListPlaceholdersItems(
placeholders = visiblePlaceholders,
contactListPagedItems(
contacts = contacts,
selectedList = selectedList,
activeContactKey = activeContactKey,
onClick = onClick,
@@ -541,8 +541,8 @@ private fun ContactListContentInternal(
haptic = haptic,
)
contactListPagedItems(
contacts = contacts,
contactListPlaceholdersItems(
placeholders = visiblePlaceholders,
selectedList = selectedList,
activeContactKey = activeContactKey,
onClick = onClick,