mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-02-07 06:12:56 -05:00
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>
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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<List<NodeWithRelations>>
|
||||
|
||||
@Upsert
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<NodesUiState> = 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<NodeFilterState> = combine(
|
||||
nodeFilterText,
|
||||
nodeSortOption,
|
||||
includeUnknown,
|
||||
onlyOnline,
|
||||
onlyDirect,
|
||||
) { filterText, includeUnknown, onlyOnline, onlyDirect ->
|
||||
NodeFilterState(filterText, includeUnknown, onlyOnline, onlyDirect)
|
||||
}
|
||||
|
||||
val nodesUiState: StateFlow<NodesUiState> = 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<List<Node>> = 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),
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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 = {},
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -40,6 +40,8 @@
|
||||
<string name="node_filter_placeholder">Filter</string>
|
||||
<string name="desc_node_filter_clear">clear node filter</string>
|
||||
<string name="node_filter_include_unknown">Include unknown</string>
|
||||
<string name="node_filter_only_online">Hide offline nodes</string>
|
||||
<string name="node_filter_only_direct">Only show direct nodes</string>
|
||||
<string name="node_filter_show_details">Show details</string>
|
||||
<string name="node_sort_button">Node sorting options</string>
|
||||
<string name="node_sort_alpha">A-Z</string>
|
||||
|
||||
Reference in New Issue
Block a user