From e781d6774bbf125674809c4942e104ff01e2622b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Kosson?= Date: Mon, 9 Jun 2025 19:44:53 +0200 Subject: [PATCH] feat: allow hiding offline and/or non-direct nodes from list and map (#2052) Co-authored-by: James Rich <2199651+jamesarich@users.noreply.github.com> --- .../com/geeksville/mesh/NodeInfoDaoTest.kt | 98 ++++++++++++++++++- .../main/java/com/geeksville/mesh/NodeInfo.kt | 5 +- .../mesh/database/NodeRepository.kt | 5 + .../mesh/database/dao/NodeInfoDao.kt | 4 + .../mesh/database/entity/NodeEntity.kt | 5 +- .../java/com/geeksville/mesh/model/UIState.kt | 44 +++++++-- .../com/geeksville/mesh/ui/node/NodeScreen.kt | 4 + .../ui/node/components/NodeFilterTextField.kt | 57 +++++++++++ .../com/geeksville/mesh/util/DateTimeUtils.kt | 3 + app/src/main/res/values/strings.xml | 2 + 10 files changed, 211 insertions(+), 16 deletions(-) diff --git a/app/src/androidTest/java/com/geeksville/mesh/NodeInfoDaoTest.kt b/app/src/androidTest/java/com/geeksville/mesh/NodeInfoDaoTest.kt index 737685f37..8111c538e 100644 --- a/app/src/androidTest/java/com/geeksville/mesh/NodeInfoDaoTest.kt +++ b/app/src/androidTest/java/com/geeksville/mesh/NodeInfoDaoTest.kt @@ -26,6 +26,7 @@ import com.geeksville.mesh.database.entity.MyNodeEntity import com.geeksville.mesh.database.entity.NodeEntity import com.geeksville.mesh.model.Node import com.geeksville.mesh.model.NodeSortOption +import com.geeksville.mesh.util.onlineTimeThreshold import com.google.protobuf.ByteString import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map @@ -43,6 +44,10 @@ class NodeInfoDaoTest { private lateinit var database: MeshtasticDatabase private lateinit var nodeInfoDao: NodeInfoDao + private val onlineThreshold = onlineTimeThreshold() + private val offlineNodeLastHeard = onlineThreshold - 30 + private val onlineNodeLastHeard = onlineThreshold + 20 + private val unknownNode = NodeEntity( num = 7, user = user { @@ -65,7 +70,64 @@ class NodeInfoDaoTest { isLicensed = false }, longName = "Kevin Mester", shortName = "KLO", - latitude = 30.267153, longitude = -97.743057 // Austin + latitude = 30.267153, longitude = -97.743057, // Austin + hopsAway = 0, + ) + + private val onlineNode = NodeEntity( + num = 9, + user = user { + id = "!25060801" + longName = "Meshtastic 0801" + shortName = "0801" + hwModel = MeshProtos.HardwareModel.ANDROID_SIM + }, + longName = "Meshtastic 0801", + shortName = "0801", + hopsAway = 0, + lastHeard = onlineNodeLastHeard + ) + + private val offlineNode = NodeEntity( + num = 10, + user = user { + id = "!25060802" + longName = "Meshtastic 0802" + shortName = "0802" + hwModel = MeshProtos.HardwareModel.ANDROID_SIM + }, + longName = "Meshtastic 0802", + shortName = "0802", + hopsAway = 0, + lastHeard = offlineNodeLastHeard + ) + + private val directNode = NodeEntity( + num = 11, + user = user { + id = "!25060803" + longName = "Meshtastic 0803" + shortName = "0803" + hwModel = MeshProtos.HardwareModel.ANDROID_SIM + }, + longName = "Meshtastic 0803", + shortName = "0803", + hopsAway = 0, + lastHeard = onlineNodeLastHeard + ) + + private val relayedNode = NodeEntity( + num = 12, + user = user { + id = "!25060804" + longName = "Meshtastic 0804" + shortName = "0804" + hwModel = MeshProtos.HardwareModel.ANDROID_SIM + }, + longName = "Meshtastic 0804", + shortName = "0804", + hopsAway = 3, + lastHeard = onlineNodeLastHeard ) private val myNodeInfo: MyNodeEntity = MyNodeEntity( @@ -93,9 +155,9 @@ class NodeInfoDaoTest { 41.878113 to -87.629799, // Chicago 39.952583 to -75.165222, // Philadelphia ) - private val testNodes = listOf(ourNode, unknownNode) + testPositions.mapIndexed { index, pos -> + private val testNodes = listOf(ourNode, unknownNode, onlineNode, offlineNode, directNode, relayedNode) + testPositions.mapIndexed { index, pos -> NodeEntity( - num = 9 + index, + num = 1000 + index, user = user { id = "+165087653%02d".format(9 + index) longName = "Kevin Mester$index" @@ -135,16 +197,20 @@ class NodeInfoDaoTest { sort: NodeSortOption = NodeSortOption.LAST_HEARD, filter: String = "", includeUnknown: Boolean = true, + onlyOnline: Boolean = false, + onlyDirect: Boolean = false, ) = nodeInfoDao.getNodes( sort = sort.sqlValue, filter = filter, includeUnknown = includeUnknown, + hopsAwayMax = if (onlyDirect) 0 else -1, + lastHeardMin = if (onlyOnline) onlineTimeThreshold() else -1, ).map { list -> list.map { it.toModel() } }.first().filter { it.num != ourNode.num } @Test // node list size fun testNodeListSize() = runBlocking { val nodes = nodeInfoDao.nodeDBbyNum().first() - assertEquals(12, nodes.size) + assertEquals(6 + testPositions.size, nodes.size) } @Test // nodeDBbyNum() re-orders our node at the top of the list @@ -205,6 +271,30 @@ class NodeInfoDaoTest { assertTrue(containsUnsetNode) } + @Test + fun testOfflineNodesIncludedByDefault() = runBlocking { + val nodes = getNodes() + assertTrue(nodes.any { it.lastHeard < onlineTimeThreshold() }) + } + + @Test + fun testOnlyOnlineExcludesOffline() = runBlocking { + val nodes = getNodes(onlyOnline = true) + assertFalse(nodes.any { it.lastHeard < onlineTimeThreshold() }) + } + + @Test + fun testRelayedNodesIncludedByDefault() = runBlocking { + val nodes = getNodes() + assertTrue(nodes.any { it.hopsAway > 0 }) + } + + @Test + fun testOnlyDirectExcludesRelayed() = runBlocking { + val nodes = getNodes(onlyDirect = true) + assertFalse(nodes.any { it.hopsAway > 0 }) + } + @Test fun testPkcMismatch() = runBlocking { val newNode = testNodes[1].copy(user = testNodes[1].user.copy { diff --git a/app/src/main/java/com/geeksville/mesh/NodeInfo.kt b/app/src/main/java/com/geeksville/mesh/NodeInfo.kt index 38aa4bde0..bf8bcbfdb 100644 --- a/app/src/main/java/com/geeksville/mesh/NodeInfo.kt +++ b/app/src/main/java/com/geeksville/mesh/NodeInfo.kt @@ -23,6 +23,7 @@ import com.geeksville.mesh.util.GPSFormat import com.geeksville.mesh.util.bearing import com.geeksville.mesh.util.latLongToMeter import com.geeksville.mesh.util.anonymize +import com.geeksville.mesh.util.onlineTimeThreshold import kotlinx.parcelize.Parcelize // @@ -202,9 +203,7 @@ data class NodeInfo( */ val isOnline: Boolean get() { - val now = System.currentTimeMillis() / 1000 - val timeout = 15 * 60 - return (now - lastHeard <= timeout) + return lastHeard > onlineTimeThreshold() } /// return the position if it is valid, else null diff --git a/app/src/main/java/com/geeksville/mesh/database/NodeRepository.kt b/app/src/main/java/com/geeksville/mesh/database/NodeRepository.kt index 20ce76d51..50f4c699b 100644 --- a/app/src/main/java/com/geeksville/mesh/database/NodeRepository.kt +++ b/app/src/main/java/com/geeksville/mesh/database/NodeRepository.kt @@ -28,6 +28,7 @@ import com.geeksville.mesh.database.entity.MyNodeEntity import com.geeksville.mesh.database.entity.NodeEntity import com.geeksville.mesh.model.Node import com.geeksville.mesh.model.NodeSortOption +import com.geeksville.mesh.util.onlineTimeThreshold import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -96,10 +97,14 @@ class NodeRepository @Inject constructor( sort: NodeSortOption = NodeSortOption.LAST_HEARD, filter: String = "", includeUnknown: Boolean = true, + onlyOnline: Boolean = false, + onlyDirect: Boolean = false, ) = nodeInfoDao.getNodes( sort = sort.sqlValue, filter = filter, includeUnknown = includeUnknown, + hopsAwayMax = if (onlyDirect) 0 else -1, + lastHeardMin = if (onlyOnline) onlineTimeThreshold() else -1, ).mapLatest { list -> list.map { it.toModel() diff --git a/app/src/main/java/com/geeksville/mesh/database/dao/NodeInfoDao.kt b/app/src/main/java/com/geeksville/mesh/database/dao/NodeInfoDao.kt index 3dd438a94..518428983 100644 --- a/app/src/main/java/com/geeksville/mesh/database/dao/NodeInfoDao.kt +++ b/app/src/main/java/com/geeksville/mesh/database/dao/NodeInfoDao.kt @@ -70,6 +70,8 @@ interface NodeInfoDao { AND (:filter = '' OR (long_name LIKE '%' || :filter || '%' OR short_name LIKE '%' || :filter || '%')) + AND (:lastHeardMin = -1 OR last_heard >= :lastHeardMin) + AND (:hopsAwayMax = -1 OR (hops_away <= :hopsAwayMax AND hops_away >= 0) OR num = (SELECT myNodeNum FROM my_node LIMIT 1)) ORDER BY CASE WHEN num = (SELECT myNodeNum FROM my_node LIMIT 1) THEN 0 ELSE 1 @@ -105,6 +107,8 @@ interface NodeInfoDao { sort: String, filter: String, includeUnknown: Boolean, + hopsAwayMax: Int, + lastHeardMin: Int, ): Flow> @Upsert diff --git a/app/src/main/java/com/geeksville/mesh/database/entity/NodeEntity.kt b/app/src/main/java/com/geeksville/mesh/database/entity/NodeEntity.kt index 5a04f5076..025fea8b3 100644 --- a/app/src/main/java/com/geeksville/mesh/database/entity/NodeEntity.kt +++ b/app/src/main/java/com/geeksville/mesh/database/entity/NodeEntity.kt @@ -33,6 +33,7 @@ import com.geeksville.mesh.Position import com.geeksville.mesh.TelemetryProtos import com.geeksville.mesh.copy import com.geeksville.mesh.model.Node +import com.geeksville.mesh.util.onlineTimeThreshold import com.google.protobuf.ByteString data class NodeWithRelations( @@ -164,9 +165,7 @@ data class NodeEntity( */ val isOnline: Boolean get() { - val now = System.currentTimeMillis() / 1000 - val timeout = 2 * 60 * 60 - return (now - lastHeard <= timeout) + return lastHeard > onlineTimeThreshold() } companion object { diff --git a/app/src/main/java/com/geeksville/mesh/model/UIState.kt b/app/src/main/java/com/geeksville/mesh/model/UIState.kt index ded62fdd3..7dd34bf91 100644 --- a/app/src/main/java/com/geeksville/mesh/model/UIState.kt +++ b/app/src/main/java/com/geeksville/mesh/model/UIState.kt @@ -68,6 +68,7 @@ import com.geeksville.mesh.util.getShortDate import com.geeksville.mesh.util.positionToMeter import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -152,6 +153,8 @@ data class NodesUiState( val sort: NodeSortOption = NodeSortOption.LAST_HEARD, val filter: String = "", val includeUnknown: Boolean = false, + val onlyOnline: Boolean = false, + val onlyDirect: Boolean = false, val gpsFormat: Int = 0, val distanceUnits: Int = 0, val tempInFahrenheit: Boolean = false, @@ -270,6 +273,8 @@ class UIViewModel @Inject constructor( private val nodeSortOption = MutableStateFlow(NodeSortOption.VIA_FAVORITE) private val includeUnknown = MutableStateFlow(preferences.getBoolean("include-unknown", false)) private val showDetails = MutableStateFlow(preferences.getBoolean("show-details", false)) + private val onlyOnline = MutableStateFlow(preferences.getBoolean("only-online", false)) + private val onlyDirect = MutableStateFlow(preferences.getBoolean("only-direct", false)) fun setSortOption(sort: NodeSortOption) { nodeSortOption.value = sort @@ -285,17 +290,44 @@ class UIViewModel @Inject constructor( preferences.edit { putBoolean("include-unknown", includeUnknown.value) } } - val nodesUiState: StateFlow = combine( + fun toggleOnlyOnline() { + onlyOnline.value = !onlyOnline.value + preferences.edit { putBoolean("only-online", onlyOnline.value) } + } + + fun toggleOnlyDirect() { + onlyDirect.value = !onlyDirect.value + preferences.edit { putBoolean("only-direct", onlyDirect.value) } + } + + data class NodeFilterState( + val filterText: String, + val includeUnknown: Boolean, + val onlyOnline: Boolean, + val onlyDirect: Boolean, + ) + + val nodeFilterStateFlow: Flow = combine( nodeFilterText, - nodeSortOption, includeUnknown, + onlyOnline, + onlyDirect, + ) { filterText, includeUnknown, onlyOnline, onlyDirect -> + NodeFilterState(filterText, includeUnknown, onlyOnline, onlyDirect) + } + + val nodesUiState: StateFlow = combine( + nodeFilterStateFlow, + nodeSortOption, showDetails, radioConfigRepository.deviceProfileFlow, - ) { filter, sort, includeUnknown, showDetails, profile -> + ) { filterFlow, sort, showDetails, profile -> NodesUiState( sort = sort, - filter = filter, - includeUnknown = includeUnknown, + filter = filterFlow.filterText, + includeUnknown = filterFlow.includeUnknown, + onlyOnline = filterFlow.onlyOnline, + onlyDirect = filterFlow.onlyDirect, gpsFormat = profile.config.display.gpsFormat.number, distanceUnits = profile.config.display.units.number, tempInFahrenheit = profile.moduleConfig.telemetry.environmentDisplayFahrenheit, @@ -314,7 +346,7 @@ class UIViewModel @Inject constructor( ) val nodeList: StateFlow> = nodesUiState.flatMapLatest { state -> - nodeDB.getNodes(state.sort, state.filter, state.includeUnknown) + nodeDB.getNodes(state.sort, state.filter, state.includeUnknown, state.onlyOnline, state.onlyDirect) }.stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(5_000), diff --git a/app/src/main/java/com/geeksville/mesh/ui/node/NodeScreen.kt b/app/src/main/java/com/geeksville/mesh/ui/node/NodeScreen.kt index 330ac68c6..9034bdb6e 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/node/NodeScreen.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/node/NodeScreen.kt @@ -104,6 +104,10 @@ fun NodeScreen( onSortSelect = model::setSortOption, includeUnknown = state.includeUnknown, onToggleIncludeUnknown = model::toggleIncludeUnknown, + onlyOnline = state.onlyOnline, + onToggleOnlyOnline = model::toggleOnlyOnline, + onlyDirect = state.onlyDirect, + onToggleOnlyDirect = model::toggleOnlyDirect, showDetails = state.showDetails, onToggleShowDetails = model::toggleShowDetails, ) diff --git a/app/src/main/java/com/geeksville/mesh/ui/node/components/NodeFilterTextField.kt b/app/src/main/java/com/geeksville/mesh/ui/node/components/NodeFilterTextField.kt index e65699954..76e9673fe 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/node/components/NodeFilterTextField.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/node/components/NodeFilterTextField.kt @@ -59,6 +59,7 @@ import com.geeksville.mesh.model.NodeSortOption import com.geeksville.mesh.ui.common.preview.LargeFontPreview import com.geeksville.mesh.ui.common.theme.AppTheme +@Suppress("LongParameterList") @Composable fun NodeFilterTextField( modifier: Modifier = Modifier, @@ -68,6 +69,10 @@ fun NodeFilterTextField( onSortSelect: (NodeSortOption) -> Unit, includeUnknown: Boolean, onToggleIncludeUnknown: () -> Unit, + onlyOnline: Boolean, + onToggleOnlyOnline: () -> Unit, + onlyDirect: Boolean, + onToggleOnlyDirect: () -> Unit, showDetails: Boolean, onToggleShowDetails: () -> Unit, ) { @@ -86,6 +91,10 @@ fun NodeFilterTextField( onSortSelect = onSortSelect, includeUnknown = includeUnknown, onToggleIncludeUnknown = onToggleIncludeUnknown, + onlyOnline = onlyOnline, + onToggleOnlyOnline = onToggleOnlyOnline, + onlyDirect = onlyDirect, + onToggleOnlyDirect = onToggleOnlyDirect, showDetails = showDetails, onToggleShowDetails = onToggleShowDetails, ) @@ -152,6 +161,10 @@ private fun NodeSortButton( onSortSelect: (NodeSortOption) -> Unit, includeUnknown: Boolean, onToggleIncludeUnknown: () -> Unit, + onlyOnline: Boolean, + onToggleOnlyOnline: () -> Unit, + onlyDirect: Boolean, + onToggleOnlyDirect: () -> Unit, showDetails: Boolean, onToggleShowDetails: () -> Unit, modifier: Modifier = Modifier, @@ -207,6 +220,46 @@ private fun NodeSortButton( } } ) + DropdownMenuItem( + onClick = { + onToggleOnlyOnline() + expanded = false + }, + text = { + Row { + AnimatedVisibility(visible = onlyOnline) { + Icon( + imageVector = Icons.Default.Done, + contentDescription = null, + modifier = Modifier.padding(end = 4.dp), + ) + } + Text( + text = stringResource(id = R.string.node_filter_only_online), + ) + } + } + ) + DropdownMenuItem( + onClick = { + onToggleOnlyDirect() + expanded = false + }, + text = { + Row { + AnimatedVisibility(visible = onlyDirect) { + Icon( + imageVector = Icons.Default.Done, + contentDescription = null, + modifier = Modifier.padding(end = 4.dp), + ) + } + Text( + text = stringResource(id = R.string.node_filter_only_direct), + ) + } + } + ) HorizontalDivider() DropdownMenuItem( onClick = { @@ -243,6 +296,10 @@ private fun NodeFilterTextFieldPreview() { onSortSelect = {}, includeUnknown = false, onToggleIncludeUnknown = {}, + onlyOnline = false, + onToggleOnlyOnline = {}, + onlyDirect = false, + onToggleOnlyDirect = {}, showDetails = false, onToggleShowDetails = {}, ) diff --git a/app/src/main/java/com/geeksville/mesh/util/DateTimeUtils.kt b/app/src/main/java/com/geeksville/mesh/util/DateTimeUtils.kt index 31edd3677..1c95d1132 100644 --- a/app/src/main/java/com/geeksville/mesh/util/DateTimeUtils.kt +++ b/app/src/main/java/com/geeksville/mesh/util/DateTimeUtils.kt @@ -60,3 +60,6 @@ private fun formatUptime(seconds: Long): String { "${secs}s".takeIf { secs > 0 }, ).joinToString(" ") } + +@Suppress("MagicNumber") +fun onlineTimeThreshold() = (System.currentTimeMillis() / 1000 - 2 * 60 * 60).toInt() diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index bfee9de40..bda202378 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -40,6 +40,8 @@ Filter clear node filter Include unknown + Hide offline nodes + Only show direct nodes Show details Node sorting options A-Z