From 6f338c4cdef6b2282deb8d74f6a48e5a9b2f592f Mon Sep 17 00:00:00 2001 From: Mac DeCourcy <49794076+mdecourcy@users.noreply.github.com> Date: Fri, 2 Jan 2026 10:14:16 -0800 Subject: [PATCH] feat: add retention period to meshLog. Defaults to 7 days, with a settings dropdown to change (#4078) --- app/build.gradle.kts | 2 + .../geeksville/mesh/MeshUtilApplication.kt | 64 ++++++++++++- .../mesh/worker/MeshLogCleanupWorker.kt | 95 +++++++++++++++++++ .../core/data/repository/MeshLogRepository.kt | 26 ++++- .../core/database/dao/MeshLogDao.kt | 3 + .../meshtastic/core/prefs/di/PrefsModule.kt | 14 +++ .../core/prefs/meshlog/MeshLogPrefs.kt | 57 +++++++++++ .../composeResources/values/strings.xml | 16 +++- .../feature/settings/SettingsScreen.kt | 1 + .../feature/settings/SettingsViewModel.kt | 20 ++++ .../feature/settings/debugging/Debug.kt | 70 +++++++++++++- .../settings/debugging/DebugViewModel.kt | 36 ++++++- 12 files changed, 396 insertions(+), 8 deletions(-) create mode 100644 app/src/main/java/com/geeksville/mesh/worker/MeshLogCleanupWorker.kt create mode 100644 core/prefs/src/main/kotlin/org/meshtastic/core/prefs/meshlog/MeshLogPrefs.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 837a8458d..103eaa501 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -231,6 +231,8 @@ dependencies { implementation(libs.streamsupport.minifuture) implementation(libs.usb.serial.android) implementation(libs.androidx.work.runtime.ktx) + implementation("androidx.hilt:hilt-work:1.2.0") + ksp("androidx.hilt:hilt-compiler:1.2.0") implementation(libs.accompanist.permissions) implementation(libs.kermit) diff --git a/app/src/main/java/com/geeksville/mesh/MeshUtilApplication.kt b/app/src/main/java/com/geeksville/mesh/MeshUtilApplication.kt index c2df57fa3..326c34e94 100644 --- a/app/src/main/java/com/geeksville/mesh/MeshUtilApplication.kt +++ b/app/src/main/java/com/geeksville/mesh/MeshUtilApplication.kt @@ -18,7 +18,15 @@ package com.geeksville.mesh import android.app.Application +import androidx.hilt.work.HiltWorkerFactory +import androidx.work.Configuration +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.ExistingWorkPolicy +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager import co.touchlab.kermit.Logger +import com.geeksville.mesh.worker.MeshLogCleanupWorker import dagger.hilt.EntryPoint import dagger.hilt.InstallIn import dagger.hilt.android.EntryPointAccessors @@ -29,6 +37,9 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import org.meshtastic.core.database.DatabaseManager import org.meshtastic.core.prefs.mesh.MeshPrefs +import org.meshtastic.core.prefs.meshlog.MeshLogPrefs +import java.util.concurrent.TimeUnit +import javax.inject.Inject /** * The main application class for Meshtastic. @@ -38,16 +49,65 @@ import org.meshtastic.core.prefs.mesh.MeshPrefs * user preferences. */ @HiltAndroidApp -class MeshUtilApplication : Application() { +class MeshUtilApplication : + Application(), + Configuration.Provider { + @Inject lateinit var workerFactory: HiltWorkerFactory + override fun onCreate() { super.onCreate() initializeMaps(this) + + // Schedule periodic MeshLog cleanup + scheduleMeshLogCleanup() + enqueueImmediateCleanupIfNeeded() + // Initialize DatabaseManager asynchronously with current device address so DAO consumers have an active DB val entryPoint = EntryPointAccessors.fromApplication(this, AppEntryPoint::class.java) CoroutineScope(Dispatchers.Default).launch { entryPoint.databaseManager().init(entryPoint.meshPrefs().deviceAddress) } } + + private fun scheduleMeshLogCleanup() { + val cleanupRequest = + PeriodicWorkRequestBuilder( + repeatInterval = 1, + repeatIntervalTimeUnit = TimeUnit.HOURS, + ) + .build() + + WorkManager.getInstance(this) + .enqueueUniquePeriodicWork( + MeshLogCleanupWorker.WORK_NAME, + ExistingPeriodicWorkPolicy.UPDATE, + cleanupRequest, + ) + } + + private fun enqueueImmediateCleanupIfNeeded() { + // Use entry point to access prefs outside of Hilt graph + val entryPoint = EntryPointAccessors.fromApplication(this, AppEntryPoint::class.java) + val meshLogPrefs = entryPoint.meshLogPrefs() + val retentionDays = meshLogPrefs.retentionDays + if (!meshLogPrefs.loggingEnabled || retentionDays == MeshLogPrefs.NEVER_CLEAR_RETENTION_DAYS) { + Logger.i { + "Skipping immediate MeshLog cleanup; " + + "loggingEnabled=${meshLogPrefs.loggingEnabled}, retention=$retentionDays" + } + return + } + Logger.i { "Enqueuing immediate MeshLog cleanup with retentionDays=$retentionDays" } + WorkManager.getInstance(this) + .enqueueUniqueWork( + "${MeshLogCleanupWorker.WORK_NAME}_immediate", + ExistingWorkPolicy.REPLACE, + OneTimeWorkRequestBuilder().build(), + ) + } + + override val workManagerConfiguration: Configuration + get() = Configuration.Builder().setWorkerFactory(workerFactory).build() } @EntryPoint @@ -56,6 +116,8 @@ interface AppEntryPoint { fun databaseManager(): DatabaseManager fun meshPrefs(): MeshPrefs + + fun meshLogPrefs(): MeshLogPrefs } fun logAssert(executeReliableWrite: Boolean) { diff --git a/app/src/main/java/com/geeksville/mesh/worker/MeshLogCleanupWorker.kt b/app/src/main/java/com/geeksville/mesh/worker/MeshLogCleanupWorker.kt new file mode 100644 index 000000000..1d23ef627 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/worker/MeshLogCleanupWorker.kt @@ -0,0 +1,95 @@ +/* + * Copyright (c) 2025 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.geeksville.mesh.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.MeshLogRepository +import org.meshtastic.core.prefs.meshlog.MeshLogPrefs + +@HiltWorker +class MeshLogCleanupWorker +@AssistedInject +constructor( + @Assisted appContext: Context, + @Assisted workerParams: WorkerParameters, + private val meshLogRepository: MeshLogRepository, + private val meshLogPrefs: MeshLogPrefs, +) : CoroutineWorker(appContext, workerParams) { + + // Fallback constructor for cases where HiltWorkerFactory is not used (e.g., some WorkManager initializations) + constructor( + appContext: Context, + workerParams: WorkerParameters, + ) : this( + appContext, + workerParams, + entryPoint(appContext).meshLogRepository(), + entryPoint(appContext).meshLogPrefs(), + ) + + @Suppress("TooGenericExceptionCaught") + override suspend fun doWork(): Result = try { + val retentionDays = meshLogPrefs.retentionDays + if (!meshLogPrefs.loggingEnabled) { + logger.i { "Skipping cleanup because mesh log storage is disabled" } + } else if (retentionDays == MeshLogPrefs.NEVER_CLEAR_RETENTION_DAYS) { + logger.i { "Skipping cleanup because retention is set to never delete" } + } else { + val retentionLabel = + if (retentionDays == MeshLogPrefs.ONE_HOUR_RETENTION_DAYS) { + "1 hour" + } else { + "$retentionDays days" + } + logger.d { "Cleaning logs older than $retentionLabel" } + meshLogRepository.deleteLogsOlderThan(retentionDays) + logger.i { "Successfully cleaned old MeshLog entries" } + } + Result.success() + } catch (e: Exception) { + logger.e(e) { "Failed to clean MeshLog entries" } + Result.failure() + } + + companion object { + const val WORK_NAME = "meshlog_cleanup_worker" + + private fun entryPoint(context: Context): WorkerEntryPoint = + EntryPointAccessors.fromApplication(context.applicationContext, WorkerEntryPoint::class.java) + } + + private val logger = Logger.withTag(WORK_NAME) +} + +@EntryPoint +@InstallIn(SingletonComponent::class) +interface WorkerEntryPoint { + fun meshLogRepository(): MeshLogRepository + + fun meshLogPrefs(): MeshLogPrefs +} diff --git a/core/data/src/main/kotlin/org/meshtastic/core/data/repository/MeshLogRepository.kt b/core/data/src/main/kotlin/org/meshtastic/core/data/repository/MeshLogRepository.kt index ad86d08bb..e15fbce2f 100644 --- a/core/data/src/main/kotlin/org/meshtastic/core/data/repository/MeshLogRepository.kt +++ b/core/data/src/main/kotlin/org/meshtastic/core/data/repository/MeshLogRepository.kt @@ -27,6 +27,7 @@ import kotlinx.coroutines.withContext import org.meshtastic.core.database.DatabaseManager import org.meshtastic.core.database.entity.MeshLog import org.meshtastic.core.di.CoroutineDispatchers +import org.meshtastic.core.prefs.meshlog.MeshLogPrefs import org.meshtastic.proto.MeshProtos import org.meshtastic.proto.MeshProtos.MeshPacket import org.meshtastic.proto.Portnums @@ -39,10 +40,16 @@ class MeshLogRepository constructor( private val dbManager: DatabaseManager, private val dispatchers: CoroutineDispatchers, + private val meshLogPrefs: MeshLogPrefs, ) { fun getAllLogs(maxItems: Int = MAX_ITEMS): Flow> = dbManager.currentDb.flatMapLatest { it.meshLogDao().getAllLogs(maxItems) }.flowOn(dispatchers.io).conflate() + fun getAllLogsUnbounded(): Flow> = dbManager.currentDb + .flatMapLatest { it.meshLogDao().getAllLogs(Int.MAX_VALUE) } + .flowOn(dispatchers.io) + .conflate() + fun getAllLogsInReceiveOrder(maxItems: Int = MAX_ITEMS): Flow> = dbManager.currentDb .flatMapLatest { it.meshLogDao().getAllLogsInReceiveOrder(maxItems) } .flowOn(dispatchers.io) @@ -134,8 +141,10 @@ constructor( .mapLatest { list -> list.firstOrNull { it.myNodeInfo != null }?.myNodeInfo } .flowOn(dispatchers.io) - suspend fun insert(log: MeshLog) = - withContext(dispatchers.io) { dbManager.currentDb.value.meshLogDao().insert(log) } + suspend fun insert(log: MeshLog) = withContext(dispatchers.io) { + if (!meshLogPrefs.loggingEnabled) return@withContext + dbManager.currentDb.value.meshLogDao().insert(log) + } suspend fun deleteAll() = withContext(dispatchers.io) { dbManager.currentDb.value.meshLogDao().deleteAll() } @@ -145,6 +154,19 @@ constructor( suspend fun deleteLogs(nodeNum: Int, portNum: Int) = withContext(dispatchers.io) { dbManager.currentDb.value.meshLogDao().deleteLogs(nodeNum, portNum) } + @Suppress("MagicNumber") + suspend fun deleteLogsOlderThan(retentionDays: Int) = withContext(dispatchers.io) { + if (retentionDays == MeshLogPrefs.NEVER_CLEAR_RETENTION_DAYS) return@withContext + + val cutoffTimestamp = + if (retentionDays == MeshLogPrefs.ONE_HOUR_RETENTION_DAYS) { + System.currentTimeMillis() - (60 * 60 * 1000L) + } else { + System.currentTimeMillis() - (retentionDays * 24 * 60 * 60 * 1000L) + } + dbManager.currentDb.value.meshLogDao().deleteOlderThan(cutoffTimestamp) + } + companion object { private const val MAX_ITEMS = 500 private const val MAX_MESH_PACKETS = 10000 diff --git a/core/database/src/main/kotlin/org/meshtastic/core/database/dao/MeshLogDao.kt b/core/database/src/main/kotlin/org/meshtastic/core/database/dao/MeshLogDao.kt index 83d7bb701..70aa407e2 100644 --- a/core/database/src/main/kotlin/org/meshtastic/core/database/dao/MeshLogDao.kt +++ b/core/database/src/main/kotlin/org/meshtastic/core/database/dao/MeshLogDao.kt @@ -56,4 +56,7 @@ interface MeshLogDao { @Query("DELETE FROM log WHERE from_num = :fromNum AND port_num = :portNum") suspend fun deleteLogs(fromNum: Int, portNum: Int) + + @Query("DELETE FROM log WHERE received_date < :cutoffTimestamp") + suspend fun deleteOlderThan(cutoffTimestamp: Long) } diff --git a/core/prefs/src/main/kotlin/org/meshtastic/core/prefs/di/PrefsModule.kt b/core/prefs/src/main/kotlin/org/meshtastic/core/prefs/di/PrefsModule.kt index 6aadabaea..e105e90b0 100644 --- a/core/prefs/src/main/kotlin/org/meshtastic/core/prefs/di/PrefsModule.kt +++ b/core/prefs/src/main/kotlin/org/meshtastic/core/prefs/di/PrefsModule.kt @@ -37,6 +37,8 @@ import org.meshtastic.core.prefs.map.MapTileProviderPrefs import org.meshtastic.core.prefs.map.MapTileProviderPrefsImpl import org.meshtastic.core.prefs.mesh.MeshPrefs import org.meshtastic.core.prefs.mesh.MeshPrefsImpl +import org.meshtastic.core.prefs.meshlog.MeshLogPrefs +import org.meshtastic.core.prefs.meshlog.MeshLogPrefsImpl import org.meshtastic.core.prefs.radio.RadioPrefs import org.meshtastic.core.prefs.radio.RadioPrefsImpl import org.meshtastic.core.prefs.ui.UiPrefs @@ -83,6 +85,10 @@ internal annotation class RadioSharedPreferences @Retention(AnnotationRetention.BINARY) internal annotation class UiSharedPreferences +@Qualifier +@Retention(AnnotationRetention.BINARY) +internal annotation class MeshLogSharedPreferences + @Suppress("TooManyFunctions") @InstallIn(SingletonComponent::class) @Module @@ -100,6 +106,8 @@ interface PrefsModule { @Binds fun bindMeshPrefs(meshPrefsImpl: MeshPrefsImpl): MeshPrefs + @Binds fun bindMeshLogPrefs(meshLogPrefsImpl: MeshLogPrefsImpl): MeshLogPrefs + @Binds fun bindRadioPrefs(radioPrefsImpl: RadioPrefsImpl): RadioPrefs @Binds fun bindUiPrefs(uiPrefsImpl: UiPrefsImpl): UiPrefs @@ -159,5 +167,11 @@ interface PrefsModule { @UiSharedPreferences fun provideUiSharedPreferences(@ApplicationContext context: Context): SharedPreferences = context.getSharedPreferences("ui-prefs", Context.MODE_PRIVATE) + + @Provides + @Singleton + @MeshLogSharedPreferences + fun provideMeshLogSharedPreferences(@ApplicationContext context: Context): SharedPreferences = + context.getSharedPreferences("meshlog-prefs", Context.MODE_PRIVATE) } } diff --git a/core/prefs/src/main/kotlin/org/meshtastic/core/prefs/meshlog/MeshLogPrefs.kt b/core/prefs/src/main/kotlin/org/meshtastic/core/prefs/meshlog/MeshLogPrefs.kt new file mode 100644 index 000000000..25475020f --- /dev/null +++ b/core/prefs/src/main/kotlin/org/meshtastic/core/prefs/meshlog/MeshLogPrefs.kt @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2025 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.meshtastic.core.prefs.meshlog + +import android.content.SharedPreferences +import org.meshtastic.core.prefs.PrefDelegate +import org.meshtastic.core.prefs.di.MeshLogSharedPreferences +import javax.inject.Inject +import javax.inject.Singleton + +interface MeshLogPrefs { + var retentionDays: Int + var loggingEnabled: Boolean + + companion object { + const val RETENTION_DAYS_KEY = "meshlog_retention_days" + const val LOGGING_ENABLED_KEY = "meshlog_logging_enabled" + const val DEFAULT_RETENTION_DAYS = 7 + const val DEFAULT_LOGGING_ENABLED = true + const val MIN_RETENTION_DAYS = -1 // -1 == keep last hour + const val MAX_RETENTION_DAYS = 365 + const val NEVER_CLEAR_RETENTION_DAYS = 0 + const val ONE_HOUR_RETENTION_DAYS = -1 + } +} + +@Singleton +class MeshLogPrefsImpl @Inject constructor(@MeshLogSharedPreferences private val prefs: SharedPreferences) : + MeshLogPrefs { + override var retentionDays: Int by + PrefDelegate( + prefs = prefs, + key = MeshLogPrefs.RETENTION_DAYS_KEY, + defaultValue = MeshLogPrefs.DEFAULT_RETENTION_DAYS, + ) + override var loggingEnabled: Boolean by + PrefDelegate( + prefs = prefs, + key = MeshLogPrefs.LOGGING_ENABLED_KEY, + defaultValue = MeshLogPrefs.DEFAULT_LOGGING_ENABLED, + ) +} diff --git a/core/strings/src/commonMain/composeResources/values/strings.xml b/core/strings/src/commonMain/composeResources/values/strings.xml index b3bfd814d..d89f5b4e0 100644 --- a/core/strings/src/commonMain/composeResources/values/strings.xml +++ b/core/strings/src/commonMain/composeResources/values/strings.xml @@ -16,7 +16,7 @@ --> - // Language tags native names (not available via .getDisplayLanguage) + Kreyòl ayisyen Português do Brasil 简体中文 @@ -226,6 +226,17 @@ Export canceled %1$d logs exported Failed to write log file: %1$s + No logs to export + + + %1$d hour + %1$d hours + + + + %1$d day + %1$d days + Filters Active filters Search in logs… @@ -515,6 +526,9 @@ Messages Device DB cache limit Max device databases to keep on this phone + MeshLog retention period + Choose how long to keep logs. Select Never to keep all logs. + Never delete logs Detection Sensor Config Detection Sensor enabled Minimum broadcast (seconds) diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt index e5c3124cd..86503ee78 100644 --- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt +++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt @@ -364,6 +364,7 @@ fun SettingsScreen( onItemSelected = { selected -> settingsViewModel.setDbCacheLimit(selected.toInt()) }, summary = stringResource(Res.string.device_db_cache_limit_summary), ) + val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date()) val nodeName = ourNode?.user?.shortName ?: "" diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/SettingsViewModel.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/SettingsViewModel.kt index c03d9afb7..2cc104a18 100644 --- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/SettingsViewModel.kt +++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/SettingsViewModel.kt @@ -48,6 +48,7 @@ import org.meshtastic.core.database.model.Node import org.meshtastic.core.datastore.UiPreferencesDataSource import org.meshtastic.core.model.Position import org.meshtastic.core.model.util.positionToMeter +import org.meshtastic.core.prefs.meshlog.MeshLogPrefs import org.meshtastic.core.prefs.radio.RadioPrefs import org.meshtastic.core.prefs.radio.isBle import org.meshtastic.core.prefs.radio.isSerial @@ -82,6 +83,7 @@ constructor( private val databaseManager: DatabaseManager, private val deviceHardwareRepository: DeviceHardwareRepository, private val radioPrefs: RadioPrefs, + private val meshLogPrefs: MeshLogPrefs, ) : ViewModel() { val myNodeInfo: StateFlow = nodeRepository.myNodeInfo @@ -140,6 +142,24 @@ constructor( databaseManager.setCacheLimit(clamped) } + // MeshLog retention period (bounded by MeshLogPrefsImpl constants) + private val _meshLogRetentionDays = MutableStateFlow(meshLogPrefs.retentionDays) + val meshLogRetentionDays: StateFlow = _meshLogRetentionDays.asStateFlow() + + private val _meshLogLoggingEnabled = MutableStateFlow(meshLogPrefs.loggingEnabled) + val meshLogLoggingEnabled: StateFlow = _meshLogLoggingEnabled.asStateFlow() + + fun setMeshLogRetentionDays(days: Int) { + val clamped = days.coerceIn(MeshLogPrefs.MIN_RETENTION_DAYS, MeshLogPrefs.MAX_RETENTION_DAYS) + meshLogPrefs.retentionDays = clamped + _meshLogRetentionDays.value = clamped + } + + fun setMeshLogLoggingEnabled(enabled: Boolean) { + meshLogPrefs.loggingEnabled = enabled + _meshLogLoggingEnabled.value = enabled + } + fun setProvideLocation(value: Boolean) { myNodeNum?.let { uiPrefs.setShouldProvideNodeLocation(it, value) } } diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/debugging/Debug.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/debugging/Debug.kt index 509041c9d..aefd7d768 100644 --- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/debugging/Debug.kt +++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/debugging/Debug.kt @@ -23,6 +23,7 @@ import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -38,6 +39,7 @@ import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.outlined.FileDownload +import androidx.compose.material.icons.rounded.Settings import androidx.compose.material.icons.twotone.FilterAltOff import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults @@ -80,6 +82,7 @@ import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import org.jetbrains.compose.resources.pluralStringResource import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.strings.Res import org.meshtastic.core.strings.debug_clear @@ -90,9 +93,16 @@ import org.meshtastic.core.strings.debug_export_success import org.meshtastic.core.strings.debug_filters import org.meshtastic.core.strings.debug_logs_export import org.meshtastic.core.strings.debug_panel +import org.meshtastic.core.strings.log_retention_days +import org.meshtastic.core.strings.log_retention_days_quantity +import org.meshtastic.core.strings.log_retention_days_summary +import org.meshtastic.core.strings.log_retention_hours +import org.meshtastic.core.strings.log_retention_never import org.meshtastic.core.ui.component.CopyIconButton +import org.meshtastic.core.ui.component.DropDownPreference import org.meshtastic.core.ui.component.MainAppBar import org.meshtastic.core.ui.component.SimpleAlertDialog +import org.meshtastic.core.ui.component.SwitchPreference import org.meshtastic.core.ui.theme.AnnotationColor import org.meshtastic.core.ui.theme.AppTheme import org.meshtastic.core.ui.util.showToast @@ -148,10 +158,12 @@ fun DebugScreen(onNavigateUp: () -> Unit, viewModel: DebugViewModel = hiltViewMo val exportLogsLauncher = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("text/plain")) { createdUri -> if (createdUri != null) { - scope.launch { exportAllLogsToUri(context, createdUri, filteredLogs) } + scope.launch { exportAllLogsToUri(context, createdUri, viewModel.loadLogsForExport()) } } } + var showSettings by remember { mutableStateOf(false) } + Scaffold( topBar = { MainAppBar( @@ -160,7 +172,12 @@ fun DebugScreen(onNavigateUp: () -> Unit, viewModel: DebugViewModel = hiltViewMo showNodeChip = false, canNavigateUp = true, onNavigateUp = onNavigateUp, - actions = { DebugMenuActions(deleteLogs = { viewModel.deleteAllLogs() }) }, + actions = { + IconButton(onClick = { showSettings = !showSettings }) { + Icon(imageVector = Icons.Rounded.Settings, contentDescription = null) + } + DebugMenuActions(deleteLogs = { viewModel.deleteAllLogs() }) + }, onClickChip = {}, ) }, @@ -187,6 +204,9 @@ fun DebugScreen(onNavigateUp: () -> Unit, viewModel: DebugViewModel = hiltViewMo exportLogsLauncher.launch(fileName) }, ) + if (showSettings) { + DebugLogSettings(viewModel = viewModel) + } } items(filteredLogs, key = { it.uuid }) { log -> DebugItem( @@ -202,6 +222,44 @@ fun DebugScreen(onNavigateUp: () -> Unit, viewModel: DebugViewModel = hiltViewMo } } +@Composable +private fun DebugLogSettings(viewModel: DebugViewModel) { + val retentionDays = viewModel.retentionDays.collectAsStateWithLifecycle().value + val loggingEnabled = viewModel.loggingEnabled.collectAsStateWithLifecycle().value + + Column( + modifier = + Modifier.fillMaxWidth() + .background(MaterialTheme.colorScheme.surface) + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + @Suppress("MagicNumber") + val retentionItems = + listOf((-1L) to pluralStringResource(Res.plurals.log_retention_hours, 1, 1)) + + listOf(1, 3, 7, 14, 30, 60, 90, 180, 365).map { days -> + days.toLong() to pluralStringResource(Res.plurals.log_retention_days_quantity, days, days) + } + + listOf(0L to stringResource(Res.string.log_retention_never)) + DropDownPreference( + title = stringResource(Res.string.log_retention_days), + enabled = loggingEnabled, + items = retentionItems, + selectedItem = retentionDays.toLong(), + onItemSelected = { selected: Long -> viewModel.setRetentionDays(selected.toInt()) }, + summary = stringResource(Res.string.log_retention_days_summary), + ) + + SwitchPreference( + title = "Store mesh logs", + enabled = true, + checked = loggingEnabled, + onCheckedChange = { viewModel.setLoggingEnabled(it) }, + summary = "Disable to skip writing mesh logs to disk", + ) + } +} + @Composable internal fun DebugItem( log: UiMeshLog, @@ -374,6 +432,12 @@ fun DebugMenuActions(deleteLogs: () -> Unit, modifier: Modifier = Modifier) { private suspend fun exportAllLogsToUri(context: Context, targetUri: Uri, logs: List) = withContext(Dispatchers.IO) { try { + if (logs.isEmpty()) { + withContext(Dispatchers.Main) { context.showToast(Res.string.debug_export_failed, "No logs to export") } + Logger.w { "MeshLog export aborted: no logs available" } + return@withContext + } + context.contentResolver.openOutputStream(targetUri)?.use { os -> OutputStreamWriter(os, StandardCharsets.UTF_8).use { writer -> logs.forEach { log -> @@ -408,7 +472,7 @@ private suspend fun exportAllLogsToUri(context: Context, targetUri: Uri, logs: L withContext(Dispatchers.Main) { context.showToast(Res.string.debug_export_success, logs.size) } } catch (e: IOException) { withContext(Dispatchers.Main) { context.showToast(Res.string.debug_export_failed, e.message ?: "") } - Logger.w(e) { "Error:IOException" } + Logger.w(e) { "MeshLog export failed" } } } diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/debugging/DebugViewModel.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/debugging/DebugViewModel.kt index 48050b229..b2b3f1c69 100644 --- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/debugging/DebugViewModel.kt +++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/debugging/DebugViewModel.kt @@ -31,13 +31,17 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import org.meshtastic.core.data.repository.MeshLogRepository import org.meshtastic.core.data.repository.NodeRepository import org.meshtastic.core.database.entity.MeshLog import org.meshtastic.core.database.entity.Packet import org.meshtastic.core.model.getTracerouteResponse +import org.meshtastic.core.prefs.meshlog.MeshLogPrefs import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed import org.meshtastic.proto.AdminProtos import org.meshtastic.proto.MeshProtos @@ -204,10 +208,20 @@ class DebugViewModel constructor( private val meshLogRepository: MeshLogRepository, private val nodeRepository: NodeRepository, + private val meshLogPrefs: MeshLogPrefs, ) : ViewModel() { val meshLog: StateFlow> = - meshLogRepository.getAllLogs().map(::toUiState).stateInWhileSubscribed(initialValue = persistentListOf()) + meshLogRepository + .getAllLogs() + .mapLatest { logs -> withContext(Dispatchers.Default) { toUiState(logs) } } + .stateInWhileSubscribed(initialValue = persistentListOf()) + + private val _retentionDays = MutableStateFlow(meshLogPrefs.retentionDays) + val retentionDays: StateFlow = _retentionDays.asStateFlow() + + private val _loggingEnabled = MutableStateFlow(meshLogPrefs.loggingEnabled) + val loggingEnabled: StateFlow = _loggingEnabled.asStateFlow() // --- Managers --- val searchManager = LogSearchManager() @@ -236,6 +250,26 @@ constructor( searchManager.updateMatches(searchManager.searchText.value, logs) } + fun setRetentionDays(days: Int) { + val clamped = days.coerceIn(MeshLogPrefs.MIN_RETENTION_DAYS, MeshLogPrefs.MAX_RETENTION_DAYS) + meshLogPrefs.retentionDays = clamped + _retentionDays.value = clamped + } + + fun setLoggingEnabled(enabled: Boolean) { + meshLogPrefs.loggingEnabled = enabled + _loggingEnabled.value = enabled + if (!enabled) { + viewModelScope.launch { meshLogRepository.deleteAll() } + } + } + + suspend fun loadLogsForExport(): ImmutableList = withContext(Dispatchers.IO) { + val unbounded = meshLogRepository.getAllLogsUnbounded().first() + val logs = if (unbounded.isEmpty()) meshLogRepository.getAllLogs().first() else unbounded + toUiState(logs) + } + init { Logger.d { "DebugViewModel created" } viewModelScope.launch {