diff --git a/core/datastore/src/main/kotlin/org/meshtastic/core/datastore/UiPreferencesDataSource.kt b/core/datastore/src/main/kotlin/org/meshtastic/core/datastore/UiPreferencesDataSource.kt index 6ba636743..69a49a521 100644 --- a/core/datastore/src/main/kotlin/org/meshtastic/core/datastore/UiPreferencesDataSource.kt +++ b/core/datastore/src/main/kotlin/org/meshtastic/core/datastore/UiPreferencesDataSource.kt @@ -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 = dataStore.prefStateFlow(key = NODE_SORT, default = -1) val includeUnknown: StateFlow = dataStore.prefStateFlow(key = INCLUDE_UNKNOWN, default = false) + val excludeInfrastructure: StateFlow = + dataStore.prefStateFlow(key = EXCLUDE_INFRASTRUCTURE, default = false) val onlyOnline: StateFlow = dataStore.prefStateFlow(key = ONLY_ONLINE, default = false) val onlyDirect: StateFlow = dataStore.prefStateFlow(key = ONLY_DIRECT, default = false) val showIgnored: StateFlow = 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) diff --git a/core/strings/src/commonMain/composeResources/values/strings.xml b/core/strings/src/commonMain/composeResources/values/strings.xml index 0c6e84a2b..1eec5b1ab 100644 --- a/core/strings/src/commonMain/composeResources/values/strings.xml +++ b/core/strings/src/commonMain/composeResources/values/strings.xml @@ -32,6 +32,7 @@ clear node filter Filter by Include unknown + Exclude infrastructure Hide offline nodes Only show direct nodes You are viewing ignored nodes,\nPress to return to the node list. @@ -46,7 +47,7 @@ via MQTT via MQTT via Favorite - Ignored Nodes + Only show ignored Nodes Unrecognized Waiting to be acknowledged Queued for sending diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeFilterTextField.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeFilterTextField.kt index bafd089ce..ec9e8fcef 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeFilterTextField.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeFilterTextField.kt @@ -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, diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/list/NodeActions.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/list/NodeActions.kt new file mode 100644 index 000000000..85b7dfbd2 --- /dev/null +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/list/NodeActions.kt @@ -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 . + */ + +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}") + } + } +} diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/list/NodeFilterPreferences.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/list/NodeFilterPreferences.kt new file mode 100644 index 000000000..51620b78e --- /dev/null +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/list/NodeFilterPreferences.kt @@ -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 . + */ + +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) + } +} diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt index 99c0335fa..cc37bbe68 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt @@ -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, ) } diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/list/NodeListViewModel.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/list/NodeListViewModel.kt index 6ff0f2ba6..b0f85cb86 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/list/NodeListViewModel.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/list/NodeListViewModel.kt @@ -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 = 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 = - 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 = 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> = 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,