feat(radioconfig): add clean node database screen (#2592)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich 2025-08-01 10:45:33 -05:00 committed by GitHub
parent 7c561ae4f8
commit 3646438a62
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 588 additions and 321 deletions

View file

@ -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<MyNodeEntity?> = nodeInfoDao.getMyNodeInfo()
.flowOn(dispatchers.io)
.stateIn(processLifecycle.coroutineScope, SharingStarted.Eagerly, null)
val myNodeInfo: StateFlow<MyNodeEntity?> =
nodeInfoDao
.getMyNodeInfo()
.flowOn(dispatchers.io)
.stateIn(processLifecycle.coroutineScope, SharingStarted.Eagerly, null)
// our node info
private val _ourNodeInfo = MutableStateFlow<Node?>(null)
val ourNodeInfo: StateFlow<Node?> get() = _ourNodeInfo
val ourNodeInfo: StateFlow<Node?>
get() = _ourNodeInfo
// The unique userId of our node
private val _myId = MutableStateFlow<String?>(null)
val myId: StateFlow<String?> get() = _myId
val myId: StateFlow<String?>
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<Map<Int, Node>> = 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<Map<Int, Node>> =
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<NodeEntity>) = 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<Int>) = withContext(dispatchers.io) {
nodeInfoDao.deleteNodes(nodeNums)
nodeNums.forEach { nodeInfoDao.deleteMetadata(it) }
}
val onlineNodeCount: Flow<Int> = nodeInfoDao.nodeDBbyNum().mapLatest { map ->
map.values.count { it.node.lastHeard > onlineTimeThreshold() }
}.flowOn(dispatchers.io).conflate()
suspend fun getNodesOlderThan(lastHeard: Int): List<NodeEntity> =
withContext(dispatchers.io) { nodeInfoDao.getNodesOlderThan(lastHeard) }
val totalNodeCount: Flow<Int> = nodeInfoDao.nodeDBbyNum().mapLatest { map ->
map.values.count()
}.flowOn(dispatchers.io).conflate()
suspend fun getUnknownNodes(): List<NodeEntity> = withContext(dispatchers.io) { nodeInfoDao.getUnknownNodes() }
suspend fun insertMetadata(metadata: MetadataEntity) = withContext(dispatchers.io) { nodeInfoDao.upsert(metadata) }
val onlineNodeCount: Flow<Int> =
nodeInfoDao
.nodeDBbyNum()
.mapLatest { map -> map.values.count { it.node.lastHeard > onlineTimeThreshold() } }
.flowOn(dispatchers.io)
.conflate()
val totalNodeCount: Flow<Int> =
nodeInfoDao.nodeDBbyNum().mapLatest { map -> map.values.count() }.flowOn(dispatchers.io).conflate()
}

View file

@ -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<Map<@MapColumn(columnName = "num") Int, NodeWithRelations>>
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<Int>)
@Query("SELECT * FROM nodes WHERE last_heard < :lastHeard")
fun getNodesOlderThan(lastHeard: Int): List<NodeEntity>
@Query("SELECT * FROM nodes WHERE short_name IS NULL")
fun getUnknownNodes(): List<NodeEntity>
@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<NodeEntity>)

View file

@ -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 =

View file

@ -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<RadioConfigRoutes.RadioConfigGraph>(
startDestination = RadioConfigRoutes.RadioConfig(),
) {
navigation<RadioConfigRoutes.RadioConfigGraph>(startDestination = RadioConfigRoutes.RadioConfig()) {
composable<RadioConfigRoutes.RadioConfig> { 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<RadioConfigRoutes.CleanNodeDb> { 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<ModuleRoute> = entries.filter {

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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<NodeEntity>) {
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)) } },
)
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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<List<NodeEntity>>(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()
}
}
}

View file

@ -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<DeviceProfile?>(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 = { _ -> })
}