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 03de04c96f..457e39868f 100644 --- a/core/strings/src/commonMain/composeResources/values/strings.xml +++ b/core/strings/src/commonMain/composeResources/values/strings.xml @@ -842,6 +842,24 @@ 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 + 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/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..0618d77709 --- /dev/null +++ b/feature/settings/src/main/java/org/meshtastic/feature/settings/worker/NodeCleanupWorker.kt @@ -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 . + */ +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 +} + 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..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 @@ -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 @@ -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 @@ -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( @@ -104,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(), @@ -111,6 +156,36 @@ fun CleanNodeDatabaseScreen(viewModel: CleanNodeDatabaseViewModel = hiltViewMode ) { 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)) + } } } @@ -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 @@ -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. * 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..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 @@ -17,18 +17,32 @@ 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.ExistingWorkPolicy +import androidx.work.OneTimeWorkRequestBuilder +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 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 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 +56,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 +67,15 @@ constructor( private val _nodesToDelete = MutableStateFlow>(emptyList()) val nodesToDelete = _nodesToDelete.asStateFlow() + 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 } @@ -73,30 +97,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 +126,141 @@ 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) + .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) + .enqueueUniquePeriodicWork( + NodeCleanupWorker.WORK_NAME, + ExistingPeriodicWorkPolicy.UPDATE, + request, + ) + + refreshScheduleStatus() + _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) + 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 { + data object Success : ScheduleResult + data object Cancelled : 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 }