Commit 72d67304ab38f485cb9f14605de61541dd91b023

Authored by yibo
1 parent 08689e77

feat(lifelog/storage): 存储 TTL — 超期删录音 .opus,文字永留

24/7 录音 .opus 只增不减(实测 ~32MB 攒几天),加保留期清理:
- StorageManager.prune:按天目录名删除早于 N 天的 segments/yyyy-MM-dd/(不碰 DB,
  Segment 行保留→文字永留;audioPath 仍指向已删文件,播放对缺失文件优雅 no-op;
  不动在途 blocks/)。+ audioUsageBytes 统计占用。
- StorageTtlWorker + StorageScheduler:每天一次 periodic 清理(电池不低时),LifelogApp 注册
- 设置页「存储」段:录音占用显示 + 保留时长下拉(永久/7/14/30/60/90 天)+ 立即清理按钮
- 默认「永久保留」(ttl=0 不删):自动删录音是破坏性的,由用户主动选保留期才生效

真机验证:占用读数 31.9MB 与 du 一致;UI 渲染正常。未跑真实删除(默认关闭)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/LifelogApp.kt
... ... @@ -23,5 +23,7 @@ class LifelogApp : Application() {
23 23 SummaryScheduler.schedulePeriodic(this)
24 24 // 相册:未处理照片的周期后台处理(人脸+VL),空闲时跑,不阻塞导入。
25 25 com.lemon.lifelog.vision.PhotoScheduler.schedulePeriodic(this)
  26 + // 存储 TTL:每天清理超期录音 .opus(文字永留);默认关闭,设置页选保留天数才生效。
  27 + com.lemon.lifelog.storage.StorageScheduler.schedulePeriodic(this)
26 28 }
27 29 }
... ...
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/SettingsScreen.kt
... ... @@ -27,9 +27,11 @@ import androidx.compose.material3.MaterialTheme
27 27 import androidx.compose.material3.OutlinedTextField
28 28 import androidx.compose.material3.Text
29 29 import androidx.compose.runtime.Composable
  30 +import androidx.compose.runtime.LaunchedEffect
30 31 import androidx.compose.runtime.getValue
31 32 import androidx.compose.runtime.mutableStateOf
32 33 import androidx.compose.runtime.remember
  34 +import androidx.compose.runtime.rememberCoroutineScope
33 35 import androidx.compose.runtime.setValue
34 36 import androidx.compose.ui.Alignment
35 37 import androidx.compose.ui.Modifier
... ... @@ -37,8 +39,10 @@ import androidx.compose.ui.unit.dp
37 39 import com.lemon.lifelog.asr.SpeakerClusterer
38 40 import com.lemon.lifelog.service.BlockBuffer
39 41 import com.lemon.lifelog.chat.ChatConfig
  42 +import com.lemon.lifelog.storage.StorageManager
40 43 import com.lemon.lifelog.summary.SummaryScheduler
41 44 import com.lemon.lifelog.summary.SummaryWorker
  45 +import kotlinx.coroutines.launch
42 46  
43 47 /** 统一配置页:纪要 / 声纹 / 本地端点 / 录音电池。改完即存 SharedPreferences,下次跑生效。 */
44 48 @OptIn(ExperimentalMaterial3Api::class)
... ... @@ -58,6 +62,11 @@ fun SettingsScreen(activity: Context) {
58 62 var blockSilenceMin by remember {
59 63 mutableStateOf(prefs.getInt(BlockBuffer.KEY_SILENCE_MIN, (BlockBuffer.SILENCE_MS / 60_000).toInt()))
60 64 }
  65 + var ttlDays by remember { mutableStateOf(prefs.getInt(StorageManager.KEY_TTL_DAYS, StorageManager.DEFAULT_TTL_DAYS)) }
  66 + var audioUsage by remember { mutableStateOf(-1L) } // -1 = 计算中
  67 + var pruneMsg by remember { mutableStateOf("") }
  68 + val scope = rememberCoroutineScope()
  69 + LaunchedEffect(Unit) { audioUsage = StorageManager.audioUsageBytes(activity.filesDir) }
61 70 val backendTargets = remember { ChatConfig.targets(activity) }
62 71  
63 72 var localBase by remember { mutableStateOf(ChatConfig.localBase(activity)) }
... ... @@ -146,10 +155,46 @@ fun SettingsScreen(activity: Context) {
146 155 }
147 156 }
148 157  
  158 + HorizontalDivider()
  159 + Text("存储", style = MaterialTheme.typography.titleMedium)
  160 + Text(
  161 + "录音占用:" + if (audioUsage < 0) "计算中…" else fmtBytes(audioUsage),
  162 + style = MaterialTheme.typography.bodySmall,
  163 + )
  164 + DropdownSetting("录音保留时长(超期删音频,文字永留)",
  165 + if (ttlDays <= 0) "永久保留" else "$ttlDays 天",
  166 + listOf("永久保留" to 0, "7 天" to 7, "14 天" to 14, "30 天" to 30, "60 天" to 60, "90 天" to 90)) {
  167 + ttlDays = it; prefs.edit().putInt(StorageManager.KEY_TTL_DAYS, it).apply()
  168 + }
  169 + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
  170 + Button(
  171 + enabled = ttlDays > 0,
  172 + onClick = {
  173 + pruneMsg = "清理中…"
  174 + scope.launch {
  175 + val r = StorageManager.prune(activity.filesDir, ttlDays)
  176 + audioUsage = StorageManager.audioUsageBytes(activity.filesDir)
  177 + pruneMsg = "已删 ${r.files} 个文件,释放 ${fmtBytes(r.bytes)}"
  178 + }
  179 + },
  180 + ) { Text("立即清理") }
  181 + if (pruneMsg.isNotEmpty()) Text(pruneMsg, style = MaterialTheme.typography.labelSmall)
  182 + }
  183 + Text("只删音频文件,转写文字与时间轴永久保留。每天自动清理一次。",
  184 + style = MaterialTheme.typography.labelSmall)
  185 +
149 186 Spacer(Modifier.height(24.dp))
150 187 }
151 188 }
152 189  
  190 +/** 字节数 → 人类可读(KB/MB/GB)。 */
  191 +private fun fmtBytes(b: Long): String = when {
  192 + b >= 1L shl 30 -> "%.2f GB".format(b.toDouble() / (1L shl 30))
  193 + b >= 1L shl 20 -> "%.1f MB".format(b.toDouble() / (1L shl 20))
  194 + b >= 1L shl 10 -> "%.0f KB".format(b.toDouble() / (1L shl 10))
  195 + else -> "$b B"
  196 +}
  197 +
153 198 @OptIn(ExperimentalMaterial3Api::class)
154 199 @Composable
155 200 private fun <T> DropdownSetting(
... ...
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/storage/StorageManager.kt 0 → 100644
  1 +package com.lemon.lifelog.storage
  2 +
  3 +import android.content.Context
  4 +import android.util.Log
  5 +import androidx.work.Constraints
  6 +import androidx.work.CoroutineWorker
  7 +import androidx.work.ExistingPeriodicWorkPolicy
  8 +import androidx.work.PeriodicWorkRequestBuilder
  9 +import androidx.work.WorkManager
  10 +import androidx.work.WorkerParameters
  11 +import java.io.File
  12 +import java.text.SimpleDateFormat
  13 +import java.util.Locale
  14 +import java.util.concurrent.TimeUnit
  15 +import kotlinx.coroutines.Dispatchers
  16 +import kotlinx.coroutines.withContext
  17 +
  18 +/**
  19 + * 存储 TTL:删除超过 N 天的「块完整录音」.opus,**文字(transcripts/segments 行)永留**。
  20 + *
  21 + * 录音文件按天分目录:`filesDir/segments/yyyy-MM-dd/HH-mm-ss-SSS.opus`(见 [com.lemon.lifelog.audio.SegmentWriter])。
  22 + * 清理直接按目录名的日期删除整天目录——不碰 DB([com.lemon.lifelog.data.Segment] 行保留,audioPath
  23 + * 仍指向已删文件;播放时 [com.lemon.lifelog.audio.SegmentPlayer] 对缺失文件优雅 no-op)。
  24 + * 不动 `blocks/`(在途未处理块)。
  25 + *
  26 + * 默认**关闭**(ttlDays=0):自动删录音是破坏性的,由用户在设置页主动选保留天数才生效。
  27 + */
  28 +object StorageManager {
  29 + private const val TAG = "StorageManager"
  30 + private const val DAY_MS = 24L * 60 * 60 * 1000
  31 +
  32 + const val PREFS = "lifelog_prefs"
  33 + const val KEY_TTL_DAYS = "audio_ttl_days"
  34 + const val DEFAULT_TTL_DAYS = 0 // 0 = 关闭,不删
  35 +
  36 + private val dayFmt = SimpleDateFormat("yyyy-MM-dd", Locale.US)
  37 +
  38 + data class PruneResult(val files: Int, val bytes: Long)
  39 +
  40 + fun ttlDaysFromPrefs(ctx: Context): Int =
  41 + ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getInt(KEY_TTL_DAYS, DEFAULT_TTL_DAYS)
  42 +
  43 + private fun segmentsRoot(filesDir: File) = File(filesDir, "segments")
  44 +
  45 + /** 录音目录当前占用字节数(设置页「存储用量」用)。 */
  46 + suspend fun audioUsageBytes(filesDir: File): Long = withContext(Dispatchers.IO) {
  47 + val root = segmentsRoot(filesDir)
  48 + if (!root.isDirectory) return@withContext 0L
  49 + root.walkTopDown().filter { it.isFile }.sumOf { it.length() }
  50 + }
  51 +
  52 + /**
  53 + * 删除日期早于 [ttlDays] 天的整天目录。ttlDays<=0 直接返回(关闭)。
  54 + * @return 删除的文件数与释放字节数
  55 + */
  56 + suspend fun prune(filesDir: File, ttlDays: Int): PruneResult = withContext(Dispatchers.IO) {
  57 + if (ttlDays <= 0) return@withContext PruneResult(0, 0)
  58 + val root = segmentsRoot(filesDir)
  59 + if (!root.isDirectory) return@withContext PruneResult(0, 0)
  60 + val cutoff = System.currentTimeMillis() - ttlDays.toLong() * DAY_MS
  61 + var files = 0
  62 + var bytes = 0L
  63 + root.listFiles()?.forEach { dir ->
  64 + if (!dir.isDirectory) return@forEach
  65 + // 目录名 = 录音那天(本地时区,与 SegmentWriter 一致);解析失败的目录跳过不动。
  66 + val day = runCatching { dayFmt.parse(dir.name) }.getOrNull() ?: return@forEach
  67 + if (day.time < cutoff) {
  68 + dir.walkTopDown().filter { it.isFile }.forEach { bytes += it.length(); files++ }
  69 + if (dir.deleteRecursively()) {
  70 + Log.i(TAG, "pruned day ${dir.name}")
  71 + } else {
  72 + Log.w(TAG, "failed to fully delete ${dir.name}")
  73 + }
  74 + }
  75 + }
  76 + Log.i(TAG, "prune ttl=${ttlDays}d: $files files, $bytes bytes freed")
  77 + PruneResult(files, bytes)
  78 + }
  79 +}
  80 +
  81 +/** 周期清理 worker(每天一次)。读 prefs 的保留天数,关闭则空跑。 */
  82 +class StorageTtlWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx, params) {
  83 + override suspend fun doWork(): Result {
  84 + val days = StorageManager.ttlDaysFromPrefs(applicationContext)
  85 + StorageManager.prune(applicationContext.filesDir, days)
  86 + return Result.success()
  87 + }
  88 +}
  89 +
  90 +object StorageScheduler {
  91 + private const val PERIODIC_TAG = "lifelog-storage-ttl"
  92 +
  93 + fun schedulePeriodic(ctx: Context) {
  94 + val constraints = Constraints.Builder()
  95 + .setRequiresBatteryNotLow(true)
  96 + .build()
  97 + val request = PeriodicWorkRequestBuilder<StorageTtlWorker>(1, TimeUnit.DAYS)
  98 + .setConstraints(constraints)
  99 + .build()
  100 + WorkManager.getInstance(ctx).enqueueUniquePeriodicWork(
  101 + PERIODIC_TAG,
  102 + ExistingPeriodicWorkPolicy.KEEP,
  103 + request,
  104 + )
  105 + }
  106 +}
... ...