Commit 8f611bff06573ba1ea31d883d393eb94b42cbd3e
1 parent
9beb325a
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>
Showing
3 changed files
with
153 additions
and
0 deletions
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 | +} | ... | ... |