Commit 72d67304ab38f485cb9f14605de61541dd91b023
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>
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,5 +23,7 @@ class LifelogApp : Application() { | ||
| 23 | SummaryScheduler.schedulePeriodic(this) | 23 | SummaryScheduler.schedulePeriodic(this) |
| 24 | // 相册:未处理照片的周期后台处理(人脸+VL),空闲时跑,不阻塞导入。 | 24 | // 相册:未处理照片的周期后台处理(人脸+VL),空闲时跑,不阻塞导入。 |
| 25 | com.lemon.lifelog.vision.PhotoScheduler.schedulePeriodic(this) | 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,9 +27,11 @@ import androidx.compose.material3.MaterialTheme | ||
| 27 | import androidx.compose.material3.OutlinedTextField | 27 | import androidx.compose.material3.OutlinedTextField |
| 28 | import androidx.compose.material3.Text | 28 | import androidx.compose.material3.Text |
| 29 | import androidx.compose.runtime.Composable | 29 | import androidx.compose.runtime.Composable |
| 30 | +import androidx.compose.runtime.LaunchedEffect | ||
| 30 | import androidx.compose.runtime.getValue | 31 | import androidx.compose.runtime.getValue |
| 31 | import androidx.compose.runtime.mutableStateOf | 32 | import androidx.compose.runtime.mutableStateOf |
| 32 | import androidx.compose.runtime.remember | 33 | import androidx.compose.runtime.remember |
| 34 | +import androidx.compose.runtime.rememberCoroutineScope | ||
| 33 | import androidx.compose.runtime.setValue | 35 | import androidx.compose.runtime.setValue |
| 34 | import androidx.compose.ui.Alignment | 36 | import androidx.compose.ui.Alignment |
| 35 | import androidx.compose.ui.Modifier | 37 | import androidx.compose.ui.Modifier |
| @@ -37,8 +39,10 @@ import androidx.compose.ui.unit.dp | @@ -37,8 +39,10 @@ import androidx.compose.ui.unit.dp | ||
| 37 | import com.lemon.lifelog.asr.SpeakerClusterer | 39 | import com.lemon.lifelog.asr.SpeakerClusterer |
| 38 | import com.lemon.lifelog.service.BlockBuffer | 40 | import com.lemon.lifelog.service.BlockBuffer |
| 39 | import com.lemon.lifelog.chat.ChatConfig | 41 | import com.lemon.lifelog.chat.ChatConfig |
| 42 | +import com.lemon.lifelog.storage.StorageManager | ||
| 40 | import com.lemon.lifelog.summary.SummaryScheduler | 43 | import com.lemon.lifelog.summary.SummaryScheduler |
| 41 | import com.lemon.lifelog.summary.SummaryWorker | 44 | import com.lemon.lifelog.summary.SummaryWorker |
| 45 | +import kotlinx.coroutines.launch | ||
| 42 | 46 | ||
| 43 | /** 统一配置页:纪要 / 声纹 / 本地端点 / 录音电池。改完即存 SharedPreferences,下次跑生效。 */ | 47 | /** 统一配置页:纪要 / 声纹 / 本地端点 / 录音电池。改完即存 SharedPreferences,下次跑生效。 */ |
| 44 | @OptIn(ExperimentalMaterial3Api::class) | 48 | @OptIn(ExperimentalMaterial3Api::class) |
| @@ -58,6 +62,11 @@ fun SettingsScreen(activity: Context) { | @@ -58,6 +62,11 @@ fun SettingsScreen(activity: Context) { | ||
| 58 | var blockSilenceMin by remember { | 62 | var blockSilenceMin by remember { |
| 59 | mutableStateOf(prefs.getInt(BlockBuffer.KEY_SILENCE_MIN, (BlockBuffer.SILENCE_MS / 60_000).toInt())) | 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 | val backendTargets = remember { ChatConfig.targets(activity) } | 70 | val backendTargets = remember { ChatConfig.targets(activity) } |
| 62 | 71 | ||
| 63 | var localBase by remember { mutableStateOf(ChatConfig.localBase(activity)) } | 72 | var localBase by remember { mutableStateOf(ChatConfig.localBase(activity)) } |
| @@ -146,10 +155,46 @@ fun SettingsScreen(activity: Context) { | @@ -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 | Spacer(Modifier.height(24.dp)) | 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 | @OptIn(ExperimentalMaterial3Api::class) | 198 | @OptIn(ExperimentalMaterial3Api::class) |
| 154 | @Composable | 199 | @Composable |
| 155 | private fun <T> DropdownSetting( | 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 | +} |