feat(map): filter the map by node role and by how a node was heard (#6959)

This commit is contained in:
James Rich authored and GitHub committed 2026-08-30 05:45:05 +00:00
1 parent 17587eb242
commit 2ac28e28c3
17 files changed
+866 -167

No files matched your search

+6
View File
@@ -1048,6 +1048,12 @@ map_download_status_complete
map_download_status_downloading
map_download_status_paused
map_filter
map_filter_all_roles
map_filter_display_title
map_filter_nodes_title
map_filter_roles_title
map_filter_show_ignored
map_filter_title
map_layer_formats
map_layer_opacity
map_node_popup_details
@@ -181,12 +181,12 @@ import org.meshtastic.feature.map.component.DeleteWaypointDialog
import org.meshtastic.feature.map.component.EditWaypointDialog
import org.meshtastic.feature.map.component.MapButton
import org.meshtastic.feature.map.component.MapControlsOverlay
import org.meshtastic.feature.map.component.MapFilterActions
import org.meshtastic.feature.map.component.MapFilterMenu
import org.meshtastic.feature.map.component.MapFilterSheet
import org.meshtastic.feature.map.component.NodeTrackFilterMenu
import org.meshtastic.feature.map.component.RasterOverlayToggles
import org.meshtastic.feature.map.component.SitePlannerLaunch
import org.meshtastic.feature.map.component.WaypointInfoDialog
import org.meshtastic.feature.map.component.mapFilterActions
import org.meshtastic.feature.map.component.toSitePlannerParams
import org.meshtastic.feature.map.includes
import org.meshtastic.feature.map.kml.ICON_URL_PROPERTY
@@ -909,6 +909,7 @@ fun MapView(
MapControlsOverlay(
modifier = Modifier.align(Alignment.TopCenter).padding(top = 8.dp),
onToggleFilterMenu = { mapFilterMenuExpanded = true },
filtersActive = mode !is GoogleMapMode.NodeTrack && mapFilterState.isNarrowing,
filterDropdownContent = {
if (mode is GoogleMapMode.NodeTrack) {
NodeTrackFilterMenu(
@@ -917,18 +918,11 @@ fun MapView(
selected = mapFilterState.lastHeardTrackFilter,
onSelect = mapViewModel::setLastHeardTrackFilter,
)
} else {
MapFilterMenu(
expanded = mapFilterMenuExpanded,
} else if (mapFilterMenuExpanded) {
MapFilterSheet(
onDismissRequest = { mapFilterMenuExpanded = false },
filterState = mapFilterState,
actions =
MapFilterActions(
onToggleOnlyFavorites = mapViewModel::toggleOnlyFavorites,
onToggleShowWaypoints = mapViewModel::toggleShowWaypointsOnMap,
onToggleShowPrecisionCircle = mapViewModel::toggleShowPrecisionCircleOnMap,
onSelectLastHeard = mapViewModel::setLastHeardFilter,
),
actions = mapViewModel.mapFilterActions(),
)
}
},
@@ -21,4 +21,12 @@ import kotlin.time.Duration.Companion.hours
private val ONLINE_WINDOW_HOURS = 2.hours
/**
* How recently a node must have been heard to count as online, in seconds.
*
* Exposed as well as applied by [onlineTimeThreshold] because not every caller has the wall clock to hand: the map's
* filter rules are pure functions given a `now`, so they compare against this window rather than re-reading the clock.
*/
val ONLINE_WINDOW_SECONDS: Long = ONLINE_WINDOW_HOURS.inWholeSeconds
fun onlineTimeThreshold(): Int = (nowInstant - ONLINE_WINDOW_HOURS).epochSeconds.toInt()
@@ -83,6 +83,50 @@ class MapPrefsImpl(private val dataStore: MapDataStore, dispatchers: CoroutineDi
scope.launch { dataStore.edit { it[KEY_LAST_HEARD_TRACK_FILTER_PREF] = seconds } }
}
override val onlyOnlineOnMap: StateFlow<Boolean> =
dataStore.data.map { it[KEY_ONLY_ONLINE_PREF] ?: false }.stateIn(scope, SharingStarted.Eagerly, false)
override fun setOnlyOnlineOnMap(only: Boolean) {
scope.launch { dataStore.edit { it[KEY_ONLY_ONLINE_PREF] = only } }
}
override val onlyDirectOnMap: StateFlow<Boolean> =
dataStore.data.map { it[KEY_ONLY_DIRECT_PREF] ?: false }.stateIn(scope, SharingStarted.Eagerly, false)
override fun setOnlyDirectOnMap(only: Boolean) {
scope.launch { dataStore.edit { it[KEY_ONLY_DIRECT_PREF] = only } }
}
override val excludeMqttOnMap: StateFlow<Boolean> =
dataStore.data.map { it[KEY_EXCLUDE_MQTT_PREF] ?: false }.stateIn(scope, SharingStarted.Eagerly, false)
override fun setExcludeMqttOnMap(exclude: Boolean) {
scope.launch { dataStore.edit { it[KEY_EXCLUDE_MQTT_PREF] = exclude } }
}
override val showIgnoredOnMap: StateFlow<Boolean> =
dataStore.data.map { it[KEY_SHOW_IGNORED_PREF] ?: false }.stateIn(scope, SharingStarted.Eagerly, false)
override fun setShowIgnoredOnMap(show: Boolean) {
scope.launch { dataStore.edit { it[KEY_SHOW_IGNORED_PREF] = show } }
}
override val includeUnknownOnMap: StateFlow<Boolean> =
dataStore.data.map { it[KEY_INCLUDE_UNKNOWN_PREF] ?: true }.stateIn(scope, SharingStarted.Eagerly, true)
override fun setIncludeUnknownOnMap(include: Boolean) {
scope.launch { dataStore.edit { it[KEY_INCLUDE_UNKNOWN_PREF] = include } }
}
override val excludedMapRoles: StateFlow<Set<String>> =
dataStore.data
.map { it[KEY_EXCLUDED_ROLES_PREF] ?: emptySet() }
.stateIn(scope, SharingStarted.Eagerly, emptySet())
override fun setExcludedMapRoles(roles: Set<String>) {
scope.launch { dataStore.edit { it[KEY_EXCLUDED_ROLES_PREF] = roles } }
}
override val hiddenLayerUrls: StateFlow<Set<String>> =
dataStore.data
.map { it[KEY_HIDDEN_LAYER_URLS_PREF] ?: emptySet() }
@@ -155,6 +199,12 @@ class MapPrefsImpl(private val dataStore: MapDataStore, dispatchers: CoroutineDi
val KEY_HIDDEN_LAYER_URLS_PREF = stringSetPreferencesKey("hidden_layer_urls")
val KEY_NETWORK_MAP_LAYERS_PREF = stringSetPreferencesKey("network_map_layers")
val KEY_LAYER_OPACITY_PREF = stringSetPreferencesKey("layer_opacity")
val KEY_ONLY_ONLINE_PREF = booleanPreferencesKey("map_only_online")
val KEY_ONLY_DIRECT_PREF = booleanPreferencesKey("map_only_direct")
val KEY_EXCLUDE_MQTT_PREF = booleanPreferencesKey("map_exclude_mqtt")
val KEY_SHOW_IGNORED_PREF = booleanPreferencesKey("map_show_ignored")
val KEY_INCLUDE_UNKNOWN_PREF = booleanPreferencesKey("map_include_unknown")
val KEY_EXCLUDED_ROLES_PREF = stringSetPreferencesKey("map_excluded_roles")
val KEY_CAMERA_LATITUDE = doublePreferencesKey("camera_latitude")
val KEY_CAMERA_LONGITUDE = doublePreferencesKey("camera_longitude")
val KEY_CAMERA_ZOOM = doublePreferencesKey("camera_zoom")
@@ -285,6 +285,41 @@ interface MapPrefs {
fun setLastHeardTrackFilter(seconds: Long)
/**
* Node filters shared with the node list's vocabulary, persisted separately: a user filtering the map to routers
* has not asked for the same of their contact list.
*/
val onlyOnlineOnMap: StateFlow<Boolean>
fun setOnlyOnlineOnMap(only: Boolean)
val onlyDirectOnMap: StateFlow<Boolean>
fun setOnlyDirectOnMap(only: Boolean)
val excludeMqttOnMap: StateFlow<Boolean>
fun setExcludeMqttOnMap(exclude: Boolean)
val showIgnoredOnMap: StateFlow<Boolean>
fun setShowIgnoredOnMap(show: Boolean)
val includeUnknownOnMap: StateFlow<Boolean>
fun setIncludeUnknownOnMap(include: Boolean)
/**
* Names of the device roles the user has switched off on the map.
*
* Excluded rather than included, and by name rather than ordinal: an included set would make nodes reporting a role
* added by future firmware invisible with no way to discover why, and `ROUTER_CLIENT = 3` is already a deprecated
* slot.
*/
val excludedMapRoles: StateFlow<Set<String>>
fun setExcludedMapRoles(roles: Set<String>)
/** URIs of imported map layers the user has toggled off; a layer is visible unless its URI is in this set. */
val hiddenLayerUrls: StateFlow<Set<String>>
@@ -1084,6 +1084,12 @@
<string name="map_download_status_downloading">Downloading</string>
<string name="map_download_status_paused">Paused</string>
<string name="map_filter">Map Filter\n</string>
<string name="map_filter_all_roles">All</string>
<string name="map_filter_display_title">Display</string>
<string name="map_filter_nodes_title">Nodes</string>
<string name="map_filter_roles_title">Node roles</string>
<string name="map_filter_show_ignored">Show ignored nodes</string>
<string name="map_filter_title">Filter map</string>
<string name="map_layer_formats">Map layers support .kml, .kmz, or GeoJSON formats.</string>
<string name="map_layer_opacity">Opacity: %1$d%</string>
<string name="map_node_popup_details">%1$s&lt;br&gt;Last heard: %2$s&lt;br&gt;Last position: %3$s&lt;br&gt;Battery: %4$s</string>
@@ -304,6 +304,42 @@ class FakeMapPrefs : MapPrefs {
lastHeardTrackFilter.value = seconds
}
override val onlyOnlineOnMap = MutableStateFlow(false)
override fun setOnlyOnlineOnMap(only: Boolean) {
onlyOnlineOnMap.value = only
}
override val onlyDirectOnMap = MutableStateFlow(false)
override fun setOnlyDirectOnMap(only: Boolean) {
onlyDirectOnMap.value = only
}
override val excludeMqttOnMap = MutableStateFlow(false)
override fun setExcludeMqttOnMap(exclude: Boolean) {
excludeMqttOnMap.value = exclude
}
override val showIgnoredOnMap = MutableStateFlow(false)
override fun setShowIgnoredOnMap(show: Boolean) {
showIgnoredOnMap.value = show
}
override val includeUnknownOnMap = MutableStateFlow(true)
override fun setIncludeUnknownOnMap(include: Boolean) {
includeUnknownOnMap.value = include
}
override val excludedMapRoles = MutableStateFlow<Set<String>>(emptySet())
override fun setExcludedMapRoles(roles: Set<String>) {
excludedMapRoles.value = roles
}
override val hiddenLayerUrls = MutableStateFlow<Set<String>>(emptySet())
override fun updateHiddenLayerUrls(transform: (Set<String>) -> Set<String>) {
@@ -52,8 +52,8 @@ import org.meshtastic.feature.map.component.ClusterMemberEntry
import org.meshtastic.feature.map.component.ClusterMembersDialog
import org.meshtastic.feature.map.component.EditWaypointDialog
import org.meshtastic.feature.map.component.MapControlsOverlay
import org.meshtastic.feature.map.component.MapFilterActions
import org.meshtastic.feature.map.component.MapFilterMenu
import org.meshtastic.feature.map.component.MapFilterSheet
import org.meshtastic.feature.map.component.mapFilterActions
import org.meshtastic.feature.map.layers.LayerOpacityStore
import org.meshtastic.feature.map.maplibre.component.BasemapButton
import org.meshtastic.feature.map.maplibre.component.BasemapSelection
@@ -320,28 +320,25 @@ private fun BoxScope.MapToolbar(
) {
var filterMenuExpanded by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
// Hoisted out of the dropdown slot: the button's badge needs the same state the sheet does.
val filterViewModel: SharedMapViewModel = koinViewModel()
val filterState by filterViewModel.mapFilterStateFlow.collectAsStateWithLifecycle()
MapControlsOverlay(
modifier = Modifier.align(Alignment.TopCenter).padding(top = TOOLBAR_INSET.dp),
onToggleFilterMenu = { filterMenuExpanded = !filterMenuExpanded },
filtersActive = filterState.isNarrowing,
bearing = cameraState.position.bearing.toFloat(),
followPhoneBearing = location.followingBearing,
onCompassClick = location.onCompassClick,
filterDropdownContent = {
val filterViewModel: SharedMapViewModel = koinViewModel()
val filterState by filterViewModel.mapFilterStateFlow.collectAsStateWithLifecycle()
MapFilterMenu(
expanded = filterMenuExpanded,
onDismissRequest = { filterMenuExpanded = false },
filterState = filterState,
actions =
MapFilterActions(
onToggleOnlyFavorites = filterViewModel::toggleOnlyFavorites,
onToggleShowWaypoints = filterViewModel::toggleShowWaypointsOnMap,
onToggleShowPrecisionCircle = filterViewModel::toggleShowPrecisionCircleOnMap,
onSelectLastHeard = filterViewModel::setLastHeardFilter,
),
)
if (filterMenuExpanded) {
MapFilterSheet(
onDismissRequest = { filterMenuExpanded = false },
filterState = filterState,
actions = filterViewModel.mapFilterActions(),
)
}
},
mapTypeContent = { BasemapButton(selection = basemaps, extra = basemapMenuExtra) },
layersContent = {
@@ -17,9 +17,8 @@
package org.meshtastic.feature.map
import androidx.lifecycle.ViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest
@@ -50,6 +49,7 @@ import org.meshtastic.core.resources.two_days
import org.meshtastic.core.ui.viewmodel.safeLaunch
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
import org.meshtastic.proto.ChannelSet
import org.meshtastic.proto.Config
import org.meshtastic.proto.Position
import org.meshtastic.proto.Waypoint
@@ -102,11 +102,6 @@ open class BaseMapViewModel(
.map { nodes -> nodes.filterNot { node -> node.isIgnored } }
.stateInWhileSubscribed(initialValue = emptyList())
val nodesWithPosition: StateFlow<List<Node>> =
nodes
.map { nodes -> nodes.filter { node -> node.validPosition != null } }
.stateInWhileSubscribed(initialValue = emptyList())
val waypoints: StateFlow<Map<Int, DataPacket>> =
packetRepository
.getWaypoints()
@@ -124,49 +119,82 @@ open class BaseMapViewModel(
/** True if the waypoint with [id] was created by this device (vs. received from another node over the mesh). */
fun isMyWaypoint(id: Int): Boolean = waypoints.value[id]?.isFromLocal(myNodeNum) == true
private val showOnlyFavorites = MutableStateFlow(mapPrefs.showOnlyFavorites.value)
val showOnlyFavoritesOnMap: StateFlow<Boolean> = showOnlyFavorites.asStateFlow()
// Every filter reads its persisted flow directly rather than snapshotting `.value` into a mirror at
// construction. MapPrefsImpl's flows start eagerly but load from DataStore asynchronously, so a view model built
// before that first read kept the defaults forever — nothing wrote the persisted values back into a mirror.
val showOnlyFavoritesOnMap: StateFlow<Boolean> = mapPrefs.showOnlyFavorites
fun toggleOnlyFavorites() {
val newValue = !showOnlyFavorites.value
showOnlyFavorites.value = newValue
mapPrefs.setShowOnlyFavorites(newValue)
fun toggleOnlyFavorites() = mapPrefs.setShowOnlyFavorites(!showOnlyFavoritesOnMap.value)
val showWaypointsOnMap: StateFlow<Boolean> = mapPrefs.showWaypointsOnMap
fun toggleShowWaypointsOnMap() = mapPrefs.setShowWaypointsOnMap(!showWaypointsOnMap.value)
val showPrecisionCircleOnMap: StateFlow<Boolean> = mapPrefs.showPrecisionCircleOnMap
fun toggleShowPrecisionCircleOnMap() = mapPrefs.setShowPrecisionCircleOnMap(!showPrecisionCircleOnMap.value)
val onlyOnlineOnMap: StateFlow<Boolean> = mapPrefs.onlyOnlineOnMap
fun toggleOnlyOnline() = mapPrefs.setOnlyOnlineOnMap(!onlyOnlineOnMap.value)
val onlyDirectOnMap: StateFlow<Boolean> = mapPrefs.onlyDirectOnMap
fun toggleOnlyDirect() = mapPrefs.setOnlyDirectOnMap(!onlyDirectOnMap.value)
val excludeMqttOnMap: StateFlow<Boolean> = mapPrefs.excludeMqttOnMap
fun toggleExcludeMqtt() = mapPrefs.setExcludeMqttOnMap(!excludeMqttOnMap.value)
val showIgnoredOnMap: StateFlow<Boolean> = mapPrefs.showIgnoredOnMap
fun toggleShowIgnored() = mapPrefs.setShowIgnoredOnMap(!showIgnoredOnMap.value)
val includeUnknownOnMap: StateFlow<Boolean> = mapPrefs.includeUnknownOnMap
fun toggleIncludeUnknown() = mapPrefs.setIncludeUnknownOnMap(!includeUnknownOnMap.value)
/**
* The nodes the map draws from.
*
* Built from the repository rather than from [nodes], which drops every ignored node unconditionally — that is the
* right default for the pickers that read it, but it left the map's own show-ignored filter with nothing to add
* back. [MapNodePolicy] still decides; this only stops the discard happening before it is asked.
*
* Declared here rather than beside [nodes] because it reads [showIgnoredOnMap], and a property initialiser cannot
* see one declared below it.
*/
val nodesWithPosition: StateFlow<List<Node>> =
combine(nodeRepository.getNodes(), showIgnoredOnMap) { all, showIgnored ->
all.filter { node -> node.validPosition != null && (showIgnored || !node.isIgnored) }
}
.stateInWhileSubscribed(initialValue = emptyList())
val excludedMapRoles: StateFlow<Set<Config.DeviceConfig.Role>> =
mapPrefs.excludedMapRoles
.map(::decodeExcludedRoles)
.stateInWhileSubscribed(decodeExcludedRoles(mapPrefs.excludedMapRoles.value))
fun toggleRoleExcluded(role: Config.DeviceConfig.Role) {
val newValue = excludedMapRoles.value.let { if (role in it) it - role else it + role }
mapPrefs.setExcludedMapRoles(newValue.mapTo(mutableSetOf()) { it.name })
}
private val showWaypoints = MutableStateFlow(mapPrefs.showWaypointsOnMap.value)
val showWaypointsOnMap: StateFlow<Boolean> = showWaypoints.asStateFlow()
fun clearExcludedRoles() = mapPrefs.setExcludedMapRoles(emptySet())
fun toggleShowWaypointsOnMap() {
val newValue = !showWaypoints.value
showWaypoints.value = newValue
mapPrefs.setShowWaypointsOnMap(newValue)
}
val lastHeardFilter: StateFlow<LastHeardFilter> =
mapPrefs.lastHeardFilter
.map(LastHeardFilter::fromSeconds)
.stateInWhileSubscribed(LastHeardFilter.fromSeconds(mapPrefs.lastHeardFilter.value))
private val showPrecisionCircle = MutableStateFlow(mapPrefs.showPrecisionCircleOnMap.value)
val showPrecisionCircleOnMap: StateFlow<Boolean> = showPrecisionCircle.asStateFlow()
fun setLastHeardFilter(filter: LastHeardFilter) = mapPrefs.setLastHeardFilter(filter.seconds)
fun toggleShowPrecisionCircleOnMap() {
val newValue = !showPrecisionCircle.value
showPrecisionCircle.value = newValue
mapPrefs.setShowPrecisionCircleOnMap(newValue)
}
val lastHeardTrackFilter: StateFlow<LastHeardFilter> =
mapPrefs.lastHeardTrackFilter
.map(LastHeardFilter::fromSeconds)
.stateInWhileSubscribed(LastHeardFilter.fromSeconds(mapPrefs.lastHeardTrackFilter.value))
private val lastHeardFilterValue = MutableStateFlow(LastHeardFilter.fromSeconds(mapPrefs.lastHeardFilter.value))
val lastHeardFilter: StateFlow<LastHeardFilter> = lastHeardFilterValue.asStateFlow()
fun setLastHeardFilter(filter: LastHeardFilter) {
lastHeardFilterValue.value = filter
mapPrefs.setLastHeardFilter(filter.seconds)
}
private val lastHeardTrackFilterValue =
MutableStateFlow(LastHeardFilter.fromSeconds(mapPrefs.lastHeardTrackFilter.value))
val lastHeardTrackFilter: StateFlow<LastHeardFilter> = lastHeardTrackFilterValue.asStateFlow()
fun setLastHeardTrackFilter(filter: LastHeardFilter) {
lastHeardTrackFilterValue.value = filter
mapPrefs.setLastHeardTrackFilter(filter.seconds)
}
fun setLastHeardTrackFilter(filter: LastHeardFilter) = mapPrefs.setLastHeardTrackFilter(filter.seconds)
open fun getUser(userId: String?) =
nodeRepository.getUser(userId ?: org.meshtastic.core.model.NodeAddress.ID_BROADCAST)
@@ -189,17 +217,42 @@ open class BaseMapViewModel(
fun generatePacketId(): Int = radioController.generatePacketId()
/**
* Everything the map's filter sheet controls.
*
* The node-level filters mirror the node list's, down to reusing its string resources, so the same words mean the
* same thing on both screens. [showIgnored] is the one deliberate divergence — see [MapNodePolicy].
*/
data class MapFilterState(
val onlyFavorites: Boolean,
val showWaypoints: Boolean,
val showPrecisionCircle: Boolean,
val lastHeardFilter: LastHeardFilter,
val lastHeardTrackFilter: LastHeardFilter,
)
/** Roles the user has switched off. Excluded rather than included so a role added by future firmware shows. */
val excludedRoles: Set<Config.DeviceConfig.Role> = emptySet(),
val onlyOnline: Boolean = false,
val onlyDirect: Boolean = false,
val excludeMqtt: Boolean = false,
val showIgnored: Boolean = false,
val includeUnknown: Boolean = true,
) {
/** True when anything here is narrowing the node set, so the filter button can show it. */
val isNarrowing: Boolean
get() =
onlyFavorites ||
excludedRoles.isNotEmpty() ||
onlyOnline ||
onlyDirect ||
excludeMqtt ||
!includeUnknown ||
lastHeardFilter != LastHeardFilter.Any
}
val mapFilterStateFlow: StateFlow<MapFilterState> =
// Two intermediate combines rather than one: `combine` tops out at five flows, and there are eleven.
private val displayFilters: Flow<MapFilterState> =
combine(
showOnlyFavorites,
showOnlyFavoritesOnMap,
showWaypointsOnMap,
showPrecisionCircleOnMap,
lastHeardFilter,
@@ -207,16 +260,52 @@ open class BaseMapViewModel(
) { favoritesOnly, showWaypoints, showPrecisionCircle, lastHeardFilter, lastHeardTrackFilter ->
MapFilterState(favoritesOnly, showWaypoints, showPrecisionCircle, lastHeardFilter, lastHeardTrackFilter)
}
private val nodeFilters: Flow<NodeFilters> =
combine(excludedMapRoles, onlyOnlineOnMap, onlyDirectOnMap, excludeMqttOnMap, showIgnoredOnMap, ::NodeFilters)
val mapFilterStateFlow: StateFlow<MapFilterState> =
combine(displayFilters, nodeFilters, includeUnknownOnMap) { display, nodes, includeUnknown ->
display.with(nodes, includeUnknown)
}
.stateInWhileSubscribed(
initialValue =
MapFilterState(
showOnlyFavorites.value,
showOnlyFavoritesOnMap.value,
showWaypointsOnMap.value,
showPrecisionCircleOnMap.value,
lastHeardFilter.value,
lastHeardTrackFilter.value,
),
)
.with(
NodeFilters(
excludedMapRoles.value,
onlyOnlineOnMap.value,
onlyDirectOnMap.value,
excludeMqttOnMap.value,
showIgnoredOnMap.value,
),
includeUnknownOnMap.value,
),
)
/** The five node-level toggles, boxed so they fit one `combine`. */
private data class NodeFilters(
val excludedRoles: Set<Config.DeviceConfig.Role>,
val onlyOnline: Boolean,
val onlyDirect: Boolean,
val excludeMqtt: Boolean,
val showIgnored: Boolean,
)
private fun MapFilterState.with(nodes: NodeFilters, includeUnknown: Boolean) = copy(
excludedRoles = nodes.excludedRoles,
onlyOnline = nodes.onlyOnline,
onlyDirect = nodes.onlyDirect,
excludeMqtt = nodes.excludeMqtt,
showIgnored = nodes.showIgnored,
includeUnknown = includeUnknown,
)
}
/**
@@ -17,6 +17,8 @@
package org.meshtastic.feature.map
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.util.ONLINE_WINDOW_SECONDS
import org.meshtastic.proto.Config
/**
* Which nodes a map shows, and which of them draws on top.
@@ -64,9 +66,36 @@ object MapNodePolicy {
node.validPosition != null && (node.num == myNodeNum || node.passesFilters(filterState, nowSeconds))
}
/**
* One node against every filter.
*
* The node-level rules match the node list's query exactly, because they carry its labels: direct means `hopsAway
* == 0` — a node whose distance was never measured (-1) is not direct — and unknown means a node that has not sent
* a short name.
*
* [BaseMapViewModel.MapFilterState.showIgnored] is the one place the map deliberately differs. The node list
* segregates (`isIgnored == showIgnored`, so switching it on shows *only* ignored nodes); here it reads literally
* and adds them, because a map of nothing but ignored nodes is not a view anyone asked for.
*/
private fun Node.passesFilters(filterState: BaseMapViewModel.MapFilterState, nowSeconds: Long): Boolean {
if (filterState.onlyFavorites && !isFavorite) return false
val window = filterState.lastHeardFilter.seconds
return window == LastHeardFilter.Any.seconds || (nowSeconds - lastHeard) <= window
val secondsSinceHeard = nowSeconds - lastHeard
val cutoff = filterState.lastHeardFilter.seconds
return (!filterState.onlyFavorites || isFavorite) &&
(!isIgnored || filterState.showIgnored) &&
user.role !in filterState.excludedRoles &&
(!filterState.onlyOnline || secondsSinceHeard <= ONLINE_WINDOW_SECONDS) &&
(!filterState.onlyDirect || hopsAway == 0) &&
(!filterState.excludeMqtt || !viaMqtt) &&
(filterState.includeUnknown || user.short_name.isNotEmpty()) &&
(cutoff == LastHeardFilter.Any.seconds || secondsSinceHeard <= cutoff)
}
}
/**
* Resolves persisted role names back to roles, dropping any the current protobufs no longer define.
*
* Names rather than ordinals, and forgiving of names it does not know: a set written by a newer build (or a role
* removed from the protos) must not throw on the way back in.
*/
fun decodeExcludedRoles(names: Set<String>): Set<Config.DeviceConfig.Role> =
names.mapNotNullTo(mutableSetOf()) { name -> Config.DeviceConfig.Role.entries.firstOrNull { it.name == name } }
@@ -21,6 +21,8 @@ package org.meshtastic.feature.map.component
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Badge
import androidx.compose.material3.BadgedBox
import androidx.compose.material3.CircularWavyProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.FloatingToolbarDefaults
@@ -55,8 +57,10 @@ import org.meshtastic.core.ui.theme.StatusColors.StatusRed
* Zoom is deliberately not here — it lives in [MapZoomControls], in the lower corner where Google Maps draws its own.
*
* @param onToggleFilterMenu Callback to open/close the filter dropdown.
* @param filterDropdownContent Composable rendered inside a [Box] alongside the filter button — typically a
* `DropdownMenu` with filter options.
* @param filterDropdownContent Composable rendered inside a [Box] alongside the filter button — [MapFilterSheet] on the
* main map, a dropdown on the node-track map.
* @param filtersActive Whether any filter is narrowing what the map shows. Badges the button, because a filter that
* hides nodes is otherwise indistinguishable from a quiet mesh.
* @param mapTypeContent Optional composable for a map type selector button + dropdown. Google flavor provides map type
* and custom tile options; F-Droid provides a tile source selector.
* @param layersContent Optional composable for a layers management button.
@@ -75,6 +79,7 @@ fun MapControlsOverlay(
onCompassClick: () -> Unit = {},
followPhoneBearing: Boolean = false,
filterDropdownContent: @Composable () -> Unit = {},
filtersActive: Boolean = false,
mapTypeContent: @Composable () -> Unit = {},
layersContent: @Composable () -> Unit = {},
onSitePlannerClick: (() -> Unit)? = null,
@@ -96,11 +101,13 @@ fun MapControlsOverlay(
// Filter button + dropdown (optional)
onToggleFilterMenu?.let { onClick ->
Box {
MapButton(
icon = MeshtasticIcons.Tune,
contentDescription = stringResource(Res.string.map_filter),
onClick = onClick,
)
BadgedBox(badge = { if (filtersActive) Badge() }) {
MapButton(
icon = MeshtasticIcons.Tune,
contentDescription = stringResource(Res.string.map_filter),
onClick = onClick,
)
}
filterDropdownContent()
}
}
@@ -17,18 +17,49 @@
package org.meshtastic.feature.map.component
import androidx.compose.runtime.Stable
import org.meshtastic.feature.map.BaseMapViewModel
import org.meshtastic.feature.map.LastHeardFilter
import org.meshtastic.proto.Config
/**
* What the map's filter menu can do.
*
* Hoisted into its own type so [MapFilterMenu] never holds a view model: each flavour builds this from whichever
* Hoisted into its own type so [MapFilterSheet] never holds a view model: each flavour builds this from whichever
* [org.meshtastic.feature.map.BaseMapViewModel] subclass it has, and the menu stays a pure function of state.
*/
@Stable
@Suppress("LongParameterList") // One callback per control; a bag of lambdas is the point of this type.
class MapFilterActions(
val onToggleOnlyFavorites: () -> Unit,
val onToggleShowWaypoints: () -> Unit,
val onToggleShowPrecisionCircle: () -> Unit,
val onSelectLastHeard: (LastHeardFilter) -> Unit,
val onToggleRoleExcluded: (Config.DeviceConfig.Role) -> Unit,
val onClearExcludedRoles: () -> Unit,
val onToggleOnlyOnline: () -> Unit,
val onToggleOnlyDirect: () -> Unit,
val onToggleExcludeMqtt: () -> Unit,
val onToggleShowIgnored: () -> Unit,
val onToggleIncludeUnknown: () -> Unit,
)
/**
* The standard wiring from a view model to [MapFilterSheet].
*
* Both flavours build the identical bag of method references, and eleven of them written out twice is eleven chances
* for the two maps to drift apart again — which is exactly what [org.meshtastic.feature.map.MapNodePolicy] exists to
* prevent on the rules side.
*/
fun BaseMapViewModel.mapFilterActions(): MapFilterActions = MapFilterActions(
onToggleOnlyFavorites = ::toggleOnlyFavorites,
onToggleShowWaypoints = ::toggleShowWaypointsOnMap,
onToggleShowPrecisionCircle = ::toggleShowPrecisionCircleOnMap,
onSelectLastHeard = ::setLastHeardFilter,
onToggleRoleExcluded = ::toggleRoleExcluded,
onClearExcludedRoles = ::clearExcludedRoles,
onToggleOnlyOnline = ::toggleOnlyOnline,
onToggleOnlyDirect = ::toggleOnlyDirect,
onToggleExcludeMqtt = ::toggleExcludeMqtt,
onToggleShowIgnored = ::toggleShowIgnored,
onToggleIncludeUnknown = ::toggleIncludeUnknown,
)
@@ -0,0 +1,223 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
package org.meshtastic.feature.map.component
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Checkbox
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.map_filter_all_roles
import org.meshtastic.core.resources.map_filter_display_title
import org.meshtastic.core.resources.map_filter_nodes_title
import org.meshtastic.core.resources.map_filter_roles_title
import org.meshtastic.core.resources.map_filter_show_ignored
import org.meshtastic.core.resources.map_filter_title
import org.meshtastic.core.resources.node_filter_exclude_mqtt
import org.meshtastic.core.resources.node_filter_include_unknown
import org.meshtastic.core.resources.node_filter_only_direct
import org.meshtastic.core.resources.node_filter_only_online
import org.meshtastic.core.resources.only_favorites
import org.meshtastic.core.resources.show_precision_circle
import org.meshtastic.core.resources.show_waypoints
import org.meshtastic.core.ui.icon.Favorite
import org.meshtastic.core.ui.icon.Lens
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.PinDrop
import org.meshtastic.core.ui.icon.role
import org.meshtastic.feature.map.BaseMapViewModel
import org.meshtastic.proto.Config
/** The tag the chip for one role carries, so a test can pick out a single role. */
fun roleFilterChipTestTag(role: Config.DeviceConfig.Role): String = "role-filter-${role.name}"
/**
* The main map's filters.
*
* A sheet rather than the dropdown this replaces: eleven controls and a role chip for every device role is more than a
* menu should hold, and the layers button on the same screen already opens a sheet.
*
* State in, actions out, exactly as the dropdown was — neither engine keeps its own copy, and the Google flavour mounts
* this same composable.
*/
@Composable
fun MapFilterSheet(
onDismissRequest: () -> Unit,
filterState: BaseMapViewModel.MapFilterState,
actions: MapFilterActions,
) {
ModalBottomSheet(onDismissRequest = onDismissRequest) {
MapFilterSheetContent(filterState = filterState, actions = actions)
}
}
/** The sheet's body, separate from the sheet itself so it can be tested without a window manager to host a modal. */
@Composable
internal fun MapFilterSheetContent(filterState: BaseMapViewModel.MapFilterState, actions: MapFilterActions) {
Column(modifier = Modifier.fillMaxWidth().verticalScroll(rememberScrollState())) {
Text(
text = stringResource(Res.string.map_filter_title),
style = MaterialTheme.typography.headlineSmall,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
SectionTitle(stringResource(Res.string.map_filter_display_title))
FilterToggle(
label = stringResource(Res.string.only_favorites),
icon = MeshtasticIcons.Favorite,
checked = filterState.onlyFavorites,
onToggle = actions.onToggleOnlyFavorites,
)
FilterToggle(
label = stringResource(Res.string.show_waypoints),
icon = MeshtasticIcons.PinDrop,
checked = filterState.showWaypoints,
onToggle = actions.onToggleShowWaypoints,
)
FilterToggle(
label = stringResource(Res.string.show_precision_circle),
icon = MeshtasticIcons.Lens,
checked = filterState.showPrecisionCircle,
onToggle = actions.onToggleShowPrecisionCircle,
)
LastHeardSlider(selected = filterState.lastHeardFilter, onSelect = actions.onSelectLastHeard)
HorizontalDivider()
SectionTitle(stringResource(Res.string.map_filter_roles_title))
RoleFilterChips(
excluded = filterState.excludedRoles,
onToggle = actions.onToggleRoleExcluded,
onClear = actions.onClearExcludedRoles,
)
HorizontalDivider()
SectionTitle(stringResource(Res.string.map_filter_nodes_title))
NodeFilterToggles(filterState = filterState, actions = actions)
}
}
/**
* The node-level filters, in the node list's own words — four of these five labels are its string resources, so a user
* who has met them there does not have to learn them twice.
*/
@Composable
private fun NodeFilterToggles(filterState: BaseMapViewModel.MapFilterState, actions: MapFilterActions) = Column {
FilterToggle(
label = stringResource(Res.string.node_filter_only_online),
checked = filterState.onlyOnline,
onToggle = actions.onToggleOnlyOnline,
)
FilterToggle(
label = stringResource(Res.string.node_filter_only_direct),
checked = filterState.onlyDirect,
onToggle = actions.onToggleOnlyDirect,
)
FilterToggle(
label = stringResource(Res.string.node_filter_exclude_mqtt),
checked = filterState.excludeMqtt,
onToggle = actions.onToggleExcludeMqtt,
)
// The fifth is ours: the list's `node_filter_show_ignored` reads "Only show ignored Nodes", which is what the
// list does and the opposite of what this does.
FilterToggle(
label = stringResource(Res.string.map_filter_show_ignored),
checked = filterState.showIgnored,
onToggle = actions.onToggleShowIgnored,
)
FilterToggle(
label = stringResource(Res.string.node_filter_include_unknown),
checked = filterState.includeUnknown,
onToggle = actions.onToggleIncludeUnknown,
)
}
/**
* One chip per device role, plus an "All" chip that clears the lot.
*
* Selected means shown, which is the way round a user reads a chip row — the state behind it is the complement, a set
* of *excluded* roles, so that a role introduced by later firmware appears instead of silently vanishing.
*/
@Composable
private fun RoleFilterChips(
excluded: Set<Config.DeviceConfig.Role>,
onToggle: (Config.DeviceConfig.Role) -> Unit,
onClear: () -> Unit,
) {
FlowRow(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
FilterChip(
selected = excluded.isEmpty(),
onClick = onClear,
label = { Text(stringResource(Res.string.map_filter_all_roles)) },
)
// Deprecated slots are still values on the wire, so a node can report one and has to be filterable.
@Suppress("DEPRECATION")
Config.DeviceConfig.Role.entries.forEach { role ->
FilterChip(
selected = role !in excluded,
onClick = { onToggle(role) },
label = { Text(role.name) },
leadingIcon = { Icon(imageVector = MeshtasticIcons.role(role), contentDescription = null) },
modifier = Modifier.testTag(roleFilterChipTestTag(role)),
)
}
}
}
@Composable
private fun SectionTitle(text: String) {
Text(
text = text,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 12.dp, bottom = 4.dp),
)
}
@Composable
private fun FilterToggle(label: String, checked: Boolean, onToggle: () -> Unit, icon: ImageVector? = null) {
ListItem(
headlineContent = { Text(label) },
leadingContent = icon?.let { { Icon(imageVector = it, contentDescription = null) } },
trailingContent = { Checkbox(checked = checked, onCheckedChange = { onToggle() }) },
)
}
@@ -20,14 +20,9 @@ package org.meshtastic.feature.map.component
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Checkbox
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuGroup
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MenuDefaults
import androidx.compose.material3.Slider
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -36,61 +31,13 @@ import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.last_heard_filter_label
import org.meshtastic.core.resources.only_favorites
import org.meshtastic.core.resources.show_precision_circle
import org.meshtastic.core.resources.show_waypoints
import org.meshtastic.core.ui.icon.Favorite
import org.meshtastic.core.ui.icon.Lens
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.PinDrop
import org.meshtastic.feature.map.BaseMapViewModel
import org.meshtastic.feature.map.LastHeardFilter
import kotlin.math.roundToInt
/**
* The main map's filter menu.
*
* State in, actions out: every control here reads [BaseMapViewModel.MapFilterState] and calls a setter the base view
* model already owns, so neither engine needs its own copy. Both had one the same four controls over the same state,
* differing only in whether the rows carried icons.
*/
@Composable
fun MapFilterMenu(
expanded: Boolean,
onDismissRequest: () -> Unit,
filterState: BaseMapViewModel.MapFilterState,
actions: MapFilterActions,
) {
DropdownMenu(expanded = expanded, onDismissRequest = onDismissRequest) {
DropdownMenuGroup(shapes = MenuDefaults.groupShapes()) {
FilterToggle(
label = stringResource(Res.string.only_favorites),
icon = MeshtasticIcons.Favorite,
checked = filterState.onlyFavorites,
onToggle = actions.onToggleOnlyFavorites,
)
FilterToggle(
label = stringResource(Res.string.show_waypoints),
icon = MeshtasticIcons.PinDrop,
checked = filterState.showWaypoints,
onToggle = actions.onToggleShowWaypoints,
)
FilterToggle(
label = stringResource(Res.string.show_precision_circle),
icon = MeshtasticIcons.Lens,
checked = filterState.showPrecisionCircle,
onToggle = actions.onToggleShowPrecisionCircle,
)
}
LastHeardSlider(selected = filterState.lastHeardFilter, onSelect = actions.onSelectLastHeard)
}
}
/**
* The filter menu for one node's position track.
*
@@ -110,23 +57,14 @@ fun NodeTrackFilterMenu(
}
}
@Composable
private fun FilterToggle(label: String, icon: ImageVector, checked: Boolean, onToggle: () -> Unit) {
DropdownMenuItem(
text = { Text(label) },
onClick = onToggle,
leadingIcon = { Icon(imageVector = icon, contentDescription = label) },
trailingIcon = { Checkbox(checked = checked, onCheckedChange = { onToggle() }) },
)
}
/**
* The age cutoff, as a slider over [LastHeardFilter]'s own entries.
*
* Written out four times before this: twice in each engine, once for the map and once for a track.
* Written out four times before this: twice in each engine, once for the map and once for a track. Shared with
* [MapFilterSheet], which shows the same control for the map's own cutoff.
*/
@Composable
private fun LastHeardSlider(selected: LastHeardFilter, onSelect: (LastHeardFilter) -> Unit) {
internal fun LastHeardSlider(selected: LastHeardFilter, onSelect: (LastHeardFilter) -> Unit) {
val options = LastHeardFilter.entries
val selectedIndex = options.indexOf(selected)
var sliderPosition by remember(selectedIndex) { mutableFloatStateOf(selectedIndex.toFloat()) }
@@ -31,6 +31,7 @@ import org.meshtastic.core.common.util.MeasurementSystem
import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.NodeAddress
import org.meshtastic.core.repository.MapPrefs
import org.meshtastic.core.repository.PacketRepository
@@ -40,6 +41,7 @@ import org.meshtastic.core.testing.FakeNotificationPrefs
import org.meshtastic.core.testing.FakeRadioConfigRepository
import org.meshtastic.core.testing.FakeRadioController
import org.meshtastic.core.testing.TestDataFactory
import org.meshtastic.proto.Position
import org.meshtastic.proto.Waypoint
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
@@ -57,6 +59,7 @@ class BaseMapViewModelTest {
private lateinit var radioConfigRepository: FakeRadioConfigRepository
private lateinit var waypointPacketsFlow: MutableStateFlow<List<DataPacket>>
private val mapPrefs: MapPrefs = mock()
private val showIgnored = MutableStateFlow(false)
private val packetRepository: PacketRepository = mock()
private val localeUnitsProvider = FakeLocaleUnitsProvider()
@@ -73,6 +76,12 @@ class BaseMapViewModelTest {
every { mapPrefs.showPrecisionCircleOnMap } returns MutableStateFlow(false)
every { mapPrefs.lastHeardFilter } returns MutableStateFlow(0L)
every { mapPrefs.lastHeardTrackFilter } returns MutableStateFlow(0L)
every { mapPrefs.onlyOnlineOnMap } returns MutableStateFlow(false)
every { mapPrefs.onlyDirectOnMap } returns MutableStateFlow(false)
every { mapPrefs.excludeMqttOnMap } returns MutableStateFlow(false)
every { mapPrefs.showIgnoredOnMap } returns showIgnored
every { mapPrefs.includeUnknownOnMap } returns MutableStateFlow(true)
every { mapPrefs.excludedMapRoles } returns MutableStateFlow(emptySet())
waypointPacketsFlow = MutableStateFlow(emptyList())
every { packetRepository.getWaypoints() } returns waypointPacketsFlow
@@ -128,6 +137,37 @@ class BaseMapViewModelTest {
}
}
@Test
fun `the map's node set hides ignored nodes by default`() = runTest(testDispatcher) {
nodeRepository.setNodes(listOf(positioned(1), positioned(2, isIgnored = true)))
viewModel.nodesWithPosition.test {
assertEquals(listOf(1), awaitItem().map { it.num })
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `the map's node set carries ignored nodes once the filter asks for them`() = runTest(testDispatcher) {
// The filter rule lives in MapNodePolicy, but the node list reaching it is built here — and it used to
// discard every ignored node unconditionally, so the toggle had nothing to add back.
// Set on the prefs flow, after the view model was built: that is the DataStore-arrives-late case, which
// used to be lost because the view model snapshotted `.value` into a mirror nothing updated.
nodeRepository.setNodes(listOf(positioned(1), positioned(2, isIgnored = true)))
showIgnored.value = true
viewModel.nodesWithPosition.test {
assertEquals(listOf(1, 2), awaitItem().map { it.num }.sorted())
cancelAndIgnoreRemainingEvents()
}
}
private fun positioned(num: Int, isIgnored: Boolean = false) = Node(
num = num,
position = Position(latitude_i = 450_000_000, longitude_i = -1_220_000_000),
isIgnored = isIgnored,
)
@Test
fun testConnectionStateFlow() = runTest(testDispatcher) {
viewModel.isConnected.test {
@@ -17,7 +17,9 @@
package org.meshtastic.feature.map
import org.meshtastic.core.model.Node
import org.meshtastic.proto.Config
import org.meshtastic.proto.Position
import org.meshtastic.proto.User
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
@@ -25,22 +27,52 @@ import kotlin.test.assertTrue
/** The rules both map engines answer: who appears, and who draws on top. */
class MapNodePolicyTest {
private fun node(num: Int, latitude: Double, longitude: Double, lastHeard: Int = 0, isFavorite: Boolean = false) =
Node(
num = num,
position = Position(latitude_i = (latitude * 1e7).toInt(), longitude_i = (longitude * 1e7).toInt()),
lastHeard = lastHeard,
isFavorite = isFavorite,
)
@Suppress("LongParameterList")
private fun node(
num: Int,
latitude: Double,
longitude: Double,
lastHeard: Int = 0,
isFavorite: Boolean = false,
role: Config.DeviceConfig.Role = Config.DeviceConfig.Role.CLIENT,
hopsAway: Int = 0,
viaMqtt: Boolean = false,
isIgnored: Boolean = false,
shortName: String = "ABCD",
) = Node(
num = num,
position = Position(latitude_i = (latitude * 1e7).toInt(), longitude_i = (longitude * 1e7).toInt()),
lastHeard = lastHeard,
isFavorite = isFavorite,
user = User(short_name = shortName, role = role),
hopsAway = hopsAway,
viaMqtt = viaMqtt,
isIgnored = isIgnored,
)
private fun filters(onlyFavorites: Boolean = false, lastHeard: LastHeardFilter = LastHeardFilter.Any) =
BaseMapViewModel.MapFilterState(
onlyFavorites = onlyFavorites,
showWaypoints = true,
showPrecisionCircle = true,
lastHeardFilter = lastHeard,
lastHeardTrackFilter = LastHeardFilter.Any,
)
@Suppress("LongParameterList")
private fun filters(
onlyFavorites: Boolean = false,
lastHeard: LastHeardFilter = LastHeardFilter.Any,
excludedRoles: Set<Config.DeviceConfig.Role> = emptySet(),
onlyOnline: Boolean = false,
onlyDirect: Boolean = false,
excludeMqtt: Boolean = false,
showIgnored: Boolean = false,
includeUnknown: Boolean = true,
) = BaseMapViewModel.MapFilterState(
onlyFavorites = onlyFavorites,
showWaypoints = true,
showPrecisionCircle = true,
lastHeardFilter = lastHeard,
lastHeardTrackFilter = LastHeardFilter.Any,
excludedRoles = excludedRoles,
onlyOnline = onlyOnline,
onlyDirect = onlyDirect,
excludeMqtt = excludeMqtt,
showIgnored = showIgnored,
includeUnknown = includeUnknown,
)
private fun visible(nodes: List<Node>, state: BaseMapViewModel.MapFilterState, now: Long = 0, mine: Int? = null) =
MapNodePolicy.visibleNodes(nodes, state, now, mine).map { it.num }
@@ -92,4 +124,89 @@ class MapNodePolicyTest {
assertEquals(MapNodePolicy.PRIORITY_ORDINARY, MapNodePolicy.priorityOf(ordinary, myNodeNum = 1))
assertTrue(MapNodePolicy.PRIORITY_PROMINENT > MapNodePolicy.PRIORITY_ORDINARY)
}
@Test
fun `a node whose role is excluded is hidden`() {
val nodes =
listOf(
node(1, 45.0, -122.0, role = Config.DeviceConfig.Role.ROUTER),
node(2, 45.1, -122.1, role = Config.DeviceConfig.Role.CLIENT),
)
assertEquals(listOf(2), visible(nodes, filters(excludedRoles = setOf(Config.DeviceConfig.Role.ROUTER))))
}
@Test
fun `excluding CLIENT also hides every node that never reported a role`() {
// CLIENT is 0, the proto default, so "never said" and "said CLIENT" are the same value on the wire. Worth
// pinning: it is the one surprising consequence of a per-role filter.
val nodes = listOf(node(1, 45.0, -122.0), node(2, 45.1, -122.1, role = Config.DeviceConfig.Role.ROUTER))
assertEquals(listOf(2), visible(nodes, filters(excludedRoles = setOf(Config.DeviceConfig.Role.CLIENT))))
}
@Test
fun `the online filter drops nodes unheard for over two hours`() {
// 97_000 is 3_000s back, inside the two-hour window; 90_000 is 10_000s back, outside it.
val nodes = listOf(node(1, 45.0, -122.0, lastHeard = 90_000), node(2, 45.1, -122.1, lastHeard = 97_000))
assertEquals(listOf(2), visible(nodes, filters(onlyOnline = true), now = 100_000))
}
@Test
fun `the direct filter drops both relayed nodes and nodes of unknown distance`() {
// The node list's query is `hops_away <= 0 AND hops_away >= 0`, so -1 — never measured — is not direct.
val nodes =
listOf(node(1, 45.0, -122.0, hopsAway = 2), node(2, 45.1, -122.1), node(3, 45.2, -122.2, hopsAway = -1))
assertEquals(listOf(2), visible(nodes, filters(onlyDirect = true)))
}
@Test
fun `the mqtt filter drops nodes heard over mqtt`() {
val nodes = listOf(node(1, 45.0, -122.0, viaMqtt = true), node(2, 45.1, -122.1))
assertEquals(listOf(2), visible(nodes, filters(excludeMqtt = true)))
}
@Test
fun `ignored nodes are hidden by default`() {
val nodes = listOf(node(1, 45.0, -122.0, isIgnored = true), node(2, 45.1, -122.1))
assertEquals(listOf(2), visible(nodes, filters()))
}
@Test
fun `showing ignored nodes adds them rather than showing only them`() {
// The node list segregates — its filter is `isIgnored == showIgnored` — but a map of nothing but ignored
// nodes is not a view anyone wants. Here the toggle reads literally: include them too.
val nodes = listOf(node(1, 45.0, -122.0, isIgnored = true), node(2, 45.1, -122.1))
assertEquals(listOf(1, 2), visible(nodes, filters(showIgnored = true)))
}
@Test
fun `excluding unknown nodes drops the ones with no name yet`() {
// "Unknown" is a node that has not sent a short name, matching the node list's `short_name IS NOT NULL`.
val nodes = listOf(node(1, 45.0, -122.0, shortName = ""), node(2, 45.1, -122.1))
assertEquals(listOf(2), visible(nodes, filters(includeUnknown = false)))
}
@Test
fun `my own node survives every new filter`() {
val mine =
node(
1,
45.0,
-122.0,
lastHeard = 0,
role = Config.DeviceConfig.Role.ROUTER,
hopsAway = 3,
viaMqtt = true,
isIgnored = true,
shortName = "",
)
val state =
filters(
excludedRoles = setOf(Config.DeviceConfig.Role.ROUTER),
onlyOnline = true,
onlyDirect = true,
excludeMqtt = true,
includeUnknown = false,
)
assertEquals(listOf(1), visible(listOf(mine), state, now = 100_000, mine = 1))
}
}
@@ -0,0 +1,93 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.map.component
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.assertIsNotSelected
import androidx.compose.ui.test.assertIsSelected
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.v2.runComposeUiTest
import org.meshtastic.feature.map.BaseMapViewModel
import org.meshtastic.feature.map.LastHeardFilter
import org.meshtastic.proto.Config
import kotlin.test.Test
import kotlin.test.assertEquals
@OptIn(ExperimentalTestApi::class)
class MapFilterSheetTest {
private val router = Config.DeviceConfig.Role.ROUTER
private fun state(excludedRoles: Set<Config.DeviceConfig.Role> = emptySet()) = BaseMapViewModel.MapFilterState(
onlyFavorites = false,
showWaypoints = true,
showPrecisionCircle = true,
lastHeardFilter = LastHeardFilter.Any,
lastHeardTrackFilter = LastHeardFilter.Any,
excludedRoles = excludedRoles,
)
private fun actions(onToggleRole: (Config.DeviceConfig.Role) -> Unit = {}) = MapFilterActions(
onToggleOnlyFavorites = {},
onToggleShowWaypoints = {},
onToggleShowPrecisionCircle = {},
onSelectLastHeard = {},
onToggleRoleExcluded = onToggleRole,
onClearExcludedRoles = {},
onToggleOnlyOnline = {},
onToggleOnlyDirect = {},
onToggleExcludeMqtt = {},
onToggleShowIgnored = {},
onToggleIncludeUnknown = {},
)
@Test
fun `a role chip reads as selected when that role is shown`() = runComposeUiTest {
// The state behind the row is the complement — a set of excluded roles — so the chip's selected flag is the
// one place that inversion can go wrong, and it would look like the filter working backwards.
setContent { MapFilterSheetContent(filterState = state(), actions = actions()) }
onNodeWithTag(roleFilterChipTestTag(router)).assertIsSelected()
}
@Test
fun `a role chip reads as unselected once that role is excluded`() = runComposeUiTest {
setContent { MapFilterSheetContent(filterState = state(excludedRoles = setOf(router)), actions = actions()) }
onNodeWithTag(roleFilterChipTestTag(router)).assertIsNotSelected()
}
@Test
fun `tapping a role chip reports that role`() = runComposeUiTest {
val toggled = mutableListOf<Config.DeviceConfig.Role>()
setContent { MapFilterSheetContent(filterState = state(), actions = actions { toggled += it }) }
onNodeWithTag(roleFilterChipTestTag(router)).performClick()
runOnIdle { assertEquals(listOf(router), toggled) }
}
@Test
fun `every role has a chip`() = runComposeUiTest {
// A role with no chip is a role the user can never hide, and one that silently stays on the map.
setContent { MapFilterSheetContent(filterState = state(), actions = actions()) }
@Suppress("DEPRECATION")
Config.DeviceConfig.Role.entries.forEach { role -> onNodeWithTag(roleFilterChipTestTag(role)).assertExists() }
}
}