Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<NodeEntity> =
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<NodeEntity> =
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
}
}
}
18 changes: 18 additions & 0 deletions core/strings/src/commonMain/composeResources/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -842,6 +842,24 @@
<string name="clean_ignored_nodes">Clean up ignored nodes</string>
<string name="clean_now">Clean Now</string>
<string name="clean_node_database_confirmation">This will remove %1$d nodes from your database. This action cannot be undone.</string>
<string name="schedule_cleanup">Schedule cleanup</string>
<string name="schedule_cleanup_title">Schedule recurring cleanup</string>
<string name="schedule_cleanup_description">Run hourly to remove nodes last seen beyond the chosen days.</string>
<string name="schedule_cleanup_success">Scheduled node cleanup</string>
<string name="schedule_cleanup_failed">Failed to schedule cleanup: %1$s</string>
<string name="cancel_scheduled_cleanup_none">No scheduled cleanup to cancel</string>
<string name="debug_schedule_one_hour">Debug: schedule 1-hour unknown-node cleanup</string>
<string name="cancel_scheduled_cleanup">Cancel scheduled cleanup</string>
<string name="cancel_scheduled_cleanup_success">Cancelled scheduled cleanup</string>
<string name="cancel_scheduled_cleanup_failed">Failed to cancel cleanup: %1$s</string>
<string name="scheduled_cleanup_status">Scheduled cleanup</string>
<string name="scheduled_cleanup_none">No cleanup scheduled</string>
<string name="scheduled_cleanup_state">State: %1$s</string>
<string name="scheduled_cleanup_params">Threshold: %1$d (%2$s unknown only)</string>
<string name="scheduled_cleanup_last_run">Last run: %1$s</string>
<string name="run_cleanup_now">Run cleanup now</string>
<string name="run_cleanup_now_success">Triggered cleanup</string>
<string name="run_cleanup_now_failed">Failed to trigger cleanup: %1$s</string>

<string name="security_icon_help_green_lock">A green lock means the channel is securely encrypted with either a 128 or 256 bit AES key.</string>

Expand Down
2 changes: 2 additions & 0 deletions feature/settings/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.settings.worker

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
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" }
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()
}

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 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)
}

private val logger = Logger.withTag(WORK_NAME)
}

@EntryPoint
@InstallIn(SingletonComponent::class)
interface NodeWorkerEntryPoint {
fun nodeRepository(): NodeRepository

fun serviceRepository(): ServiceRepository
}

Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -56,8 +57,25 @@ 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_failed
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
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
Expand All @@ -68,9 +86,32 @@ 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) }
val context = LocalContext.current

LaunchedEffect(olderThanDays, onlyUnknownNodes) { viewModel.getNodesToDelete() }
LaunchedEffect(Unit) { viewModel.refreshScheduleStatus() }

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)
}
}
}

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(
Expand Down Expand Up @@ -104,13 +145,47 @@ 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(),
enabled = nodesToDelete.isNotEmpty(),
) {
Text(stringResource(Res.string.clean_now))
}

Spacer(modifier = Modifier.height(12.dp))

Button(
onClick = { viewModel.scheduleNodeCleanup(olderThanDays = olderThanDays.toInt(), onlyUnknownNodes = onlyUnknownNodes) },
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.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(),
) {
Text(stringResource(Res.string.debug_schedule_one_hour))
}
}
}

Expand All @@ -126,9 +201,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
Expand All @@ -150,6 +232,43 @@ private fun DaysThresholdFilter(olderThanDays: Float, onlyUnknownNodes: Boolean,
}
}

@Composable
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
}

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,
)
} else {
stringResource(
Res.string.scheduled_cleanup_params,
status.olderThanDays ?: 0,
status.onlyUnknown,
)
}
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.
*
Expand Down
Loading
Loading