mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-05-18 19:56:34 -04:00
feat(connections): connection sorting & conversation empty channel ranking (#5295)
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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 -> {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user