feat #3642: Add infrastructure to the list of filters. (#3716)

This commit is contained in:
Dane Evans
2025-11-17 11:52:25 +11:00
committed by GitHub
parent 4015e67b2b
commit d6f39fdcb5
7 changed files with 192 additions and 68 deletions

View File

@@ -39,6 +39,7 @@ internal const val KEY_THEME = "theme"
// Node list filters/sort
internal const val KEY_NODE_SORT = "node-sort-option"
internal const val KEY_INCLUDE_UNKNOWN = "include-unknown"
internal const val KEY_EXCLUDE_INFRASTRUCTURE = "exclude-infrastructure"
internal const val KEY_ONLY_ONLINE = "only-online"
internal const val KEY_ONLY_DIRECT = "only-direct"
internal const val KEY_SHOW_IGNORED = "show-ignored"
@@ -57,6 +58,8 @@ class UiPreferencesDataSource @Inject constructor(private val dataStore: DataSto
val nodeSort: StateFlow<Int> = dataStore.prefStateFlow(key = NODE_SORT, default = -1)
val includeUnknown: StateFlow<Boolean> = dataStore.prefStateFlow(key = INCLUDE_UNKNOWN, default = false)
val excludeInfrastructure: StateFlow<Boolean> =
dataStore.prefStateFlow(key = EXCLUDE_INFRASTRUCTURE, default = false)
val onlyOnline: StateFlow<Boolean> = dataStore.prefStateFlow(key = ONLY_ONLINE, default = false)
val onlyDirect: StateFlow<Boolean> = dataStore.prefStateFlow(key = ONLY_DIRECT, default = false)
val showIgnored: StateFlow<Boolean> = dataStore.prefStateFlow(key = SHOW_IGNORED, default = false)
@@ -77,6 +80,10 @@ class UiPreferencesDataSource @Inject constructor(private val dataStore: DataSto
dataStore.setPref(key = INCLUDE_UNKNOWN, value = value)
}
fun setExcludeInfrastructure(value: Boolean) {
dataStore.setPref(key = EXCLUDE_INFRASTRUCTURE, value = value)
}
fun setOnlyOnline(value: Boolean) {
dataStore.setPref(key = ONLY_ONLINE, value = value)
}
@@ -104,6 +111,7 @@ class UiPreferencesDataSource @Inject constructor(private val dataStore: DataSto
val THEME = intPreferencesKey(KEY_THEME)
val NODE_SORT = intPreferencesKey(KEY_NODE_SORT)
val INCLUDE_UNKNOWN = booleanPreferencesKey(KEY_INCLUDE_UNKNOWN)
val EXCLUDE_INFRASTRUCTURE = booleanPreferencesKey(KEY_EXCLUDE_INFRASTRUCTURE)
val ONLY_ONLINE = booleanPreferencesKey(KEY_ONLY_ONLINE)
val ONLY_DIRECT = booleanPreferencesKey(KEY_ONLY_DIRECT)
val SHOW_IGNORED = booleanPreferencesKey(KEY_SHOW_IGNORED)

View File

@@ -32,6 +32,7 @@
<string name="desc_node_filter_clear">clear node filter</string>
<string name="node_filter_title">Filter by</string>
<string name="node_filter_include_unknown">Include unknown</string>
<string name="node_filter_exclude_infrastructure">Exclude infrastructure</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_ignored">You are viewing ignored nodes,\nPress to return to the node list.</string>
@@ -46,7 +47,7 @@
<string name="node_sort_via_mqtt">via MQTT</string>
<string name="via_mqtt">via MQTT</string>
<string name="node_sort_via_favorite">via Favorite</string>
<string name="node_filter_show_ignored">Ignored Nodes</string>
<string name="node_filter_show_ignored">Only show ignored Nodes</string>
<string name="unrecognized">Unrecognized</string>
<string name="message_status_enroute">Waiting to be acknowledged</string>
<string name="message_status_queued">Queued for sending</string>

View File

@@ -64,6 +64,7 @@ import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.database.model.NodeSortOption
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.desc_node_filter_clear
import org.meshtastic.core.strings.node_filter_exclude_infrastructure
import org.meshtastic.core.strings.node_filter_ignored
import org.meshtastic.core.strings.node_filter_include_unknown
import org.meshtastic.core.strings.node_filter_only_direct
@@ -85,6 +86,8 @@ fun NodeFilterTextField(
onSortSelect: (NodeSortOption) -> Unit,
includeUnknown: Boolean,
onToggleIncludeUnknown: () -> Unit,
excludeInfrastructure: Boolean,
onToggleExcludeInfrastructure: () -> Unit,
onlyOnline: Boolean,
onToggleOnlyOnline: () -> Unit,
onlyDirect: Boolean,
@@ -105,6 +108,8 @@ fun NodeFilterTextField(
NodeFilterToggles(
includeUnknown = includeUnknown,
onToggleIncludeUnknown = onToggleIncludeUnknown,
excludeInfrastructure = excludeInfrastructure,
onToggleExcludeInfrastructure = onToggleExcludeInfrastructure,
onlyOnline = onlyOnline,
onToggleOnlyOnline = onToggleOnlyOnline,
onlyDirect = onlyDirect,
@@ -212,6 +217,12 @@ private fun NodeSortButton(
DropdownMenuTitle(text = stringResource(Res.string.node_filter_title))
DropdownMenuCheck(
text = stringResource(Res.string.node_filter_exclude_infrastructure),
checked = toggles.excludeInfrastructure,
onClick = toggles.onToggleExcludeInfrastructure,
)
DropdownMenuCheck(
text = stringResource(Res.string.node_filter_include_unknown),
checked = toggles.includeUnknown,
@@ -298,6 +309,8 @@ private fun NodeFilterTextFieldPreview() {
onSortSelect = {},
includeUnknown = false,
onToggleIncludeUnknown = {},
excludeInfrastructure = false,
onToggleExcludeInfrastructure = {},
onlyOnline = false,
onToggleOnlyOnline = {},
onlyDirect = false,
@@ -312,6 +325,8 @@ private fun NodeFilterTextFieldPreview() {
data class NodeFilterToggles(
val includeUnknown: Boolean,
val onToggleIncludeUnknown: () -> Unit,
val excludeInfrastructure: Boolean,
val onToggleExcludeInfrastructure: () -> Unit,
val onlyOnline: Boolean,
val onToggleOnlyOnline: () -> Unit,
val onlyDirect: Boolean,

View File

@@ -0,0 +1,62 @@
/*
* Copyright (c) 2025 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.node.list
import android.os.RemoteException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.service.ServiceAction
import org.meshtastic.core.service.ServiceRepository
import timber.log.Timber
import javax.inject.Inject
class NodeActions
@Inject
constructor(
private val serviceRepository: ServiceRepository,
private val nodeRepository: NodeRepository,
) {
suspend fun favoriteNode(node: Node) {
try {
serviceRepository.onServiceAction(ServiceAction.Favorite(node))
} catch (ex: RemoteException) {
Timber.e(ex, "Favorite node error")
}
}
suspend fun ignoreNode(node: Node) {
try {
serviceRepository.onServiceAction(ServiceAction.Ignore(node))
} catch (ex: RemoteException) {
Timber.e(ex, "Ignore node error")
}
}
suspend fun removeNode(nodeNum: Int) = withContext(Dispatchers.IO) {
Timber.i("Removing node '$nodeNum'")
try {
val packetId = serviceRepository.meshService?.packetId ?: return@withContext
serviceRepository.meshService?.removeByNodenum(packetId, nodeNum)
nodeRepository.deleteNode(nodeNum)
} catch (ex: RemoteException) {
Timber.e("Remove node error: ${ex.message}")
}
}
}

View File

@@ -0,0 +1,49 @@
/*
* Copyright (c) 2025 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.node.list
import org.meshtastic.core.datastore.UiPreferencesDataSource
import javax.inject.Inject
class NodeFilterPreferences @Inject constructor(private val uiPreferencesDataSource: UiPreferencesDataSource) {
val includeUnknown = uiPreferencesDataSource.includeUnknown
val excludeInfrastructure = uiPreferencesDataSource.excludeInfrastructure
val onlyOnline = uiPreferencesDataSource.onlyOnline
val onlyDirect = uiPreferencesDataSource.onlyDirect
val showIgnored = uiPreferencesDataSource.showIgnored
fun toggleIncludeUnknown() {
uiPreferencesDataSource.setIncludeUnknown(!includeUnknown.value)
}
fun toggleExcludeInfrastructure() {
uiPreferencesDataSource.setExcludeInfrastructure(!excludeInfrastructure.value)
}
fun toggleOnlyOnline() {
uiPreferencesDataSource.setOnlyOnline(!onlyOnline.value)
}
fun toggleOnlyDirect() {
uiPreferencesDataSource.setOnlyDirect(!onlyDirect.value)
}
fun toggleShowIgnored() {
uiPreferencesDataSource.setShowIgnored(!showIgnored.value)
}
}

View File

@@ -159,17 +159,21 @@ fun NodeListScreen(
.background(MaterialTheme.colorScheme.surfaceDim)
.padding(8.dp),
filterText = state.filter.filterText,
onTextChange = viewModel::setNodeFilterText,
onTextChange = { viewModel.nodeFilterText = it },
currentSortOption = state.sort,
onSortSelect = viewModel::setSortOption,
includeUnknown = state.filter.includeUnknown,
onToggleIncludeUnknown = viewModel::toggleIncludeUnknown,
onToggleIncludeUnknown = { viewModel.nodeFilterPreferences.toggleIncludeUnknown() },
excludeInfrastructure = state.filter.excludeInfrastructure,
onToggleExcludeInfrastructure = {
viewModel.nodeFilterPreferences.toggleExcludeInfrastructure()
},
onlyOnline = state.filter.onlyOnline,
onToggleOnlyOnline = viewModel::toggleOnlyOnline,
onToggleOnlyOnline = { viewModel.nodeFilterPreferences.toggleOnlyOnline() },
onlyDirect = state.filter.onlyDirect,
onToggleOnlyDirect = viewModel::toggleOnlyDirect,
onToggleOnlyDirect = { viewModel.nodeFilterPreferences.toggleOnlyDirect() },
showIgnored = state.filter.showIgnored,
onToggleShowIgnored = viewModel::toggleShowIgnored,
onToggleShowIgnored = { viewModel.nodeFilterPreferences.toggleShowIgnored() },
ignoredNodeCount = ignoredNodeCount,
)
}

View File

@@ -17,11 +17,9 @@
package org.meshtastic.feature.node.list
import android.os.RemoteException
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -35,11 +33,11 @@ import org.meshtastic.core.data.repository.RadioConfigRepository
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.database.model.NodeSortOption
import org.meshtastic.core.datastore.UiPreferencesDataSource
import org.meshtastic.core.service.ServiceAction
import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
import org.meshtastic.feature.node.model.isEffectivelyUnmessageable
import org.meshtastic.proto.AdminProtos
import timber.log.Timber
import org.meshtastic.proto.ConfigProtos
import javax.inject.Inject
@HiltViewModel
@@ -50,6 +48,8 @@ constructor(
radioConfigRepository: RadioConfigRepository,
private val serviceRepository: ServiceRepository,
private val uiPreferencesDataSource: UiPreferencesDataSource,
val nodeActions: NodeActions,
val nodeFilterPreferences: NodeFilterPreferences,
) : ViewModel() {
val ourNodeInfo: StateFlow<Node?> = nodeRepository.ourNodeInfo
@@ -66,23 +66,24 @@ constructor(
private val nodeSortOption =
uiPreferencesDataSource.nodeSort.map { NodeSortOption.entries.getOrElse(it) { NodeSortOption.VIA_FAVORITE } }
private val nodeFilterText = MutableStateFlow("")
private val includeUnknown = uiPreferencesDataSource.includeUnknown
private val onlyOnline = uiPreferencesDataSource.onlyOnline
private val onlyDirect = uiPreferencesDataSource.onlyDirect
private val showIgnored = uiPreferencesDataSource.showIgnored
private val _nodeFilterText = MutableStateFlow("")
private val includeUnknown = nodeFilterPreferences.includeUnknown
private val excludeInfrastructure = nodeFilterPreferences.excludeInfrastructure
private val onlyOnline = nodeFilterPreferences.onlyOnline
private val onlyDirect = nodeFilterPreferences.onlyDirect
private val showIgnored = nodeFilterPreferences.showIgnored
private val nodeFilter: Flow<NodeFilterState> =
combine(nodeFilterText, includeUnknown, onlyOnline, onlyDirect, showIgnored) {
filterText,
includeUnknown,
onlyOnline,
onlyDirect,
showIgnored,
->
NodeFilterState(filterText, includeUnknown, onlyOnline, onlyDirect, showIgnored)
combine(_nodeFilterText, includeUnknown, excludeInfrastructure, onlyOnline, onlyDirect, showIgnored) { values ->
NodeFilterState(
filterText = values[0] as String,
includeUnknown = values[1] as Boolean,
excludeInfrastructure = values[2] as Boolean,
onlyOnline = values[3] as Boolean,
onlyDirect = values[4] as Boolean,
showIgnored = values[5] as Boolean,
)
}
val nodesUiState: StateFlow<NodesUiState> =
combine(nodeSortOption, nodeFilter, radioConfigRepository.deviceProfileFlow) { sort, nodeFilter, profile ->
NodesUiState(
@@ -105,32 +106,36 @@ constructor(
onlyOnline = filter.onlyOnline,
onlyDirect = filter.onlyDirect,
)
.map { list -> list.filter { it.isIgnored == filter.showIgnored } }
.map { list ->
list
.filter { it.isIgnored == filter.showIgnored }
.filter { node ->
if (filter.excludeInfrastructure) {
val role = node.user.role
val infrastructureRoles =
listOf(
ConfigProtos.Config.DeviceConfig.Role.ROUTER,
ConfigProtos.Config.DeviceConfig.Role.REPEATER,
ConfigProtos.Config.DeviceConfig.Role.ROUTER_LATE,
ConfigProtos.Config.DeviceConfig.Role.CLIENT_BASE,
)
role !in infrastructureRoles && !node.isEffectivelyUnmessageable
} else {
true
}
}
}
}
.stateInWhileSubscribed(initialValue = emptyList())
val unfilteredNodeList: StateFlow<List<Node>> =
nodeRepository.getNodes().stateInWhileSubscribed(initialValue = emptyList())
fun setNodeFilterText(text: String) {
nodeFilterText.value = text
}
fun toggleIncludeUnknown() {
uiPreferencesDataSource.setIncludeUnknown(!includeUnknown.value)
}
fun toggleOnlyOnline() {
uiPreferencesDataSource.setOnlyOnline(!onlyOnline.value)
}
fun toggleOnlyDirect() {
uiPreferencesDataSource.setOnlyDirect(!onlyDirect.value)
}
fun toggleShowIgnored() {
uiPreferencesDataSource.setShowIgnored(!showIgnored.value)
}
var nodeFilterText: String
get() = _nodeFilterText.value
set(value) {
_nodeFilterText.value = value
}
fun setSortOption(sort: NodeSortOption) {
uiPreferencesDataSource.setNodeSort(sort.ordinal)
@@ -140,32 +145,11 @@ constructor(
_sharedContactRequested.value = sharedContact
}
fun favoriteNode(node: Node) = viewModelScope.launch {
try {
serviceRepository.onServiceAction(ServiceAction.Favorite(node))
} catch (ex: RemoteException) {
Timber.e(ex, "Favorite node error")
}
}
fun favoriteNode(node: Node) = viewModelScope.launch { nodeActions.favoriteNode(node) }
fun ignoreNode(node: Node) = viewModelScope.launch {
try {
serviceRepository.onServiceAction(ServiceAction.Ignore(node))
} catch (ex: RemoteException) {
Timber.e(ex, "Ignore node error")
}
}
fun ignoreNode(node: Node) = viewModelScope.launch { nodeActions.ignoreNode(node) }
fun removeNode(nodeNum: Int) = viewModelScope.launch(Dispatchers.IO) {
Timber.i("Removing node '$nodeNum'")
try {
val packetId = serviceRepository.meshService?.packetId ?: return@launch
serviceRepository.meshService?.removeByNodenum(packetId, nodeNum)
nodeRepository.deleteNode(nodeNum)
} catch (ex: RemoteException) {
Timber.e("Remove node error: ${ex.message}")
}
}
fun removeNode(nodeNum: Int) = viewModelScope.launch { nodeActions.removeNode(nodeNum) }
}
data class NodesUiState(
@@ -178,6 +162,7 @@ data class NodesUiState(
data class NodeFilterState(
val filterText: String = "",
val includeUnknown: Boolean = false,
val excludeInfrastructure: Boolean = false,
val onlyOnline: Boolean = false,
val onlyDirect: Boolean = false,
val showIgnored: Boolean = false,