From 72d67304ab38f485cb9f14605de61541dd91b023 Mon Sep 17 00:00:00 2001 From: yibo Date: Mon, 8 Jun 2026 20:22:59 +0800 Subject: [PATCH] feat(lifelog/storage): 存储 TTL — 超期删录音 .opus,文字永留 --- lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/LifelogApp.kt | 2 ++ lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/SettingsScreen.kt | 45 +++++++++++++++++++++++++++++++++++++++++++++ lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/storage/StorageManager.kt | 106 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 153 insertions(+), 0 deletions(-) create mode 100644 lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/storage/StorageManager.kt diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/LifelogApp.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/LifelogApp.kt index b322759..2791275 100644 --- a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/LifelogApp.kt +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/LifelogApp.kt @@ -23,5 +23,7 @@ class LifelogApp : Application() { SummaryScheduler.schedulePeriodic(this) // 相册:未处理照片的周期后台处理(人脸+VL),空闲时跑,不阻塞导入。 com.lemon.lifelog.vision.PhotoScheduler.schedulePeriodic(this) + // 存储 TTL:每天清理超期录音 .opus(文字永留);默认关闭,设置页选保留天数才生效。 + com.lemon.lifelog.storage.StorageScheduler.schedulePeriodic(this) } } diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/SettingsScreen.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/SettingsScreen.kt index fd5c839..4f4d120 100644 --- a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/SettingsScreen.kt +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/SettingsScreen.kt @@ -27,9 +27,11 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -37,8 +39,10 @@ import androidx.compose.ui.unit.dp import com.lemon.lifelog.asr.SpeakerClusterer import com.lemon.lifelog.service.BlockBuffer import com.lemon.lifelog.chat.ChatConfig +import com.lemon.lifelog.storage.StorageManager import com.lemon.lifelog.summary.SummaryScheduler import com.lemon.lifelog.summary.SummaryWorker +import kotlinx.coroutines.launch /** 统一配置页:纪要 / 声纹 / 本地端点 / 录音电池。改完即存 SharedPreferences,下次跑生效。 */ @OptIn(ExperimentalMaterial3Api::class) @@ -58,6 +62,11 @@ fun SettingsScreen(activity: Context) { var blockSilenceMin by remember { mutableStateOf(prefs.getInt(BlockBuffer.KEY_SILENCE_MIN, (BlockBuffer.SILENCE_MS / 60_000).toInt())) } + var ttlDays by remember { mutableStateOf(prefs.getInt(StorageManager.KEY_TTL_DAYS, StorageManager.DEFAULT_TTL_DAYS)) } + var audioUsage by remember { mutableStateOf(-1L) } // -1 = 计算中 + var pruneMsg by remember { mutableStateOf("") } + val scope = rememberCoroutineScope() + LaunchedEffect(Unit) { audioUsage = StorageManager.audioUsageBytes(activity.filesDir) } val backendTargets = remember { ChatConfig.targets(activity) } var localBase by remember { mutableStateOf(ChatConfig.localBase(activity)) } @@ -146,10 +155,46 @@ fun SettingsScreen(activity: Context) { } } + HorizontalDivider() + Text("存储", style = MaterialTheme.typography.titleMedium) + Text( + "录音占用:" + if (audioUsage < 0) "计算中…" else fmtBytes(audioUsage), + style = MaterialTheme.typography.bodySmall, + ) + DropdownSetting("录音保留时长(超期删音频,文字永留)", + if (ttlDays <= 0) "永久保留" else "$ttlDays 天", + listOf("永久保留" to 0, "7 天" to 7, "14 天" to 14, "30 天" to 30, "60 天" to 60, "90 天" to 90)) { + ttlDays = it; prefs.edit().putInt(StorageManager.KEY_TTL_DAYS, it).apply() + } + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button( + enabled = ttlDays > 0, + onClick = { + pruneMsg = "清理中…" + scope.launch { + val r = StorageManager.prune(activity.filesDir, ttlDays) + audioUsage = StorageManager.audioUsageBytes(activity.filesDir) + pruneMsg = "已删 ${r.files} 个文件,释放 ${fmtBytes(r.bytes)}" + } + }, + ) { Text("立即清理") } + if (pruneMsg.isNotEmpty()) Text(pruneMsg, style = MaterialTheme.typography.labelSmall) + } + Text("只删音频文件,转写文字与时间轴永久保留。每天自动清理一次。", + style = MaterialTheme.typography.labelSmall) + Spacer(Modifier.height(24.dp)) } } +/** 字节数 → 人类可读(KB/MB/GB)。 */ +private fun fmtBytes(b: Long): String = when { + b >= 1L shl 30 -> "%.2f GB".format(b.toDouble() / (1L shl 30)) + b >= 1L shl 20 -> "%.1f MB".format(b.toDouble() / (1L shl 20)) + b >= 1L shl 10 -> "%.0f KB".format(b.toDouble() / (1L shl 10)) + else -> "$b B" +} + @OptIn(ExperimentalMaterial3Api::class) @Composable private fun DropdownSetting( diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/storage/StorageManager.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/storage/StorageManager.kt new file mode 100644 index 0000000..833d1ad --- /dev/null +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/storage/StorageManager.kt @@ -0,0 +1,106 @@ +package com.lemon.lifelog.storage + +import android.content.Context +import android.util.Log +import androidx.work.Constraints +import androidx.work.CoroutineWorker +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import java.io.File +import java.text.SimpleDateFormat +import java.util.Locale +import java.util.concurrent.TimeUnit +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** + * 存储 TTL:删除超过 N 天的「块完整录音」.opus,**文字(transcripts/segments 行)永留**。 + * + * 录音文件按天分目录:`filesDir/segments/yyyy-MM-dd/HH-mm-ss-SSS.opus`(见 [com.lemon.lifelog.audio.SegmentWriter])。 + * 清理直接按目录名的日期删除整天目录——不碰 DB([com.lemon.lifelog.data.Segment] 行保留,audioPath + * 仍指向已删文件;播放时 [com.lemon.lifelog.audio.SegmentPlayer] 对缺失文件优雅 no-op)。 + * 不动 `blocks/`(在途未处理块)。 + * + * 默认**关闭**(ttlDays=0):自动删录音是破坏性的,由用户在设置页主动选保留天数才生效。 + */ +object StorageManager { + private const val TAG = "StorageManager" + private const val DAY_MS = 24L * 60 * 60 * 1000 + + const val PREFS = "lifelog_prefs" + const val KEY_TTL_DAYS = "audio_ttl_days" + const val DEFAULT_TTL_DAYS = 0 // 0 = 关闭,不删 + + private val dayFmt = SimpleDateFormat("yyyy-MM-dd", Locale.US) + + data class PruneResult(val files: Int, val bytes: Long) + + fun ttlDaysFromPrefs(ctx: Context): Int = + ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getInt(KEY_TTL_DAYS, DEFAULT_TTL_DAYS) + + private fun segmentsRoot(filesDir: File) = File(filesDir, "segments") + + /** 录音目录当前占用字节数(设置页「存储用量」用)。 */ + suspend fun audioUsageBytes(filesDir: File): Long = withContext(Dispatchers.IO) { + val root = segmentsRoot(filesDir) + if (!root.isDirectory) return@withContext 0L + root.walkTopDown().filter { it.isFile }.sumOf { it.length() } + } + + /** + * 删除日期早于 [ttlDays] 天的整天目录。ttlDays<=0 直接返回(关闭)。 + * @return 删除的文件数与释放字节数 + */ + suspend fun prune(filesDir: File, ttlDays: Int): PruneResult = withContext(Dispatchers.IO) { + if (ttlDays <= 0) return@withContext PruneResult(0, 0) + val root = segmentsRoot(filesDir) + if (!root.isDirectory) return@withContext PruneResult(0, 0) + val cutoff = System.currentTimeMillis() - ttlDays.toLong() * DAY_MS + var files = 0 + var bytes = 0L + root.listFiles()?.forEach { dir -> + if (!dir.isDirectory) return@forEach + // 目录名 = 录音那天(本地时区,与 SegmentWriter 一致);解析失败的目录跳过不动。 + val day = runCatching { dayFmt.parse(dir.name) }.getOrNull() ?: return@forEach + if (day.time < cutoff) { + dir.walkTopDown().filter { it.isFile }.forEach { bytes += it.length(); files++ } + if (dir.deleteRecursively()) { + Log.i(TAG, "pruned day ${dir.name}") + } else { + Log.w(TAG, "failed to fully delete ${dir.name}") + } + } + } + Log.i(TAG, "prune ttl=${ttlDays}d: $files files, $bytes bytes freed") + PruneResult(files, bytes) + } +} + +/** 周期清理 worker(每天一次)。读 prefs 的保留天数,关闭则空跑。 */ +class StorageTtlWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx, params) { + override suspend fun doWork(): Result { + val days = StorageManager.ttlDaysFromPrefs(applicationContext) + StorageManager.prune(applicationContext.filesDir, days) + return Result.success() + } +} + +object StorageScheduler { + private const val PERIODIC_TAG = "lifelog-storage-ttl" + + fun schedulePeriodic(ctx: Context) { + val constraints = Constraints.Builder() + .setRequiresBatteryNotLow(true) + .build() + val request = PeriodicWorkRequestBuilder(1, TimeUnit.DAYS) + .setConstraints(constraints) + .build() + WorkManager.getInstance(ctx).enqueueUniquePeriodicWork( + PERIODIC_TAG, + ExistingPeriodicWorkPolicy.KEEP, + request, + ) + } +} -- libgit2 0.21.4