From a024e1852fdf6a5ab381f9489d3065b955d5f040 Mon Sep 17 00:00:00 2001 From: Mac DeCourcy Date: Sat, 3 Jan 2026 07:31:00 -0800 Subject: [PATCH 1/2] feat: schedule nodedb cleanup --- .../core/data/repository/NodeRepository.kt | 53 +++++++ .../composeResources/values/strings.xml | 10 ++ feature/settings/build.gradle.kts | 2 + .../settings/worker/NodeCleanupWorker.kt | 105 ++++++++++++++ .../settings/radio/CleanNodeDatabaseScreen.kt | 131 +++++++++++++++++- .../radio/CleanNodeDatabaseViewModel.kt | 101 ++++++++++---- 6 files changed, 375 insertions(+), 27 deletions(-) create mode 100644 feature/settings/src/main/java/org/meshtastic/feature/settings/worker/NodeCleanupWorker.kt diff --git a/core/data/src/main/kotlin/org/meshtastic/core/data/repository/NodeRepository.kt b/core/data/src/main/kotlin/org/meshtastic/core/data/repository/NodeRepository.kt index 6453b3069e..226066c53d 100644 --- a/core/data/src/main/kotlin/org/meshtastic/core/data/repository/NodeRepository.kt +++ b/core/data/src/main/kotlin/org/meshtastic/core/data/repository/NodeRepository.kt @@ -32,6 +32,9 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import kotlin.time.Duration.Companion.days +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.milliseconds import org.meshtastic.core.data.datasource.NodeInfoReadDataSource import org.meshtastic.core.data.datasource.NodeInfoWriteDataSource import org.meshtastic.core.database.entity.MetadataEntity @@ -186,4 +189,54 @@ constructor( suspend fun setNodeNotes(num: Int, notes: String) = withContext(dispatchers.io) { nodeInfoWriteDataSource.setNodeNotes(num, notes) } + + /** + * Returns nodes eligible for cleanup using the same rules as the settings UI: + * - Nodes older than [olderThanDays] + * - If [onlyUnknownNodes] is true, they must also be unknown + * - Always exclude nodes with PKI heard within the last 7 days, ignored nodes, and favorites + */ + suspend fun getNodesForCleanup(olderThanDays: Int, onlyUnknownNodes: Boolean): List = + withContext(dispatchers.io) { + val currentTimeSeconds = System.currentTimeMillis().milliseconds.inWholeSeconds + val sevenDaysAgoSeconds = currentTimeSeconds - 7.days.inWholeSeconds + val olderThanTimestamp = currentTimeSeconds - olderThanDays.days.inWholeSeconds + + val initialNodesToConsider = + if (onlyUnknownNodes) { + val olderNodes = nodeInfoReadDataSource.getNodesOlderThan(olderThanTimestamp.toInt()) + val unknownNodes = nodeInfoReadDataSource.getUnknownNodes() + olderNodes.filter { node -> unknownNodes.any { unknown -> node.num == unknown.num } } + } else { + nodeInfoReadDataSource.getNodesOlderThan(olderThanTimestamp.toInt()) + } + + initialNodesToConsider.filterNot { node -> + (node.hasPKC && node.lastHeard >= sevenDaysAgoSeconds) || + node.isIgnored || + node.isFavorite + } + } + + suspend fun getNodesForCleanupMinutes(olderThanMinutes: Int, onlyUnknownNodes: Boolean): List = + withContext(dispatchers.io) { + val currentTimeSeconds = System.currentTimeMillis().milliseconds.inWholeSeconds + val sevenDaysAgoSeconds = currentTimeSeconds - 7.days.inWholeSeconds + val olderThanTimestamp = currentTimeSeconds - olderThanMinutes.minutes.inWholeSeconds + + val initialNodesToConsider = + if (onlyUnknownNodes) { + val olderNodes = nodeInfoReadDataSource.getNodesOlderThan(olderThanTimestamp.toInt()) + val unknownNodes = nodeInfoReadDataSource.getUnknownNodes() + olderNodes.filter { node -> unknownNodes.any { unknown -> node.num == unknown.num } } + } else { + nodeInfoReadDataSource.getNodesOlderThan(olderThanTimestamp.toInt()) + } + + initialNodesToConsider.filterNot { node -> + (node.hasPKC && node.lastHeard >= sevenDaysAgoSeconds) || + node.isIgnored || + node.isFavorite + } + } } diff --git a/core/strings/src/commonMain/composeResources/values/strings.xml b/core/strings/src/commonMain/composeResources/values/strings.xml index d89f5b4e03..a7547b62aa 100644 --- a/core/strings/src/commonMain/composeResources/values/strings.xml +++ b/core/strings/src/commonMain/composeResources/values/strings.xml @@ -837,6 +837,16 @@ Clean up ignored nodes Clean Now This will remove %1$d nodes from your database. This action cannot be undone. + Schedule cleanup + Schedule recurring cleanup + Run hourly to remove nodes last seen beyond the chosen days. + Scheduled node cleanup + Failed to schedule cleanup: %1$s + No scheduled cleanup to cancel + Debug: schedule 1-hour unknown-node cleanup + Cancel scheduled cleanup + Cancelled scheduled cleanup + Failed to cancel cleanup: %1$s A green lock means the channel is securely encrypted with either a 128 or 256 bit AES key. diff --git a/feature/settings/build.gradle.kts b/feature/settings/build.gradle.kts index 49d8ad1876..f2657801d1 100644 --- a/feature/settings/build.gradle.kts +++ b/feature/settings/build.gradle.kts @@ -63,6 +63,8 @@ dependencies { implementation(libs.androidx.compose.ui.text) implementation(libs.androidx.compose.ui.tooling.preview) implementation(libs.androidx.navigation.compose) + implementation(libs.androidx.hilt.work) + implementation(libs.androidx.work.runtime.ktx) implementation(libs.kotlinx.collections.immutable) implementation(libs.kermit) implementation(libs.zxing.android.embedded) diff --git a/feature/settings/src/main/java/org/meshtastic/feature/settings/worker/NodeCleanupWorker.kt b/feature/settings/src/main/java/org/meshtastic/feature/settings/worker/NodeCleanupWorker.kt new file mode 100644 index 0000000000..561cd2d156 --- /dev/null +++ b/feature/settings/src/main/java/org/meshtastic/feature/settings/worker/NodeCleanupWorker.kt @@ -0,0 +1,105 @@ +/* + * Copyright (c) 2025-2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.settings.worker + +import android.content.Context +import androidx.hilt.work.HiltWorker +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import co.touchlab.kermit.Logger +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import dagger.hilt.android.EntryPointAccessors +import dagger.hilt.components.SingletonComponent +import org.meshtastic.core.data.repository.NodeRepository +import org.meshtastic.core.service.ServiceRepository + +@HiltWorker +class NodeCleanupWorker +@AssistedInject +constructor( + @Assisted appContext: Context, + @Assisted workerParams: WorkerParameters, + private val nodeRepository: NodeRepository, + private val serviceRepository: ServiceRepository, +) : CoroutineWorker(appContext, workerParams) { + + constructor( + appContext: Context, + workerParams: WorkerParameters, + ) : this( + appContext, + workerParams, + entryPoint(appContext).nodeRepository(), + entryPoint(appContext).serviceRepository(), + ) + + @Suppress("TooGenericExceptionCaught") + override suspend fun doWork(): Result = try { + val olderThanDays = inputData.getInt(KEY_OLDER_THAN_DAYS, DEFAULT_DAYS) + val olderThanMinutes = inputData.getInt(KEY_OLDER_THAN_MINUTES, -1) + val onlyUnknownNodes = inputData.getBoolean(KEY_ONLY_UNKNOWN, false) + + val nodesToDelete = + if (olderThanMinutes > 0) { + nodeRepository.getNodesForCleanupMinutes(olderThanMinutes, onlyUnknownNodes) + } else { + nodeRepository.getNodesForCleanup(olderThanDays, onlyUnknownNodes) + } + if (nodesToDelete.isEmpty()) { + logger.i { "Node cleanup: no nodes eligible for deletion (olderThanDays=$olderThanDays, unknownOnly=$onlyUnknownNodes)" } + return Result.success() + } + + val nodeNums = nodesToDelete.map { it.num } + nodeRepository.deleteNodes(nodeNums) + + serviceRepository.meshService?.let { service -> + nodeNums.forEach { nodeNum -> service.removeByNodenum(service.packetId, nodeNum) } + } + + logger.i { "Node cleanup: removed ${nodeNums.size} nodes older than $olderThanDays days" } + Result.success() + } catch (e: Exception) { + logger.e(e) { "Node cleanup failed" } + Result.failure() + } + + companion object { + const val WORK_NAME = "node_cleanup_worker" + const val KEY_OLDER_THAN_DAYS = "older_than_days" + const val KEY_OLDER_THAN_MINUTES = "older_than_minutes" + const val KEY_ONLY_UNKNOWN = "only_unknown" + const val DEFAULT_DAYS = 30 + + private fun entryPoint(context: Context): NodeWorkerEntryPoint = + EntryPointAccessors.fromApplication(context.applicationContext, NodeWorkerEntryPoint::class.java) + } + + private val logger = Logger.withTag(WORK_NAME) +} + +@EntryPoint +@InstallIn(SingletonComponent::class) +interface NodeWorkerEntryPoint { + fun nodeRepository(): NodeRepository + + fun serviceRepository(): ServiceRepository +} + diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/CleanNodeDatabaseScreen.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/CleanNodeDatabaseScreen.kt index cd30b6ab81..b0d2370be7 100644 --- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/CleanNodeDatabaseScreen.kt +++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/CleanNodeDatabaseScreen.kt @@ -43,6 +43,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import org.jetbrains.compose.resources.stringResource @@ -57,7 +58,18 @@ import org.meshtastic.core.strings.clean_nodes_older_than import org.meshtastic.core.strings.clean_now import org.meshtastic.core.strings.clean_unknown_nodes import org.meshtastic.core.strings.nodes_queued_for_deletion +import org.meshtastic.core.strings.schedule_cleanup +import org.meshtastic.core.strings.schedule_cleanup_description +import org.meshtastic.core.strings.schedule_cleanup_failed +import org.meshtastic.core.strings.schedule_cleanup_title +import org.meshtastic.core.strings.schedule_cleanup_success +import org.meshtastic.core.strings.cancel_scheduled_cleanup +import org.meshtastic.core.strings.cancel_scheduled_cleanup_success +import org.meshtastic.core.strings.cancel_scheduled_cleanup_failed +import org.meshtastic.core.strings.cancel_scheduled_cleanup_none +import org.meshtastic.core.strings.debug_schedule_one_hour import org.meshtastic.core.ui.component.NodeChip +import org.meshtastic.core.ui.util.showToast /** * Composable screen for cleaning the node database. Allows users to specify criteria for deleting nodes. The list of @@ -69,9 +81,22 @@ fun CleanNodeDatabaseScreen(viewModel: CleanNodeDatabaseViewModel = hiltViewMode val onlyUnknownNodes by viewModel.onlyUnknownNodes.collectAsState() val nodesToDelete by viewModel.nodesToDelete.collectAsState() var showConfirmationDialog by remember { mutableStateOf(false) } + var showScheduleDialog by remember { mutableStateOf(false) } + val context = LocalContext.current LaunchedEffect(olderThanDays, onlyUnknownNodes) { viewModel.getNodesToDelete() } + LaunchedEffect(Unit) { + viewModel.scheduleEvents.collect { result -> + when (result) { + is ScheduleResult.Success -> context.showToast(Res.string.schedule_cleanup_success) + is ScheduleResult.Cancelled -> context.showToast(Res.string.cancel_scheduled_cleanup_success) + is ScheduleResult.NoWork -> context.showToast(Res.string.cancel_scheduled_cleanup_none) + is ScheduleResult.Failure -> context.showToast(Res.string.schedule_cleanup_failed, result.reason) + } + } + } + if (showConfirmationDialog) { ConfirmationDialog( nodesToDeleteCount = nodesToDelete.size, @@ -83,6 +108,18 @@ fun CleanNodeDatabaseScreen(viewModel: CleanNodeDatabaseViewModel = hiltViewMode ) } + if (showScheduleDialog) { + ScheduleCleanupDialog( + initialDays = olderThanDays.toInt().coerceAtLeast(MIN_KNOWN_DAYS_THRESHOLD.toInt()), + initialUnknownOnly = onlyUnknownNodes, + onConfirm = { days, unknownOnly -> + viewModel.scheduleNodeCleanup(days, unknownOnly) + showScheduleDialog = false + }, + onDismiss = { showScheduleDialog = false }, + ) + } + Column(modifier = Modifier.padding(16.dp).verticalScroll(rememberScrollState())) { Text(stringResource(Res.string.clean_node_database_title)) Text(stringResource(Res.string.clean_node_database_description), style = MaterialTheme.typography.bodySmall) @@ -111,6 +148,29 @@ fun CleanNodeDatabaseScreen(viewModel: CleanNodeDatabaseViewModel = hiltViewMode ) { Text(stringResource(Res.string.clean_now)) } + + Spacer(modifier = Modifier.height(12.dp)) + + Button( + onClick = { showScheduleDialog = true }, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(Res.string.schedule_cleanup)) + } + + TextButton( + onClick = { viewModel.cancelScheduledNodeCleanup() }, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(Res.string.cancel_scheduled_cleanup)) + } + + TextButton( + onClick = { viewModel.scheduleNodeCleanup(olderThanDays = 0, olderThanMinutes = 60, onlyUnknownNodes = true) }, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(Res.string.debug_schedule_one_hour)) + } } } @@ -126,9 +186,16 @@ private const val MAX_DAYS_THRESHOLD = 365f * @param onDaysChanged Callback for when the number of days changes. */ @Composable -private fun DaysThresholdFilter(olderThanDays: Float, onlyUnknownNodes: Boolean, onDaysChanged: (Float) -> Unit) { +private fun DaysThresholdFilter( + olderThanDays: Float, + onlyUnknownNodes: Boolean, + onDaysChanged: (Float) -> Unit, + useZeroMin: Boolean = false, +) { val valueRange = - if (onlyUnknownNodes) { + if (useZeroMin) { + MIN_UNKNOWN_DAYS_THRESHOLD..MAX_DAYS_THRESHOLD + } else if (onlyUnknownNodes) { MIN_UNKNOWN_DAYS_THRESHOLD..MAX_DAYS_THRESHOLD } else { MIN_KNOWN_DAYS_THRESHOLD..MAX_DAYS_THRESHOLD @@ -150,6 +217,66 @@ private fun DaysThresholdFilter(olderThanDays: Float, onlyUnknownNodes: Boolean, } } +@Composable +private fun ScheduleCleanupDialog( + initialDays: Int, + initialUnknownOnly: Boolean, + onConfirm: (Int, Boolean) -> Unit, + onDismiss: () -> Unit, +) { + var selectedDays by remember { mutableStateOf(initialDays.toFloat()) } + var unknownOnly by remember { mutableStateOf(initialUnknownOnly) } + + val valueRange = + if (unknownOnly) { + MIN_UNKNOWN_DAYS_THRESHOLD..MAX_DAYS_THRESHOLD + } else { + MIN_KNOWN_DAYS_THRESHOLD..MAX_DAYS_THRESHOLD + } + + LaunchedEffect(unknownOnly) { + if (!unknownOnly && selectedDays < MIN_KNOWN_DAYS_THRESHOLD) { + selectedDays = MIN_KNOWN_DAYS_THRESHOLD + } + } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(Res.string.schedule_cleanup_title)) }, + text = { + Column { + Text(stringResource(Res.string.schedule_cleanup_description)) + Spacer(modifier = Modifier.height(16.dp)) + DaysThresholdFilter( + olderThanDays = selectedDays, + onlyUnknownNodes = unknownOnly, + useZeroMin = true, // keep slider visual position stable while enforcing min via clamp + onDaysChanged = { + val min = if (unknownOnly) MIN_UNKNOWN_DAYS_THRESHOLD else MIN_KNOWN_DAYS_THRESHOLD + selectedDays = it.coerceAtLeast(min) + }, + ) + Spacer(modifier = Modifier.height(12.dp)) + UnknownNodesFilter( + onlyUnknownNodes = unknownOnly, + onCheckedChanged = { + unknownOnly = it + if (!it && selectedDays < MIN_KNOWN_DAYS_THRESHOLD) { + selectedDays = MIN_KNOWN_DAYS_THRESHOLD + } + }, + ) + } + }, + confirmButton = { + Button(onClick = { onConfirm(selectedDays.toInt(), unknownOnly) }) { + Text(stringResource(Res.string.schedule_cleanup)) + } + }, + dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(Res.string.cancel)) } }, + ) +} + /** * Composable for the "only unknown nodes" filter. * diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/CleanNodeDatabaseViewModel.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/CleanNodeDatabaseViewModel.kt index 1b616102b7..362a750fe9 100644 --- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/CleanNodeDatabaseViewModel.kt +++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/CleanNodeDatabaseViewModel.kt @@ -17,18 +17,29 @@ package org.meshtastic.feature.settings.radio +import android.content.Context import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkInfo +import androidx.work.WorkManager +import androidx.work.workDataOf import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.coroutines.Dispatchers +import org.meshtastic.feature.settings.worker.NodeCleanupWorker import org.meshtastic.core.data.repository.NodeRepository import org.meshtastic.core.database.entity.NodeEntity import org.meshtastic.core.service.ServiceRepository +import java.util.concurrent.TimeUnit import javax.inject.Inject -import kotlin.time.Duration.Companion.days -import kotlin.time.Duration.Companion.milliseconds private const val MIN_DAYS_THRESHOLD = 7f @@ -42,6 +53,7 @@ class CleanNodeDatabaseViewModel constructor( private val nodeRepository: NodeRepository, private val serviceRepository: ServiceRepository, + @ApplicationContext private val appContext: Context, ) : ViewModel() { private val _olderThanDays = MutableStateFlow(30f) val olderThanDays = _olderThanDays.asStateFlow() @@ -52,6 +64,9 @@ constructor( private val _nodesToDelete = MutableStateFlow>(emptyList()) val nodesToDelete = _nodesToDelete.asStateFlow() + private val _scheduleEvents = MutableSharedFlow() + val scheduleEvents = _scheduleEvents.asSharedFlow() + fun onOlderThanDaysChanged(value: Float) { _olderThanDays.value = value } @@ -73,30 +88,11 @@ constructor( */ 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 - } + nodeRepository.getNodesForCleanup( + olderThanDays = _olderThanDays.value.toInt(), + onlyUnknownNodes = _onlyUnknownNodes.value, + ) } } @@ -121,4 +117,59 @@ constructor( _nodesToDelete.value = emptyList() } } + + /** + * Schedule recurring cleanup using WorkManager. Runs hourly (matching mesh log cleanup cadence). + */ + fun scheduleNodeCleanup(olderThanDays: Int, onlyUnknownNodes: Boolean, olderThanMinutes: Int? = null) { + viewModelScope.launch { + try { + val data = + workDataOf( + NodeCleanupWorker.KEY_OLDER_THAN_DAYS to olderThanDays, + NodeCleanupWorker.KEY_OLDER_THAN_MINUTES to (olderThanMinutes ?: -1), + NodeCleanupWorker.KEY_ONLY_UNKNOWN to onlyUnknownNodes, + ) + val request = + PeriodicWorkRequestBuilder(1, TimeUnit.HOURS) + .setInputData(data) + .build() + + WorkManager.getInstance(appContext) + .enqueueUniquePeriodicWork( + NodeCleanupWorker.WORK_NAME, + ExistingPeriodicWorkPolicy.UPDATE, + request, + ) + + _scheduleEvents.emit(ScheduleResult.Success) + } catch (e: Exception) { + _scheduleEvents.emit(ScheduleResult.Failure(e.message ?: "")) + } + } + } + + fun cancelScheduledNodeCleanup() { + viewModelScope.launch { + try { + val workManager = WorkManager.getInstance(appContext) + val infos = withContext(Dispatchers.IO) { workManager.getWorkInfosForUniqueWork(NodeCleanupWorker.WORK_NAME).get() } + if (infos.isNullOrEmpty() || infos.all { it.state.isFinished }) { + _scheduleEvents.emit(ScheduleResult.NoWork) + return@launch + } + workManager.cancelUniqueWork(NodeCleanupWorker.WORK_NAME) + _scheduleEvents.emit(ScheduleResult.Cancelled) + } catch (e: Exception) { + _scheduleEvents.emit(ScheduleResult.Failure(e.message ?: "")) + } + } + } +} + +sealed interface ScheduleResult { + data object Success : ScheduleResult + data object Cancelled : ScheduleResult + data object NoWork : ScheduleResult + data class Failure(val reason: String) : ScheduleResult } From 16784ea147c5068e7715762c38fe5be7a99c4dbc Mon Sep 17 00:00:00 2001 From: Mac DeCourcy Date: Sun, 4 Jan 2026 16:44:38 -0800 Subject: [PATCH 2/2] feat: schedule nodedb cleanup --- .../composeResources/values/strings.xml | 8 ++ .../settings/worker/NodeCleanupWorker.kt | 15 +- .../settings/radio/CleanNodeDatabaseScreen.kt | 128 ++++++++---------- .../radio/CleanNodeDatabaseViewModel.kt | 91 +++++++++++++ 4 files changed, 173 insertions(+), 69 deletions(-) diff --git a/core/strings/src/commonMain/composeResources/values/strings.xml b/core/strings/src/commonMain/composeResources/values/strings.xml index a7547b62aa..0a645fc8ee 100644 --- a/core/strings/src/commonMain/composeResources/values/strings.xml +++ b/core/strings/src/commonMain/composeResources/values/strings.xml @@ -847,6 +847,14 @@ Cancel scheduled cleanup Cancelled scheduled cleanup Failed to cancel cleanup: %1$s + Scheduled cleanup + No cleanup scheduled + State: %1$s + Threshold: %1$d (%2$s unknown only) + Last run: %1$s + Run cleanup now + Triggered cleanup + Failed to trigger cleanup: %1$s A green lock means the channel is securely encrypted with either a 128 or 256 bit AES key. diff --git a/feature/settings/src/main/java/org/meshtastic/feature/settings/worker/NodeCleanupWorker.kt b/feature/settings/src/main/java/org/meshtastic/feature/settings/worker/NodeCleanupWorker.kt index 561cd2d156..0618d77709 100644 --- a/feature/settings/src/main/java/org/meshtastic/feature/settings/worker/NodeCleanupWorker.kt +++ b/feature/settings/src/main/java/org/meshtastic/feature/settings/worker/NodeCleanupWorker.kt @@ -20,6 +20,7 @@ import android.content.Context import androidx.hilt.work.HiltWorker import androidx.work.CoroutineWorker import androidx.work.WorkerParameters +import androidx.work.workDataOf import co.touchlab.kermit.Logger import dagger.assisted.Assisted import dagger.assisted.AssistedInject @@ -75,7 +76,14 @@ constructor( } logger.i { "Node cleanup: removed ${nodeNums.size} nodes older than $olderThanDays days" } - Result.success() + val output = + workDataOf( + KEY_LAST_RUN_EPOCH to System.currentTimeMillis(), + KEY_OLDER_THAN_DAYS to olderThanDays, + KEY_OLDER_THAN_MINUTES to olderThanMinutes, + KEY_ONLY_UNKNOWN to onlyUnknownNodes, + ) + Result.success(output) } catch (e: Exception) { logger.e(e) { "Node cleanup failed" } Result.failure() @@ -86,8 +94,13 @@ constructor( const val KEY_OLDER_THAN_DAYS = "older_than_days" const val KEY_OLDER_THAN_MINUTES = "older_than_minutes" const val KEY_ONLY_UNKNOWN = "only_unknown" + const val KEY_LAST_RUN_EPOCH = "last_run_epoch" const val DEFAULT_DAYS = 30 + const val TAG_DAYS_PREFIX = "days:" + const val TAG_MINUTES_PREFIX = "minutes:" + const val TAG_UNKNOWN = "unknown" + private fun entryPoint(context: Context): NodeWorkerEntryPoint = EntryPointAccessors.fromApplication(context.applicationContext, NodeWorkerEntryPoint::class.java) } diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/CleanNodeDatabaseScreen.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/CleanNodeDatabaseScreen.kt index b0d2370be7..288d495509 100644 --- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/CleanNodeDatabaseScreen.kt +++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/CleanNodeDatabaseScreen.kt @@ -57,12 +57,18 @@ import org.meshtastic.core.strings.clean_node_database_title import org.meshtastic.core.strings.clean_nodes_older_than import org.meshtastic.core.strings.clean_now import org.meshtastic.core.strings.clean_unknown_nodes +import org.meshtastic.core.strings.scheduled_cleanup_status +import org.meshtastic.core.strings.scheduled_cleanup_none +import org.meshtastic.core.strings.scheduled_cleanup_state +import org.meshtastic.core.strings.scheduled_cleanup_params +import org.meshtastic.core.strings.scheduled_cleanup_last_run import org.meshtastic.core.strings.nodes_queued_for_deletion import org.meshtastic.core.strings.schedule_cleanup -import org.meshtastic.core.strings.schedule_cleanup_description import org.meshtastic.core.strings.schedule_cleanup_failed -import org.meshtastic.core.strings.schedule_cleanup_title import org.meshtastic.core.strings.schedule_cleanup_success +import org.meshtastic.core.strings.run_cleanup_now +import org.meshtastic.core.strings.run_cleanup_now_success +import org.meshtastic.core.strings.run_cleanup_now_failed import org.meshtastic.core.strings.cancel_scheduled_cleanup import org.meshtastic.core.strings.cancel_scheduled_cleanup_success import org.meshtastic.core.strings.cancel_scheduled_cleanup_failed @@ -80,11 +86,12 @@ fun CleanNodeDatabaseScreen(viewModel: CleanNodeDatabaseViewModel = hiltViewMode val olderThanDays by viewModel.olderThanDays.collectAsState() val onlyUnknownNodes by viewModel.onlyUnknownNodes.collectAsState() val nodesToDelete by viewModel.nodesToDelete.collectAsState() + val scheduleStatus by viewModel.scheduleStatus.collectAsState() var showConfirmationDialog by remember { mutableStateOf(false) } - var showScheduleDialog by remember { mutableStateOf(false) } val context = LocalContext.current LaunchedEffect(olderThanDays, onlyUnknownNodes) { viewModel.getNodesToDelete() } + LaunchedEffect(Unit) { viewModel.refreshScheduleStatus() } LaunchedEffect(Unit) { viewModel.scheduleEvents.collect { result -> @@ -97,6 +104,15 @@ fun CleanNodeDatabaseScreen(viewModel: CleanNodeDatabaseViewModel = hiltViewMode } } + LaunchedEffect(Unit) { + viewModel.runNowEvents.collect { result -> + when (result) { + is RunNowResult.Success -> context.showToast(Res.string.run_cleanup_now_success) + is RunNowResult.Failure -> context.showToast(Res.string.run_cleanup_now_failed, result.reason) + } + } + } + if (showConfirmationDialog) { ConfirmationDialog( nodesToDeleteCount = nodesToDelete.size, @@ -108,18 +124,6 @@ fun CleanNodeDatabaseScreen(viewModel: CleanNodeDatabaseViewModel = hiltViewMode ) } - if (showScheduleDialog) { - ScheduleCleanupDialog( - initialDays = olderThanDays.toInt().coerceAtLeast(MIN_KNOWN_DAYS_THRESHOLD.toInt()), - initialUnknownOnly = onlyUnknownNodes, - onConfirm = { days, unknownOnly -> - viewModel.scheduleNodeCleanup(days, unknownOnly) - showScheduleDialog = false - }, - onDismiss = { showScheduleDialog = false }, - ) - } - Column(modifier = Modifier.padding(16.dp).verticalScroll(rememberScrollState())) { Text(stringResource(Res.string.clean_node_database_title)) Text(stringResource(Res.string.clean_node_database_description), style = MaterialTheme.typography.bodySmall) @@ -141,6 +145,10 @@ fun CleanNodeDatabaseScreen(viewModel: CleanNodeDatabaseViewModel = hiltViewMode Spacer(modifier = Modifier.height(16.dp)) + ScheduleStatus(status = scheduleStatus) + + Spacer(modifier = Modifier.height(16.dp)) + Button( onClick = { if (nodesToDelete.isNotEmpty()) showConfirmationDialog = true }, modifier = Modifier.fillMaxWidth(), @@ -152,7 +160,7 @@ fun CleanNodeDatabaseScreen(viewModel: CleanNodeDatabaseViewModel = hiltViewMode Spacer(modifier = Modifier.height(12.dp)) Button( - onClick = { showScheduleDialog = true }, + onClick = { viewModel.scheduleNodeCleanup(olderThanDays = olderThanDays.toInt(), onlyUnknownNodes = onlyUnknownNodes) }, modifier = Modifier.fillMaxWidth(), ) { Text(stringResource(Res.string.schedule_cleanup)) @@ -165,6 +173,13 @@ fun CleanNodeDatabaseScreen(viewModel: CleanNodeDatabaseViewModel = hiltViewMode Text(stringResource(Res.string.cancel_scheduled_cleanup)) } + TextButton( + onClick = { viewModel.runNodeCleanupNow(olderThanDays = olderThanDays.toInt(), onlyUnknownNodes = onlyUnknownNodes) }, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(Res.string.run_cleanup_now)) + } + TextButton( onClick = { viewModel.scheduleNodeCleanup(olderThanDays = 0, olderThanMinutes = 60, onlyUnknownNodes = true) }, modifier = Modifier.fillMaxWidth(), @@ -218,65 +233,42 @@ private fun DaysThresholdFilter( } @Composable -private fun ScheduleCleanupDialog( - initialDays: Int, - initialUnknownOnly: Boolean, - onConfirm: (Int, Boolean) -> Unit, - onDismiss: () -> Unit, -) { - var selectedDays by remember { mutableStateOf(initialDays.toFloat()) } - var unknownOnly by remember { mutableStateOf(initialUnknownOnly) } - - val valueRange = - if (unknownOnly) { - MIN_UNKNOWN_DAYS_THRESHOLD..MAX_DAYS_THRESHOLD - } else { - MIN_KNOWN_DAYS_THRESHOLD..MAX_DAYS_THRESHOLD - } - - LaunchedEffect(unknownOnly) { - if (!unknownOnly && selectedDays < MIN_KNOWN_DAYS_THRESHOLD) { - selectedDays = MIN_KNOWN_DAYS_THRESHOLD +private fun ScheduleStatus(status: ScheduleStatus?) { + Column(modifier = Modifier.fillMaxWidth()) { + Text(text = stringResource(Res.string.scheduled_cleanup_status), style = MaterialTheme.typography.titleMedium) + if (status == null) { + Text(text = stringResource(Res.string.scheduled_cleanup_none), style = MaterialTheme.typography.bodySmall) + return } - } - AlertDialog( - onDismissRequest = onDismiss, - title = { Text(stringResource(Res.string.schedule_cleanup_title)) }, - text = { - Column { - Text(stringResource(Res.string.schedule_cleanup_description)) - Spacer(modifier = Modifier.height(16.dp)) - DaysThresholdFilter( - olderThanDays = selectedDays, - onlyUnknownNodes = unknownOnly, - useZeroMin = true, // keep slider visual position stable while enforcing min via clamp - onDaysChanged = { - val min = if (unknownOnly) MIN_UNKNOWN_DAYS_THRESHOLD else MIN_KNOWN_DAYS_THRESHOLD - selectedDays = it.coerceAtLeast(min) - }, + val stateText = status.state?.name ?: "N/A" + val paramsText = + if (status.olderThanMinutes != null && status.olderThanMinutes > 0) { + stringResource( + Res.string.scheduled_cleanup_params, + status.olderThanMinutes, + status.onlyUnknown, ) - Spacer(modifier = Modifier.height(12.dp)) - UnknownNodesFilter( - onlyUnknownNodes = unknownOnly, - onCheckedChanged = { - unknownOnly = it - if (!it && selectedDays < MIN_KNOWN_DAYS_THRESHOLD) { - selectedDays = MIN_KNOWN_DAYS_THRESHOLD - } - }, + } else { + stringResource( + Res.string.scheduled_cleanup_params, + status.olderThanDays ?: 0, + status.onlyUnknown, ) } - }, - confirmButton = { - Button(onClick = { onConfirm(selectedDays.toInt(), unknownOnly) }) { - Text(stringResource(Res.string.schedule_cleanup)) - } - }, - dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(Res.string.cancel)) } }, - ) + val lastRunText = + status.lastRunEpochMillis?.let { formatDateTime(it) } + ?: stringResource(Res.string.scheduled_cleanup_none) + + Text(text = stringResource(Res.string.scheduled_cleanup_state, stateText), style = MaterialTheme.typography.bodySmall) + Text(text = paramsText, style = MaterialTheme.typography.bodySmall) + Text(text = stringResource(Res.string.scheduled_cleanup_last_run, lastRunText), style = MaterialTheme.typography.bodySmall) + } } +private fun formatDateTime(epochMillis: Long): String = + java.text.DateFormat.getDateTimeInstance(java.text.DateFormat.SHORT, java.text.DateFormat.SHORT).format(java.util.Date(epochMillis)) + /** * Composable for the "only unknown nodes" filter. * diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/CleanNodeDatabaseViewModel.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/CleanNodeDatabaseViewModel.kt index 362a750fe9..1041bb2e12 100644 --- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/CleanNodeDatabaseViewModel.kt +++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/CleanNodeDatabaseViewModel.kt @@ -21,6 +21,8 @@ import android.content.Context import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.ExistingWorkPolicy +import androidx.work.OneTimeWorkRequestBuilder import androidx.work.PeriodicWorkRequestBuilder import androidx.work.WorkInfo import androidx.work.WorkManager @@ -34,6 +36,7 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.update import org.meshtastic.feature.settings.worker.NodeCleanupWorker import org.meshtastic.core.data.repository.NodeRepository import org.meshtastic.core.database.entity.NodeEntity @@ -67,6 +70,12 @@ constructor( private val _scheduleEvents = MutableSharedFlow() val scheduleEvents = _scheduleEvents.asSharedFlow() + private val _scheduleStatus = MutableStateFlow(null) + val scheduleStatus = _scheduleStatus.asStateFlow() + + private val _runNowEvents = MutableSharedFlow() + val runNowEvents = _runNowEvents.asSharedFlow() + fun onOlderThanDaysChanged(value: Float) { _olderThanDays.value = value } @@ -133,6 +142,15 @@ constructor( val request = PeriodicWorkRequestBuilder(1, TimeUnit.HOURS) .setInputData(data) + .addTag("${NodeCleanupWorker.TAG_DAYS_PREFIX}$olderThanDays") + .apply { + if (olderThanMinutes != null && olderThanMinutes > 0) { + addTag("${NodeCleanupWorker.TAG_MINUTES_PREFIX}$olderThanMinutes") + } + } + .apply { + if (onlyUnknownNodes) addTag(NodeCleanupWorker.TAG_UNKNOWN) + } .build() WorkManager.getInstance(appContext) @@ -142,6 +160,7 @@ constructor( request, ) + refreshScheduleStatus() _scheduleEvents.emit(ScheduleResult.Success) } catch (e: Exception) { _scheduleEvents.emit(ScheduleResult.Failure(e.message ?: "")) @@ -160,11 +179,70 @@ constructor( } workManager.cancelUniqueWork(NodeCleanupWorker.WORK_NAME) _scheduleEvents.emit(ScheduleResult.Cancelled) + refreshScheduleStatus() } catch (e: Exception) { _scheduleEvents.emit(ScheduleResult.Failure(e.message ?: "")) } } } + + fun runNodeCleanupNow(olderThanDays: Int, onlyUnknownNodes: Boolean, olderThanMinutes: Int? = null) { + viewModelScope.launch { + try { + val data = + workDataOf( + NodeCleanupWorker.KEY_OLDER_THAN_DAYS to olderThanDays, + NodeCleanupWorker.KEY_OLDER_THAN_MINUTES to (olderThanMinutes ?: -1), + NodeCleanupWorker.KEY_ONLY_UNKNOWN to onlyUnknownNodes, + ) + val request = + OneTimeWorkRequestBuilder() + .setInputData(data) + .addTag("${NodeCleanupWorker.TAG_DAYS_PREFIX}$olderThanDays") + .apply { + if (olderThanMinutes != null && olderThanMinutes > 0) { + addTag("${NodeCleanupWorker.TAG_MINUTES_PREFIX}$olderThanMinutes") + } + } + .apply { if (onlyUnknownNodes) addTag(NodeCleanupWorker.TAG_UNKNOWN) } + .build() + + WorkManager.getInstance(appContext) + .enqueueUniqueWork( + "${NodeCleanupWorker.WORK_NAME}_now", + ExistingWorkPolicy.REPLACE, + request, + ) + _runNowEvents.emit(RunNowResult.Success) + refreshScheduleStatus() + } catch (e: Exception) { + _runNowEvents.emit(RunNowResult.Failure(e.message ?: "")) + } + } + } + + fun refreshScheduleStatus() { + viewModelScope.launch(Dispatchers.IO) { + val workManager = WorkManager.getInstance(appContext) + val info = workManager.getWorkInfosForUniqueWork(NodeCleanupWorker.WORK_NAME).get().firstOrNull() + _scheduleStatus.update { + info?.let { workInfo -> + ScheduleStatus( + state = workInfo.state, + olderThanDays = workInfo.tags.firstOrNull { it.startsWith(NodeCleanupWorker.TAG_DAYS_PREFIX) } + ?.removePrefix(NodeCleanupWorker.TAG_DAYS_PREFIX) + ?.toIntOrNull(), + olderThanMinutes = workInfo.tags.firstOrNull { it.startsWith(NodeCleanupWorker.TAG_MINUTES_PREFIX) } + ?.removePrefix(NodeCleanupWorker.TAG_MINUTES_PREFIX) + ?.toIntOrNull(), + onlyUnknown = workInfo.tags.contains(NodeCleanupWorker.TAG_UNKNOWN), + lastRunEpochMillis = workInfo.outputData.getLong(NodeCleanupWorker.KEY_LAST_RUN_EPOCH, -1) + .takeIf { it > 0 }, + ) + } + } + } + } } sealed interface ScheduleResult { @@ -173,3 +251,16 @@ sealed interface ScheduleResult { data object NoWork : ScheduleResult data class Failure(val reason: String) : ScheduleResult } + +data class ScheduleStatus( + val state: WorkInfo.State?, + val olderThanDays: Int?, + val olderThanMinutes: Int?, + val onlyUnknown: Boolean, + val lastRunEpochMillis: Long?, +) + +sealed interface RunNowResult { + data object Success : RunNowResult + data class Failure(val reason: String) : RunNowResult +}