From 3646438a62b7b9d428068b8e8559c197b5c18ad9 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Fri, 1 Aug 2025 10:45:33 -0500 Subject: [PATCH] feat(radioconfig): add clean node database screen (#2592) Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com> --- .../mesh/database/NodeRepository.kt | 120 ++++++----- .../mesh/database/dao/NodeInfoDao.kt | 46 ++-- .../mesh/database/entity/NodeEntity.kt | 19 ++ .../mesh/navigation/RadioConfigRoutes.kt | 199 ++++++----------- .../ui/radioconfig/CleanNodeDatabaseScreen.kt | 202 ++++++++++++++++++ .../radioconfig/CleanNodeDatabaseViewModel.kt | 124 +++++++++++ .../mesh/ui/radioconfig/RadioConfig.kt | 189 +++++++--------- app/src/main/res/values/strings.xml | 10 + 8 files changed, 588 insertions(+), 321 deletions(-) create mode 100644 app/src/main/java/com/geeksville/mesh/ui/radioconfig/CleanNodeDatabaseScreen.kt create mode 100644 app/src/main/java/com/geeksville/mesh/ui/radioconfig/CleanNodeDatabaseViewModel.kt diff --git a/app/src/main/java/com/geeksville/mesh/database/NodeRepository.kt b/app/src/main/java/com/geeksville/mesh/database/NodeRepository.kt index 436c0225b..220f6648b 100644 --- a/app/src/main/java/com/geeksville/mesh/database/NodeRepository.kt +++ b/app/src/main/java/com/geeksville/mesh/database/NodeRepository.kt @@ -44,55 +44,59 @@ import javax.inject.Inject import javax.inject.Singleton @Singleton -class NodeRepository @Inject constructor( +@Suppress("TooManyFunctions") +class NodeRepository +@Inject +constructor( processLifecycle: Lifecycle, private val nodeInfoDao: NodeInfoDao, private val dispatchers: CoroutineDispatchers, ) { // hardware info about our local device (can be null) - val myNodeInfo: StateFlow = nodeInfoDao.getMyNodeInfo() - .flowOn(dispatchers.io) - .stateIn(processLifecycle.coroutineScope, SharingStarted.Eagerly, null) + val myNodeInfo: StateFlow = + nodeInfoDao + .getMyNodeInfo() + .flowOn(dispatchers.io) + .stateIn(processLifecycle.coroutineScope, SharingStarted.Eagerly, null) // our node info private val _ourNodeInfo = MutableStateFlow(null) - val ourNodeInfo: StateFlow get() = _ourNodeInfo + val ourNodeInfo: StateFlow + get() = _ourNodeInfo // The unique userId of our node private val _myId = MutableStateFlow(null) - val myId: StateFlow get() = _myId + val myId: StateFlow + get() = _myId - fun getNodeDBbyNum() = nodeInfoDao.nodeDBbyNum() - .map { map -> map.mapValues { (_, it) -> it.toEntity() } } + fun getNodeDBbyNum() = nodeInfoDao.nodeDBbyNum().map { map -> map.mapValues { (_, it) -> it.toEntity() } } // A map from nodeNum to Node - val nodeDBbyNum: StateFlow> = nodeInfoDao.nodeDBbyNum() - .mapLatest { map -> map.mapValues { (_, it) -> it.toModel() } } - .onEach { - val ourNodeInfo = it.values.firstOrNull() - _ourNodeInfo.value = ourNodeInfo - _myId.value = ourNodeInfo?.user?.id - } - .flowOn(dispatchers.io) - .conflate() - .stateIn(processLifecycle.coroutineScope, SharingStarted.Eagerly, emptyMap()) + val nodeDBbyNum: StateFlow> = + nodeInfoDao + .nodeDBbyNum() + .mapLatest { map -> map.mapValues { (_, it) -> it.toModel() } } + .onEach { + val ourNodeInfo = it.values.firstOrNull() + _ourNodeInfo.value = ourNodeInfo + _myId.value = ourNodeInfo?.user?.id + } + .flowOn(dispatchers.io) + .conflate() + .stateIn(processLifecycle.coroutineScope, SharingStarted.Eagerly, emptyMap()) fun getNode(userId: String): Node = nodeDBbyNum.value.values.find { it.user.id == userId } - ?: Node( - num = DataPacket.idToDefaultNodeNum(userId) ?: 0, - user = getUser(userId), - ) + ?: Node(num = DataPacket.idToDefaultNodeNum(userId) ?: 0, user = getUser(userId)) fun getUser(nodeNum: Int): MeshProtos.User = getUser(DataPacket.nodeNumToDefaultId(nodeNum)) - fun getUser(userId: String): MeshProtos.User = - nodeDBbyNum.value.values.find { it.user.id == userId }?.user - ?: MeshProtos.User.newBuilder() - .setId(userId) - .setLongName("Meshtastic ${userId.takeLast(n = 4)}") - .setShortName(userId.takeLast(n = 4)) - .setHwModel(MeshProtos.HardwareModel.UNSET) - .build() + fun getUser(userId: String): MeshProtos.User = nodeDBbyNum.value.values.find { it.user.id == userId }?.user + ?: MeshProtos.User.newBuilder() + .setId(userId) + .setLongName("Meshtastic ${userId.takeLast(n = 4)}") + .setShortName(userId.takeLast(n = 4)) + .setHwModel(MeshProtos.HardwareModel.UNSET) + .build() fun getNodes( sort: NodeSortOption = NodeSortOption.LAST_HEARD, @@ -100,21 +104,19 @@ class NodeRepository @Inject constructor( 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() - } - }.flowOn(dispatchers.io).conflate() + ) = 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() } } + .flowOn(dispatchers.io) + .conflate() - suspend fun upsert(node: NodeEntity) = withContext(dispatchers.io) { - nodeInfoDao.upsert(node) - } + suspend fun upsert(node: NodeEntity) = withContext(dispatchers.io) { nodeInfoDao.upsert(node) } suspend fun installNodeDB(mi: MyNodeEntity, nodes: List) = withContext(dispatchers.io) { nodeInfoDao.clearMyNodeInfo() @@ -122,24 +124,32 @@ class NodeRepository @Inject constructor( nodeInfoDao.putAll(nodes) } - suspend fun clearNodeDB() = withContext(dispatchers.io) { - nodeInfoDao.clearNodeInfo() - } + suspend fun clearNodeDB() = withContext(dispatchers.io) { nodeInfoDao.clearNodeInfo() } suspend fun deleteNode(num: Int) = withContext(dispatchers.io) { nodeInfoDao.deleteNode(num) nodeInfoDao.deleteMetadata(num) } - suspend fun insertMetadata(metadata: MetadataEntity) = withContext(dispatchers.io) { - nodeInfoDao.upsert(metadata) + suspend fun deleteNodes(nodeNums: List) = withContext(dispatchers.io) { + nodeInfoDao.deleteNodes(nodeNums) + nodeNums.forEach { nodeInfoDao.deleteMetadata(it) } } - val onlineNodeCount: Flow = nodeInfoDao.nodeDBbyNum().mapLatest { map -> - map.values.count { it.node.lastHeard > onlineTimeThreshold() } - }.flowOn(dispatchers.io).conflate() + suspend fun getNodesOlderThan(lastHeard: Int): List = + withContext(dispatchers.io) { nodeInfoDao.getNodesOlderThan(lastHeard) } - val totalNodeCount: Flow = nodeInfoDao.nodeDBbyNum().mapLatest { map -> - map.values.count() - }.flowOn(dispatchers.io).conflate() + suspend fun getUnknownNodes(): List = withContext(dispatchers.io) { nodeInfoDao.getUnknownNodes() } + + suspend fun insertMetadata(metadata: MetadataEntity) = withContext(dispatchers.io) { nodeInfoDao.upsert(metadata) } + + val onlineNodeCount: Flow = + nodeInfoDao + .nodeDBbyNum() + .mapLatest { map -> map.values.count { it.node.lastHeard > onlineTimeThreshold() } } + .flowOn(dispatchers.io) + .conflate() + + val totalNodeCount: Flow = + nodeInfoDao.nodeDBbyNum().mapLatest { map -> map.values.count() }.flowOn(dispatchers.io).conflate() } diff --git a/app/src/main/java/com/geeksville/mesh/database/dao/NodeInfoDao.kt b/app/src/main/java/com/geeksville/mesh/database/dao/NodeInfoDao.kt index e79c5e29e..12826cb7d 100644 --- a/app/src/main/java/com/geeksville/mesh/database/dao/NodeInfoDao.kt +++ b/app/src/main/java/com/geeksville/mesh/database/dao/NodeInfoDao.kt @@ -50,17 +50,18 @@ interface NodeInfoDao { if (nodeWithSamePK != null && nodeWithSamePK.num != node.num) { // This is the impersonation attempt we want to block. @Suppress("MaxLineLength") - warn("NodeInfoDao: Blocking new node #${node.num} because its public key is already used by #${nodeWithSamePK.num}.") + warn( + "NodeInfoDao: Blocking new node #${node.num} because its public key is already used by #${nodeWithSamePK.num}.", + ) return null // ABORT } } // If we're here, the new node is safe to add. - node + node } else { // This is an update to an existing node. - val keyMatch = - existingNode.user.publicKey == node.user.publicKey || existingNode.user.publicKey.isEmpty - if (keyMatch) { + val keyMatch = existingNode.user.publicKey == node.user.publicKey || existingNode.user.publicKey.isEmpty + if (keyMatch) { // Keys match, trust the incoming node completely. // This allows for legit nodeId changes etc. node @@ -69,13 +70,15 @@ interface NodeInfoDao { // Log it, and create a NEW entity based on the EXISTING trusted one, // only updating dynamic data and setting the public key to EMPTY to signal a conflict. @Suppress("MaxLineLength") - warn("NodeInfoDao: Received packet for #${node.num} with non-matching public key. Identity data ignored, key set to EMPTY.") + warn( + "NodeInfoDao: Received packet for #${node.num} with non-matching public key. Identity data ignored, key set to EMPTY.", + ) existingNode.copy( lastHeard = node.lastHeard, snr = node.snr, position = node.position, user = existingNode.user.toBuilder().setPublicKey(ByteString.EMPTY).build(), - publicKey = ByteString.EMPTY + publicKey = ByteString.EMPTY, ) } } @@ -98,10 +101,16 @@ interface NodeInfoDao { ELSE 1 END, last_heard DESC - """ + """, ) @Transaction - fun nodeDBbyNum(): Flow> + fun nodeDBbyNum(): Flow< + Map< + @MapColumn(columnName = "num") + Int, + NodeWithRelations, + >, + > @Query( """ @@ -125,7 +134,7 @@ interface NodeInfoDao { END, CASE WHEN :sort = 'last_heard' THEN last_heard * -1 - WHEN :sort = 'alpha' THEN UPPER(long_name) + WHEN :sort = 'alpha' THEN UPPER(long_name) WHEN :sort = 'distance' THEN CASE WHEN latitude IS NULL OR longitude IS NULL OR @@ -147,7 +156,7 @@ interface NodeInfoDao { ELSE 0 END ASC, last_heard DESC - """ + """, ) @Transaction fun getNodes( @@ -178,8 +187,16 @@ interface NodeInfoDao { @Query("DELETE FROM nodes WHERE num=:num") fun deleteNode(num: Int) - @Upsert - fun upsert(meta: MetadataEntity) + @Query("DELETE FROM nodes WHERE num IN (:nodeNums)") + fun deleteNodes(nodeNums: List) + + @Query("SELECT * FROM nodes WHERE last_heard < :lastHeard") + fun getNodesOlderThan(lastHeard: Int): List + + @Query("SELECT * FROM nodes WHERE short_name IS NULL") + fun getUnknownNodes(): List + + @Upsert fun upsert(meta: MetadataEntity) @Query("DELETE FROM metadata WHERE num=:num") fun deleteMetadata(num: Int) @@ -191,8 +208,7 @@ interface NodeInfoDao { @Query("SELECT * FROM nodes WHERE public_key = :publicKey LIMIT 1") fun findNodeByPublicKey(publicKey: ByteString?): NodeEntity? - @Upsert - fun doUpsert(node: NodeEntity) + @Upsert fun doUpsert(node: NodeEntity) @Insert(onConflict = OnConflictStrategy.REPLACE) fun doPutAll(nodes: List) diff --git a/app/src/main/java/com/geeksville/mesh/database/entity/NodeEntity.kt b/app/src/main/java/com/geeksville/mesh/database/entity/NodeEntity.kt index 565cd075e..677d60eff 100644 --- a/app/src/main/java/com/geeksville/mesh/database/entity/NodeEntity.kt +++ b/app/src/main/java/com/geeksville/mesh/database/entity/NodeEntity.kt @@ -155,6 +155,25 @@ data class NodeEntity( fun currentTime() = (System.currentTimeMillis() / 1000).toInt() } + fun toModel() = Node( + num = num, + user = user, + position = position, + snr = snr, + rssi = rssi, + lastHeard = lastHeard, + deviceMetrics = deviceTelemetry.deviceMetrics, + channel = channel, + viaMqtt = viaMqtt, + hopsAway = hopsAway, + isFavorite = isFavorite, + isIgnored = isIgnored, + environmentMetrics = environmentTelemetry.environmentMetrics, + powerMetrics = powerTelemetry.powerMetrics, + paxcounter = paxcounter, + publicKey = publicKey ?: user.publicKey, + ) + fun toNodeInfo() = NodeInfo( num = num, user = diff --git a/app/src/main/java/com/geeksville/mesh/navigation/RadioConfigRoutes.kt b/app/src/main/java/com/geeksville/mesh/navigation/RadioConfigRoutes.kt index eff2bff4b..5676376ac 100644 --- a/app/src/main/java/com/geeksville/mesh/navigation/RadioConfigRoutes.kt +++ b/app/src/main/java/com/geeksville/mesh/navigation/RadioConfigRoutes.kt @@ -52,6 +52,7 @@ import androidx.navigation.navigation import com.geeksville.mesh.MeshProtos.DeviceMetadata import com.geeksville.mesh.R import com.geeksville.mesh.model.UIViewModel +import com.geeksville.mesh.ui.radioconfig.CleanNodeDatabaseScreen import com.geeksville.mesh.ui.radioconfig.RadioConfigScreen import com.geeksville.mesh.ui.radioconfig.components.AmbientLightingConfigScreen import com.geeksville.mesh.ui.radioconfig.components.AudioConfigScreen @@ -79,120 +80,88 @@ import com.geeksville.mesh.ui.radioconfig.components.UserConfigScreen import kotlinx.serialization.Serializable sealed class RadioConfigRoutes { - @Serializable - data class RadioConfigGraph(val destNum: Int? = null) : Graph + @Serializable data class RadioConfigGraph(val destNum: Int? = null) : Graph - @Serializable - data class RadioConfig(val destNum: Int? = null) : Route + @Serializable data class RadioConfig(val destNum: Int? = null) : Route - @Serializable - data object User : Route - @Serializable - data object ChannelConfig : Route + @Serializable data object User : Route - @Serializable - data object Device : Route + @Serializable data object ChannelConfig : Route - @Serializable - data object Position : Route + @Serializable data object Device : Route - @Serializable - data object Power : Route + @Serializable data object Position : Route - @Serializable - data object Network : Route + @Serializable data object Power : Route - @Serializable - data object Display : Route + @Serializable data object Network : Route - @Serializable - data object LoRa : Route + @Serializable data object Display : Route - @Serializable - data object Bluetooth : Route + @Serializable data object LoRa : Route - @Serializable - data object Security : Route + @Serializable data object Bluetooth : Route - @Serializable - data object MQTT : Route + @Serializable data object Security : Route - @Serializable - data object Serial : Route + @Serializable data object MQTT : Route - @Serializable - data object ExtNotification : Route + @Serializable data object Serial : Route - @Serializable - data object StoreForward : Route + @Serializable data object ExtNotification : Route - @Serializable - data object RangeTest : Route + @Serializable data object StoreForward : Route - @Serializable - data object Telemetry : Route + @Serializable data object RangeTest : Route - @Serializable - data object CannedMessage : Route + @Serializable data object Telemetry : Route - @Serializable - data object Audio : Route + @Serializable data object CannedMessage : Route - @Serializable - data object RemoteHardware : Route + @Serializable data object Audio : Route - @Serializable - data object NeighborInfo : Route + @Serializable data object RemoteHardware : Route - @Serializable - data object AmbientLighting : Route + @Serializable data object NeighborInfo : Route - @Serializable - data object DetectionSensor : Route + @Serializable data object AmbientLighting : Route - @Serializable - data object Paxcounter : Route + @Serializable data object DetectionSensor : Route + + @Serializable data object Paxcounter : Route + + @Serializable data object CleanNodeDb : Route } -fun getNavRouteFrom(routeName: String): Route? { - return ConfigRoute.entries.find { it.name == routeName }?.route - ?: ModuleRoute.entries.find { it.name == routeName }?.route -} +fun getNavRouteFrom(routeName: String): Route? = + ConfigRoute.entries.find { it.name == routeName }?.route ?: ModuleRoute.entries.find { it.name == routeName }?.route fun NavGraphBuilder.radioConfigGraph(navController: NavHostController, uiViewModel: UIViewModel) { - navigation( - startDestination = RadioConfigRoutes.RadioConfig(), - ) { + navigation(startDestination = RadioConfigRoutes.RadioConfig()) { composable { backStackEntry -> - val parentEntry = remember(backStackEntry) { - val parentRoute = backStackEntry.destination.parent!!.route!! - navController.getBackStackEntry(parentRoute) - } - RadioConfigScreen( - uiViewModel = uiViewModel, - viewModel = hiltViewModel(parentEntry) - ) { - navController.navigate(it) { - popUpTo(RadioConfigRoutes.RadioConfig()) { - inclusive = false - } + val parentEntry = + remember(backStackEntry) { + val parentRoute = backStackEntry.destination.parent!!.route!! + navController.getBackStackEntry(parentRoute) } + RadioConfigScreen(uiViewModel = uiViewModel, viewModel = hiltViewModel(parentEntry)) { + navController.navigate(it) { popUpTo(RadioConfigRoutes.RadioConfig()) { inclusive = false } } } } + composable { CleanNodeDatabaseScreen() } configRoutes(navController) moduleRoutes(navController) } } -private fun NavGraphBuilder.configRoutes( - navController: NavHostController, -) { +private fun NavGraphBuilder.configRoutes(navController: NavHostController) { ConfigRoute.entries.forEach { configRoute -> composable(configRoute.route::class) { backStackEntry -> - val parentEntry = remember(backStackEntry) { - val parentRoute = backStackEntry.destination.parent!!.route!! - navController.getBackStackEntry(parentRoute) - } + val parentEntry = + remember(backStackEntry) { + val parentRoute = backStackEntry.destination.parent!!.route!! + navController.getBackStackEntry(parentRoute) + } when (configRoute) { ConfigRoute.USER -> UserConfigScreen(hiltViewModel(parentEntry)) ConfigRoute.CHANNELS -> ChannelConfigScreen(hiltViewModel(parentEntry)) @@ -210,42 +179,31 @@ private fun NavGraphBuilder.configRoutes( } @Suppress("CyclomaticComplexMethod") -private fun NavGraphBuilder.moduleRoutes( - navController: NavHostController, -) { +private fun NavGraphBuilder.moduleRoutes(navController: NavHostController) { ModuleRoute.entries.forEach { moduleRoute -> composable(moduleRoute.route::class) { backStackEntry -> - val parentEntry = remember(backStackEntry) { - val parentRoute = backStackEntry.destination.parent!!.route!! - navController.getBackStackEntry(parentRoute) - } + val parentEntry = + remember(backStackEntry) { + val parentRoute = backStackEntry.destination.parent!!.route!! + navController.getBackStackEntry(parentRoute) + } when (moduleRoute) { ModuleRoute.MQTT -> MQTTConfigScreen(hiltViewModel(parentEntry)) ModuleRoute.SERIAL -> SerialConfigScreen(hiltViewModel(parentEntry)) - ModuleRoute.EXT_NOTIFICATION -> ExternalNotificationConfigScreen( - hiltViewModel(parentEntry) - ) + ModuleRoute.EXT_NOTIFICATION -> ExternalNotificationConfigScreen(hiltViewModel(parentEntry)) ModuleRoute.STORE_FORWARD -> StoreForwardConfigScreen(hiltViewModel(parentEntry)) ModuleRoute.RANGE_TEST -> RangeTestConfigScreen(hiltViewModel(parentEntry)) ModuleRoute.TELEMETRY -> TelemetryConfigScreen(hiltViewModel(parentEntry)) - ModuleRoute.CANNED_MESSAGE -> CannedMessageConfigScreen( - hiltViewModel(parentEntry) - ) + ModuleRoute.CANNED_MESSAGE -> CannedMessageConfigScreen(hiltViewModel(parentEntry)) ModuleRoute.AUDIO -> AudioConfigScreen(hiltViewModel(parentEntry)) - ModuleRoute.REMOTE_HARDWARE -> RemoteHardwareConfigScreen( - hiltViewModel(parentEntry) - ) + ModuleRoute.REMOTE_HARDWARE -> RemoteHardwareConfigScreen(hiltViewModel(parentEntry)) ModuleRoute.NEIGHBOR_INFO -> NeighborInfoConfigScreen(hiltViewModel(parentEntry)) - ModuleRoute.AMBIENT_LIGHTING -> AmbientLightingConfigScreen( - hiltViewModel(parentEntry) - ) + ModuleRoute.AMBIENT_LIGHTING -> AmbientLightingConfigScreen(hiltViewModel(parentEntry)) - ModuleRoute.DETECTION_SENSOR -> DetectionSensorConfigScreen( - hiltViewModel(parentEntry) - ) + ModuleRoute.DETECTION_SENSOR -> DetectionSensorConfigScreen(hiltViewModel(parentEntry)) ModuleRoute.PAXCOUNTER -> PaxcounterConfigScreen(hiltViewModel(parentEntry)) } @@ -255,12 +213,7 @@ private fun NavGraphBuilder.moduleRoutes( // Config (type = AdminProtos.AdminMessage.ConfigType) @Suppress("MagicNumber") -enum class ConfigRoute( - @StringRes val title: Int, - val route: Route, - val icon: ImageVector?, - val type: Int = 0 -) { +enum class ConfigRoute(@StringRes val title: Int, val route: Route, val icon: ImageVector?, val type: Int = 0) { USER(R.string.user, RadioConfigRoutes.User, Icons.Default.Person, 0), CHANNELS(R.string.channels, RadioConfigRoutes.ChannelConfig, Icons.AutoMirrored.Default.List, 0), DEVICE(R.string.device, RadioConfigRoutes.Device, Icons.Default.Router, 0), @@ -287,48 +240,24 @@ enum class ConfigRoute( // ModuleConfig (type = AdminProtos.AdminMessage.ModuleConfigType) @Suppress("MagicNumber") -enum class ModuleRoute( - @StringRes val title: Int, - val route: Route, - val icon: ImageVector?, - val type: Int = 0 -) { +enum class ModuleRoute(@StringRes val title: Int, val route: Route, val icon: ImageVector?, val type: Int = 0) { MQTT(R.string.mqtt, RadioConfigRoutes.MQTT, Icons.Default.Cloud, 0), SERIAL(R.string.serial, RadioConfigRoutes.Serial, Icons.Default.Usb, 1), - EXT_NOTIFICATION( - R.string.external_notification, - RadioConfigRoutes.ExtNotification, - Icons.Default.Notifications, - 2 - ), - STORE_FORWARD( - R.string.store_forward, - RadioConfigRoutes.StoreForward, - Icons.AutoMirrored.Default.Forward, - 3 - ), + EXT_NOTIFICATION(R.string.external_notification, RadioConfigRoutes.ExtNotification, Icons.Default.Notifications, 2), + STORE_FORWARD(R.string.store_forward, RadioConfigRoutes.StoreForward, Icons.AutoMirrored.Default.Forward, 3), RANGE_TEST(R.string.range_test, RadioConfigRoutes.RangeTest, Icons.Default.Speed, 4), TELEMETRY(R.string.telemetry, RadioConfigRoutes.Telemetry, Icons.Default.DataUsage, 5), - CANNED_MESSAGE( - R.string.canned_message, - RadioConfigRoutes.CannedMessage, - Icons.AutoMirrored.Default.Message, - 6 - ), + CANNED_MESSAGE(R.string.canned_message, RadioConfigRoutes.CannedMessage, Icons.AutoMirrored.Default.Message, 6), AUDIO(R.string.audio, RadioConfigRoutes.Audio, Icons.AutoMirrored.Default.VolumeUp, 7), - REMOTE_HARDWARE( - R.string.remote_hardware, - RadioConfigRoutes.RemoteHardware, - Icons.Default.SettingsRemote, - 8 - ), + REMOTE_HARDWARE(R.string.remote_hardware, RadioConfigRoutes.RemoteHardware, Icons.Default.SettingsRemote, 8), NEIGHBOR_INFO(R.string.neighbor_info, RadioConfigRoutes.NeighborInfo, Icons.Default.People, 9), AMBIENT_LIGHTING(R.string.ambient_lighting, RadioConfigRoutes.AmbientLighting, Icons.Default.LightMode, 10), DETECTION_SENSOR(R.string.detection_sensor, RadioConfigRoutes.DetectionSensor, Icons.Default.Sensors, 11), PAXCOUNTER(R.string.paxcounter, RadioConfigRoutes.Paxcounter, Icons.Default.PermScanWifi, 12), ; - val bitfield: Int get() = 1 shl ordinal + val bitfield: Int + get() = 1 shl ordinal companion object { fun filterExcludedFrom(metadata: DeviceMetadata?): List = entries.filter { diff --git a/app/src/main/java/com/geeksville/mesh/ui/radioconfig/CleanNodeDatabaseScreen.kt b/app/src/main/java/com/geeksville/mesh/ui/radioconfig/CleanNodeDatabaseScreen.kt new file mode 100644 index 000000000..3abb27f68 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/radioconfig/CleanNodeDatabaseScreen.kt @@ -0,0 +1,202 @@ +/* + * 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 com.geeksville.mesh.ui.radioconfig + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Slider +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import com.geeksville.mesh.R +import com.geeksville.mesh.database.entity.NodeEntity +import com.geeksville.mesh.ui.node.components.NodeChip + +/** + * Composable screen for cleaning the node database. Allows users to specify criteria for deleting nodes. The list of + * nodes to be deleted updates automatically as filter criteria change. + */ +@Composable +fun CleanNodeDatabaseScreen(viewModel: CleanNodeDatabaseViewModel = hiltViewModel()) { + val olderThanDays by viewModel.olderThanDays.collectAsState() + val onlyUnknownNodes by viewModel.onlyUnknownNodes.collectAsState() + val nodesToDelete by viewModel.nodesToDelete.collectAsState() + var showConfirmationDialog by remember { mutableStateOf(false) } + + LaunchedEffect(olderThanDays, onlyUnknownNodes) { viewModel.getNodesToDelete() } + + if (showConfirmationDialog) { + ConfirmationDialog( + nodesToDeleteCount = nodesToDelete.size, + onConfirm = { + viewModel.cleanNodes() + showConfirmationDialog = false + }, + onDismiss = { showConfirmationDialog = false }, + ) + } + + Column(modifier = Modifier.padding(16.dp).verticalScroll(rememberScrollState())) { + Text(stringResource(R.string.clean_node_database_title)) + Text(stringResource(R.string.clean_node_database_description), style = MaterialTheme.typography.bodySmall) + Spacer(modifier = Modifier.height(16.dp)) + + DaysThresholdFilter( + olderThanDays = olderThanDays, + onlyUnknownNodes = onlyUnknownNodes, + onDaysChanged = viewModel::onOlderThanDaysChanged, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + UnknownNodesFilter(onlyUnknownNodes = onlyUnknownNodes, onCheckedChanged = viewModel::onOnlyUnknownNodesChanged) + + Spacer(modifier = Modifier.height(32.dp)) + + NodesDeletionPreview(nodesToDelete = nodesToDelete) + + Spacer(modifier = Modifier.height(16.dp)) + + Button( + onClick = { if (nodesToDelete.isNotEmpty()) showConfirmationDialog = true }, + modifier = Modifier.fillMaxWidth(), + enabled = nodesToDelete.isNotEmpty(), + ) { + Text(stringResource(R.string.clean_now)) + } + } +} + +private const val MIN_UNKNOWN_DAYS_THRESHOLD = 0f +private const val MIN_KNOWN_DAYS_THRESHOLD = 7f +private const val MAX_DAYS_THRESHOLD = 365f + +/** + * Composable for the "older than X days" filter. This filter is always active. + * + * @param olderThanDays The number of days for the filter. + * @param onlyUnknownNodes Whether the "only unknown nodes" filter is enabled. + * @param onDaysChanged Callback for when the number of days changes. + */ +@Composable +private fun DaysThresholdFilter(olderThanDays: Float, onlyUnknownNodes: Boolean, onDaysChanged: (Float) -> Unit) { + val valueRange = + if (onlyUnknownNodes) { + MIN_UNKNOWN_DAYS_THRESHOLD..MAX_DAYS_THRESHOLD + } else { + MIN_KNOWN_DAYS_THRESHOLD..MAX_DAYS_THRESHOLD + } + val steps = (valueRange.endInclusive - valueRange.start - 1).toInt().coerceAtLeast(0) + + Column(modifier = Modifier.fillMaxWidth()) { + Text( + modifier = Modifier.padding(bottom = 8.dp), + text = stringResource(R.string.clean_nodes_older_than, olderThanDays.toInt()), + ) + Slider( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), + value = olderThanDays, + onValueChange = onDaysChanged, + valueRange = valueRange, + steps = steps, + ) + } +} + +/** + * Composable for the "only unknown nodes" filter. + * + * @param onlyUnknownNodes Whether the filter is enabled. + * @param onCheckedChanged Callback for when the checked state changes. + */ +@Composable +private fun UnknownNodesFilter(onlyUnknownNodes: Boolean, onCheckedChanged: (Boolean) -> Unit) { + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Text(stringResource(R.string.clean_unknown_nodes)) + Spacer(Modifier.weight(1f)) + Switch(checked = onlyUnknownNodes, onCheckedChange = onCheckedChanged) + } +} + +/** + * Composable for displaying the list of nodes queued for deletion. + * + * @param nodesToDelete The list of nodes to be deleted. + */ +@Composable +private fun NodesDeletionPreview(nodesToDelete: List) { + Text( + stringResource(R.string.nodes_queued_for_deletion, nodesToDelete.size), + modifier = Modifier.padding(bottom = 16.dp), + ) + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalArrangement = Arrangement.Center, + ) { + nodesToDelete.forEach { node -> + NodeChip( + node = node.toModel(), + modifier = Modifier.padding(end = 8.dp, bottom = 8.dp), + isThisNode = false, + isConnected = false, + ) {} + } + } +} + +/** + * Composable for the confirmation dialog before deleting nodes. + * + * @param nodesToDeleteCount The number of nodes to be deleted. + * @param onConfirm Callback for when the user confirms the deletion. + * @param onDismiss Callback for when the user dismisses the dialog. + */ +@Composable +private fun ConfirmationDialog(nodesToDeleteCount: Int, onConfirm: () -> Unit, onDismiss: () -> Unit) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.are_you_sure)) }, + text = { Text(stringResource(R.string.clean_node_database_confirmation, nodesToDeleteCount)) }, + confirmButton = { Button(onClick = onConfirm) { Text(stringResource(R.string.clean_now)) } }, + dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) } }, + ) +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/radioconfig/CleanNodeDatabaseViewModel.kt b/app/src/main/java/com/geeksville/mesh/ui/radioconfig/CleanNodeDatabaseViewModel.kt new file mode 100644 index 000000000..14c1da6a6 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/radioconfig/CleanNodeDatabaseViewModel.kt @@ -0,0 +1,124 @@ +/* + * 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 com.geeksville.mesh.ui.radioconfig + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.geeksville.mesh.database.NodeRepository +import com.geeksville.mesh.database.entity.NodeEntity +import com.geeksville.mesh.repository.datastore.RadioConfigRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject +import kotlin.time.Duration.Companion.days +import kotlin.time.Duration.Companion.milliseconds + +private const val MIN_DAYS_THRESHOLD = 7f + +/** + * ViewModel for [CleanNodeDatabaseScreen]. Manages the state and logic for cleaning the node database based on + * specified criteria. The "older than X days" filter is always active. + */ +@HiltViewModel +class CleanNodeDatabaseViewModel +@Inject +constructor( + private val nodeRepository: NodeRepository, + private val radioConfigRepository: RadioConfigRepository, +) : ViewModel() { + private val _olderThanDays = MutableStateFlow(30f) + val olderThanDays = _olderThanDays.asStateFlow() + + private val _onlyUnknownNodes = MutableStateFlow(false) + val onlyUnknownNodes = _onlyUnknownNodes.asStateFlow() + + private val _nodesToDelete = MutableStateFlow>(emptyList()) + val nodesToDelete = _nodesToDelete.asStateFlow() + + fun onOlderThanDaysChanged(value: Float) { + _olderThanDays.value = value + } + + fun onOnlyUnknownNodesChanged(value: Boolean) { + _onlyUnknownNodes.value = value + if (!value && _olderThanDays.value < MIN_DAYS_THRESHOLD) { + _olderThanDays.value = MIN_DAYS_THRESHOLD + } + } + + /** + * Updates the list of nodes to be deleted based on the current filter criteria. The logic is as follows: + * - The "older than X days" filter (controlled by the slider) is always active. + * - If "only unknown nodes" is also enabled, nodes that are BOTH unknown AND older than X days are selected. + * - If "only unknown nodes" is not enabled, all nodes older than X days are selected. + * - Nodes with an associated public key (PKI) heard from within the last 7 days are always excluded from deletion. + * - Nodes marked as ignored or favorite are always excluded from deletion. + */ + fun getNodesToDelete() { + viewModelScope.launch { + val onlyUnknownEnabled = _onlyUnknownNodes.value + val currentTimeSeconds = System.currentTimeMillis().milliseconds.inWholeSeconds + val sevenDaysAgoSeconds = currentTimeSeconds - 7.days.inWholeSeconds + val olderThanTimestamp = currentTimeSeconds - _olderThanDays.value.toInt().days.inWholeSeconds + + val initialNodesToConsider = + if (onlyUnknownEnabled) { + // Both "older than X days" and "only unknown nodes" filters apply + val olderNodes = nodeRepository.getNodesOlderThan(olderThanTimestamp.toInt()) + val unknownNodes = nodeRepository.getUnknownNodes() + olderNodes.filter { itNode -> unknownNodes.any { unknownNode -> itNode.num == unknownNode.num } } + } else { + // Only "older than X days" filter applies + nodeRepository.getNodesOlderThan(olderThanTimestamp.toInt()) + } + + _nodesToDelete.value = + initialNodesToConsider.filterNot { node -> + // Exclude nodes with PKI heard in the last 7 days + (node.hasPKC && node.lastHeard >= sevenDaysAgoSeconds) || + // Exclude ignored or favorite nodes + node.isIgnored || + node.isFavorite + } + } + } + + /** + * Deletes the nodes currently queued in [_nodesToDelete] from the database and instructs the mesh service to remove + * them. + */ + fun cleanNodes() { + viewModelScope.launch { + val nodeNums = _nodesToDelete.value.map { it.num } + if (nodeNums.isNotEmpty()) { + nodeRepository.deleteNodes(nodeNums) + + val service = radioConfigRepository.meshService + if (service != null) { + for (nodeNum in nodeNums) { + service.removeByNodenum(service.packetId, nodeNum) + } + } + } + // Clear the list after deletion or if it was empty + _nodesToDelete.value = emptyList() + } + } +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/radioconfig/RadioConfig.kt b/app/src/main/java/com/geeksville/mesh/ui/radioconfig/RadioConfig.kt index a36b89853..a4e652cd1 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/radioconfig/RadioConfig.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/radioconfig/RadioConfig.kt @@ -72,6 +72,7 @@ import com.geeksville.mesh.model.UIViewModel import com.geeksville.mesh.navigation.AdminRoute import com.geeksville.mesh.navigation.ConfigRoute import com.geeksville.mesh.navigation.ModuleRoute +import com.geeksville.mesh.navigation.RadioConfigRoutes import com.geeksville.mesh.navigation.Route import com.geeksville.mesh.navigation.getNavRouteFrom import com.geeksville.mesh.ui.common.components.PreferenceCategory @@ -87,22 +88,21 @@ fun RadioConfigScreen( modifier: Modifier = Modifier, viewModel: RadioConfigViewModel = hiltViewModel(), uiViewModel: UIViewModel = hiltViewModel(), - onNavigate: (Route) -> Unit = {} + onNavigate: (Route) -> Unit = {}, ) { val node by viewModel.destNode.collectAsStateWithLifecycle() val ourNode by uiViewModel.ourNodeInfo.collectAsStateWithLifecycle() val isLocal = node?.num == ourNode?.num - val nodeName: String? = node?.user?.longName?.let { - if (!isLocal) { - "$it (" + stringResource(R.string.remote) + ")" - } else { - it + val nodeName: String? = + node?.user?.longName?.let { + if (!isLocal) { + "$it (" + stringResource(R.string.remote) + ")" + } else { + it + } } - } - nodeName?.let { - uiViewModel.setTitle(it) - } + nodeName?.let { uiViewModel.setTitle(it) } val excludedModulesUnlocked by uiViewModel.excludedModulesUnlocked.collectAsStateWithLifecycle() @@ -128,28 +128,25 @@ fun RadioConfigScreen( var deviceProfile by remember { mutableStateOf(null) } var showEditDeviceProfileDialog by remember { mutableStateOf(false) } - val importConfigLauncher = rememberLauncherForActivityResult( - ActivityResultContracts.StartActivityForResult() - ) { - if (it.resultCode == Activity.RESULT_OK) { - showEditDeviceProfileDialog = true - it.data?.data?.let { uri -> - viewModel.importProfile(uri) { profile -> deviceProfile = profile } + val importConfigLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { + if (it.resultCode == Activity.RESULT_OK) { + showEditDeviceProfileDialog = true + it.data?.data?.let { uri -> viewModel.importProfile(uri) { profile -> deviceProfile = profile } } } } - } - val exportConfigLauncher = rememberLauncherForActivityResult( - ActivityResultContracts.StartActivityForResult() - ) { - if (it.resultCode == Activity.RESULT_OK) { - it.data?.data?.let { uri -> viewModel.exportProfile(uri, deviceProfile!!) } + val exportConfigLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { + if (it.resultCode == Activity.RESULT_OK) { + it.data?.data?.let { uri -> viewModel.exportProfile(uri, deviceProfile!!) } + } } - } if (showEditDeviceProfileDialog) { EditDeviceProfileDialog( - title = if (deviceProfile != null) { + title = + if (deviceProfile != null) { stringResource(R.string.import_configuration) } else { stringResource(R.string.export_configuration) @@ -161,18 +158,19 @@ fun RadioConfigScreen( viewModel.installProfile(it) } else { deviceProfile = it - val intent = Intent(Intent.ACTION_CREATE_DOCUMENT).apply { - addCategory(Intent.CATEGORY_OPENABLE) - type = "application/*" - putExtra(Intent.EXTRA_TITLE, "device_profile.cfg") - } + val intent = + Intent(Intent.ACTION_CREATE_DOCUMENT).apply { + addCategory(Intent.CATEGORY_OPENABLE) + type = "application/*" + putExtra(Intent.EXTRA_TITLE, "device_profile.cfg") + } exportConfigLauncher.launch(intent) } }, onDismiss = { showEditDeviceProfileDialog = false deviceProfile = null - } + }, ) } @@ -187,10 +185,11 @@ fun RadioConfigScreen( onImport = { viewModel.clearPacketResponse() deviceProfile = null - val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply { - addCategory(Intent.CATEGORY_OPENABLE) - type = "application/*" - } + val intent = + Intent(Intent.ACTION_OPEN_DOCUMENT).apply { + addCategory(Intent.CATEGORY_OPENABLE) + type = "application/*" + } importConfigLauncher.launch(intent) }, onExport = { @@ -198,44 +197,23 @@ fun RadioConfigScreen( deviceProfile = null showEditDeviceProfileDialog = true }, + onNavigate = onNavigate, ) } @Composable -fun NavCard( - title: String, - enabled: Boolean, - icon: ImageVector? = null, - onClick: () -> Unit -) { - Card( - onClick = onClick, - enabled = enabled, - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 2.dp) - ) { +fun NavCard(title: String, enabled: Boolean, icon: ImageVector? = null, onClick: () -> Unit) { + Card(onClick = onClick, enabled = enabled, modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp)) { Row( verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(vertical = 12.dp, horizontal = 16.dp) + modifier = Modifier.padding(vertical = 12.dp, horizontal = 16.dp), ) { if (icon != null) { - Icon( - imageVector = icon, - contentDescription = title, - modifier = Modifier.size(24.dp), - ) + Icon(imageVector = icon, contentDescription = title, modifier = Modifier.size(24.dp)) Spacer(modifier = Modifier.width(8.dp)) } - Text( - text = title, - style = MaterialTheme.typography.bodyLarge, - modifier = Modifier.weight(1f) - ) - Icon( - Icons.AutoMirrored.TwoTone.KeyboardArrowRight, "trailingIcon", - modifier = Modifier.wrapContentSize(), - ) + Text(text = title, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f)) + Icon(Icons.AutoMirrored.TwoTone.KeyboardArrowRight, "trailingIcon", modifier = Modifier.wrapContentSize()) } } } @@ -249,58 +227,48 @@ private fun NavButton(@StringRes title: Int, enabled: Boolean, onClick: () -> Un onDismissRequest = {}, shape = RoundedCornerShape(16.dp), title = { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Center, - ) { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) { Icon( imageVector = Icons.TwoTone.Warning, contentDescription = stringResource(id = R.string.warning), - modifier = Modifier.padding(end = 8.dp) - ) - Text( - text = "${stringResource(title)}?\n" + modifier = Modifier.padding(end = 8.dp), ) + Text(text = "${stringResource(title)}?\n") Icon( imageVector = Icons.TwoTone.Warning, contentDescription = stringResource(id = R.string.warning), - modifier = Modifier.padding(start = 8.dp) + modifier = Modifier.padding(start = 8.dp), ) } }, confirmButton = { Row( - modifier = Modifier - .fillMaxWidth() - .padding(start = 16.dp, end = 16.dp, bottom = 16.dp), + modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 16.dp, bottom = 16.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, ) { - TextButton( - modifier = Modifier.weight(1f), - onClick = { showDialog = false }, - ) { Text(stringResource(R.string.cancel)) } + TextButton(modifier = Modifier.weight(1f), onClick = { showDialog = false }) { + Text(stringResource(R.string.cancel)) + } Button( modifier = Modifier.weight(1f), onClick = { showDialog = false onClick() }, - ) { Text(stringResource(R.string.send)) } + ) { + Text(stringResource(R.string.send)) + } } - } + }, ) } Column { Spacer(modifier = Modifier.height(4.dp)) - Button( - modifier = Modifier - .fillMaxWidth() - .height(48.dp), - enabled = enabled, - onClick = { showDialog = true }, - ) { Text(text = stringResource(title)) } + Button(modifier = Modifier.fillMaxWidth().height(48.dp), enabled = enabled, onClick = { showDialog = true }) { + Text(text = stringResource(title)) + } } } @@ -312,6 +280,7 @@ private fun RadioConfigItemList( onRouteClick: (Enum<*>) -> Unit = {}, onImport: () -> Unit = {}, onExport: () -> Unit = {}, + onNavigate: (Route) -> Unit, ) { val enabled = state.connected && !state.responseState.isWaiting() var modules by remember { mutableStateOf(ModuleRoute.filterExcludedFrom(state.metadata)) } @@ -322,26 +291,15 @@ private fun RadioConfigItemList( modules = ModuleRoute.filterExcludedFrom(state.metadata) } } - LazyColumn( - modifier = modifier, - contentPadding = PaddingValues(horizontal = 16.dp), - ) { + LazyColumn(modifier = modifier, contentPadding = PaddingValues(horizontal = 16.dp)) { item { PreferenceCategory(stringResource(R.string.radio_configuration)) } items(ConfigRoute.filterExcludedFrom(state.metadata)) { - NavCard( - title = stringResource(it.title), - icon = it.icon, - enabled = enabled - ) { onRouteClick(it) } + NavCard(title = stringResource(it.title), icon = it.icon, enabled = enabled) { onRouteClick(it) } } item { PreferenceCategory(stringResource(R.string.module_settings)) } items(modules) { - NavCard( - title = stringResource(it.title), - icon = it.icon, - enabled = enabled - ) { onRouteClick(it) } + NavCard(title = stringResource(it.title), icon = it.icon, enabled = enabled) { onRouteClick(it) } } if (state.isLocal) { @@ -363,6 +321,15 @@ private fun RadioConfigItemList( } items(AdminRoute.entries) { NavButton(it.title, enabled) { onRouteClick(it) } } + + item { + PreferenceCategory("Advanced") + NavCard( + title = stringResource(R.string.clean_node_database_title), + enabled = enabled, + onClick = { onNavigate(RadioConfigRoutes.CleanNodeDb) }, + ) + } } } @@ -370,10 +337,7 @@ private const val UNLOCK_CLICK_COUNT = 5 // Number of clicks required to unlock private const val UNLOCK_TIMEOUT_SECONDS = 3 // Timeout in seconds to reset the click counter. @Composable -fun RadioConfigMenuActions( - modifier: Modifier = Modifier, - viewModel: UIViewModel = hiltViewModel(), -) { +fun RadioConfigMenuActions(modifier: Modifier = Modifier, viewModel: UIViewModel = hiltViewModel()) { val context = LocalContext.current var counter by remember { mutableIntStateOf(0) } LaunchedEffect(counter) { @@ -388,22 +352,15 @@ fun RadioConfigMenuActions( counter++ if (counter == UNLOCK_CLICK_COUNT) { viewModel.unlockExcludedModules() - Toast.makeText( - context, - context.getString(R.string.modules_unlocked), - Toast.LENGTH_LONG - ).show() + Toast.makeText(context, context.getString(R.string.modules_unlocked), Toast.LENGTH_LONG).show() } }, modifier = modifier, - ) { - } + ) {} } @Preview(showBackground = true) @Composable private fun RadioSettingsScreenPreview() = AppTheme { - RadioConfigItemList( - RadioConfigState(isLocal = true, connected = true) - ) + RadioConfigItemList(state = RadioConfigState(isLocal = true, connected = true), onNavigate = { _ -> }) } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 57e39f9cc..bba9ce7e3 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -666,6 +666,14 @@ UV Lux Unknown + Clean Node Database + Clean up nodes last seen older than %1$d days + Clean up only unknown nodes + Clean up nodes with low/no interaction + Clean up ignored nodes + Clean Now + This will remove %1$d nodes from your database. This action cannot be undone. + A green lock means the channel is securely encrypted with either a 128 or 256 bit AES key. @@ -751,4 +759,6 @@ Meshtastic uses notifications to keep you updated on new messages and other important events. You can update your notification permissions at any time from settings. Next Grant Permissions and Scan + %d nodes queued for deletion: + Caution: This removes nodes from in-app and on-device databases.\nSelections are additive.