Commit 0f73025393c2bcfbb03abc5eaec05f17727f6246

Authored by yibo
1 parent 08de2824

feat(lifelog): 定时不录(quiet hours)— 设定时段自动暂停采集、到点恢复

- QuietHours:可配时段(跨午夜支持,如 23:00→07:00),存 lifelog_prefs
- RecorderService 重构为 reconcile():采集 = 非手动暂停 且 非静音时段;每 30s 调和,
  到点自动停/起。手动暂停彻底释放 wakelock;静音时段保留 wakelock 供定时器到点恢复。
  统一了手动暂停/恢复与定时不录两条路径(capturing 单一真相 + statusText 去重通知)。
- RecorderState 加 isQuiet;首页状态显示「🌙 静音时段(自动)」、秒表冻结
- 设置页「录音/电池」段加 定时不录 开关 + 起止时刻(整点);30s 内生效,无需重启服务

真机验证:窗口外正常录音(reconcile→录音中,回归通过);窗口内自动转「静音时段」停采集、
秒表冻结;关窗口后恢复。测试用窗口与服务状态已还原。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/MainActivity.kt
... ... @@ -250,6 +250,7 @@ private fun RecordingScreen(
250 250 private fun HomeTab(activity: Context, repo: Repository, onOpenTimeline: () -> Unit, onOpenBlock: (Long) -> Unit) {
251 251 val running by RecorderState.isRunning.collectAsState()
252 252 val paused by RecorderState.isPaused.collectAsState()
  253 + val quiet by RecorderState.isQuiet.collectAsState()
253 254 val status by RecorderState.status.collectAsState()
254 255 val segmentCount by RecorderState.segmentCount.collectAsState()
255 256 val lastAudioMs by RecorderState.lastAudioMs.collectAsState()
... ... @@ -300,16 +301,19 @@ private fun HomeTab(activity: Context, repo: Repository, onOpenTimeline: () -&gt; U
300 301 val stateLabel = when {
301 302 !running -> "○ 已停止"
302 303 paused -> "⏸ 已暂停(模型常驻)"
  304 + quiet -> "🌙 静音时段(自动)"
303 305 else -> "● 录音中"
304 306 }
305   - Text("$stateLabel $status")
  307 + // 稳态文案已在 label 里体现,status 只在加载/下载等阶段额外展示。
  308 + val showStatus = status.isNotBlank() && status !in setOf("录音中", "已暂停", "静音时段")
  309 + Text(stateLabel + if (showStatus) " $status" else "")
306 310  
307 311 // 已录制秒表:录音中显示当前这段连续采集的累计时长。
308 312 if (running) {
309 313 Text(
310 314 "⏱ 已录制 ${fmtClock(elapsedMs)}",
311 315 style = MaterialTheme.typography.headlineSmall,
312   - color = if (paused) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.primary,
  316 + color = if (paused || quiet) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.primary,
313 317 )
314 318 }
315 319  
... ...
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/SettingsScreen.kt
... ... @@ -38,6 +38,7 @@ import androidx.compose.ui.Modifier
38 38 import androidx.compose.ui.unit.dp
39 39 import com.lemon.lifelog.asr.SpeakerClusterer
40 40 import com.lemon.lifelog.service.BlockBuffer
  41 +import com.lemon.lifelog.service.QuietHours
41 42 import com.lemon.lifelog.chat.ChatConfig
42 43 import com.lemon.lifelog.storage.StorageManager
43 44 import com.lemon.lifelog.summary.SummaryScheduler
... ... @@ -62,6 +63,9 @@ fun SettingsScreen(activity: Context) {
62 63 var blockSilenceMin by remember {
63 64 mutableStateOf(prefs.getInt(BlockBuffer.KEY_SILENCE_MIN, (BlockBuffer.SILENCE_MS / 60_000).toInt()))
64 65 }
  66 + var quietEnabled by remember { mutableStateOf(QuietHours.enabled(activity)) }
  67 + var quietStartMin by remember { mutableStateOf(QuietHours.startMin(activity)) }
  68 + var quietEndMin by remember { mutableStateOf(QuietHours.endMin(activity)) }
65 69 var ttlDays by remember { mutableStateOf(prefs.getInt(StorageManager.KEY_TTL_DAYS, StorageManager.DEFAULT_TTL_DAYS)) }
66 70 var audioUsage by remember { mutableStateOf(-1L) } // -1 = 计算中
67 71 var pruneMsg by remember { mutableStateOf("") }
... ... @@ -144,6 +148,22 @@ fun SettingsScreen(activity: Context) {
144 148 blockSilenceMin = it; prefs.edit().putInt(BlockBuffer.KEY_SILENCE_MIN, it).apply()
145 149 }
146 150 Text("改完下次录音服务启动生效(停止再开始 / 重启 app)。", style = MaterialTheme.typography.labelSmall)
  151 + DropdownSetting("定时不录(此时段自动暂停)", if (quietEnabled) "已启用" else "关闭",
  152 + listOf("关闭" to false, "启用" to true)) {
  153 + quietEnabled = it; prefs.edit().putBoolean(QuietHours.KEY_ENABLED, it).apply()
  154 + }
  155 + if (quietEnabled) {
  156 + DropdownSetting("起始时刻", "%02d:00".format(quietStartMin / 60),
  157 + (0..23).map { "%02d:00".format(it) to it * 60 }) {
  158 + quietStartMin = it; prefs.edit().putInt(QuietHours.KEY_START_MIN, it).apply()
  159 + }
  160 + DropdownSetting("结束时刻", "%02d:00".format(quietEndMin / 60),
  161 + (0..23).map { "%02d:00".format(it) to it * 60 }) {
  162 + quietEndMin = it; prefs.edit().putInt(QuietHours.KEY_END_MIN, it).apply()
  163 + }
  164 + Text("此时段自动暂停采集、到点恢复;跨午夜支持(如 23:00→07:00)。30 秒内生效。",
  165 + style = MaterialTheme.typography.labelSmall)
  166 + }
147 167 Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
148 168 Text(if (batteryExempt) "✓ 已加入电池白名单" else "⚠ 未加入电池白名单(Doze 会杀后台)",
149 169 style = MaterialTheme.typography.bodySmall)
... ...
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/service/QuietHours.kt 0 → 100644
  1 +package com.lemon.lifelog.service
  2 +
  3 +import android.content.Context
  4 +import java.util.Calendar
  5 +
  6 +/**
  7 + * 定时不录(quiet hours):用户配置一个时段,录音服务在此时段自动暂停采集、到点自动恢复。
  8 + * 跨午夜支持(如 23:00→07:00)。配置存 lifelog_prefs,[RecorderService] 每 30s reconcile 时读。
  9 + *
  10 + * 时刻用「当天分钟数」(0..1439)表示。start==end 视为空窗(永不静音)。
  11 + */
  12 +object QuietHours {
  13 + const val PREFS = "lifelog_prefs"
  14 + const val KEY_ENABLED = "quiet_enabled"
  15 + const val KEY_START_MIN = "quiet_start_min"
  16 + const val KEY_END_MIN = "quiet_end_min"
  17 +
  18 + const val DEFAULT_START_MIN = 23 * 60 // 23:00
  19 + const val DEFAULT_END_MIN = 7 * 60 // 07:00
  20 +
  21 + fun enabled(ctx: Context): Boolean =
  22 + ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getBoolean(KEY_ENABLED, false)
  23 +
  24 + fun startMin(ctx: Context): Int =
  25 + ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getInt(KEY_START_MIN, DEFAULT_START_MIN)
  26 +
  27 + fun endMin(ctx: Context): Int =
  28 + ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getInt(KEY_END_MIN, DEFAULT_END_MIN)
  29 +
  30 + /** 当前是否处于「不录」时段。未启用或空窗恒 false。 */
  31 + fun isQuietNow(ctx: Context): Boolean {
  32 + if (!enabled(ctx)) return false
  33 + val start = startMin(ctx)
  34 + val end = endMin(ctx)
  35 + if (start == end) return false
  36 + val cal = Calendar.getInstance()
  37 + val now = cal.get(Calendar.HOUR_OF_DAY) * 60 + cal.get(Calendar.MINUTE)
  38 + return if (start < end) now in start until end else now >= start || now < end
  39 + }
  40 +}
... ...
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/service/RecorderService.kt
... ... @@ -29,7 +29,9 @@ import kotlinx.coroutines.Job
29 29 import kotlinx.coroutines.SupervisorJob
30 30 import kotlinx.coroutines.cancel
31 31 import kotlinx.coroutines.channels.Channel
  32 +import kotlinx.coroutines.delay
32 33 import kotlinx.coroutines.flow.collect
  34 +import kotlinx.coroutines.isActive
33 35 import kotlinx.coroutines.launch
34 36  
35 37 class RecorderService : Service() {
... ... @@ -58,6 +60,12 @@ class RecorderService : Service() {
58 60 @Volatile
59 61 private var paused = false
60 62  
  63 + /** 实际是否在采集(= 非手动暂停 且 非静音时段)。由 reconcile() 维护。 */
  64 + private var capturing = false
  65 +
  66 + /** 当前展示状态文案,去重避免每 30s reconcile 重发通知。 */
  67 + private var statusText = ""
  68 +
61 69 private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
62 70 private var pipelineJob: Job? = null
63 71 private var wakeLock: PowerManager.WakeLock? = null
... ... @@ -105,25 +113,56 @@ class RecorderService : Service() {
105 113 private fun doPause() {
106 114 if (paused) return
107 115 paused = true
108   - capture.stop()
109   - wakeLock?.takeIf { it.isHeld }?.release()
110   - RecorderState.onCapturePaused()
111   - RecorderState.setPaused(true)
112   - RecorderState.setStatus("已暂停")
113   - notify("已暂停")
  116 + reconcile()
114 117 Log.i(TAG, "paused (models kept resident)")
115 118 }
116 119  
117 120 private fun doResume() {
118 121 if (!paused) return
119 122 paused = false
120   - acquireWakeLock()
  123 + reconcile()
  124 + Log.i(TAG, "resumed")
  125 + }
  126 +
  127 + private fun startCapture() {
  128 + if (capturing) return
121 129 capture.start(scope)
122 130 RecorderState.onCapturing()
123   - RecorderState.setPaused(false)
124   - RecorderState.setStatus("录音中")
125   - notify("录音中")
126   - Log.i(TAG, "resumed")
  131 + capturing = true
  132 + }
  133 +
  134 + private fun stopCapture() {
  135 + if (!capturing) return
  136 + capture.stop()
  137 + RecorderState.onCapturePaused()
  138 + capturing = false
  139 + }
  140 +
  141 + /**
  142 + * 调和实际采集状态:采集 = 非手动暂停 且 非「定时不录」时段。手动暂停彻底释放 wakelock;
  143 + * 定时不录时保留 wakelock,让 30s 定时器到点能自动恢复。每 30s 及暂停/恢复时调用。
  144 + */
  145 + private fun reconcile() {
  146 + val quiet = QuietHours.isQuietNow(this)
  147 + val want = !paused && !quiet
  148 + if (want != capturing) {
  149 + if (want) {
  150 + acquireWakeLock()
  151 + startCapture()
  152 + } else {
  153 + stopCapture()
  154 + if (paused) wakeLock?.takeIf { it.isHeld }?.release() else acquireWakeLock()
  155 + }
  156 + }
  157 + RecorderState.setPaused(paused)
  158 + RecorderState.setQuiet(quiet)
  159 + val st = if (paused) "已暂停" else if (quiet) "静音时段" else "录音中"
  160 + if (st != statusText) {
  161 + statusText = st
  162 + RecorderState.setStatus(st)
  163 + notify(if (quiet && !paused) "静音时段(到点自动恢复)" else st)
  164 + Log.i(TAG, "reconcile → $st")
  165 + }
127 166 }
128 167  
129 168 private fun notify(text: String) {
... ... @@ -253,17 +292,20 @@ class RecorderService : Service() {
253 292 } catch (t: Throwable) {
254 293 Log.w(TAG, "block ${dir.name} process failed", t)
255 294 }
256   - RecorderState.setStatus(if (paused) "已暂停" else "录音中")
  295 + RecorderState.setStatus(statusText.ifBlank { if (paused) "已暂停" else "录音中" })
257 296 }
258 297 }
259 298 // 崩溃恢复:把上次没处理完的块重新入队。
260 299 BlockBuffer.listPending(File(filesDir, "blocks")).forEach { pendingBlocks.trySend(it) }
261 300  
262   - // 3. Start capturing (unless paused arrived during model load).
263   - if (!paused) {
264   - capture.start(scope)
265   - RecorderState.onCapturing()
266   - RecorderState.setStatus("录音中")
  301 + // 3. Start capturing — 据手动暂停 / 定时不录时段决定(reconcile)。
  302 + reconcile()
  303 + // 定时不录:每 30s 调和一次,到点自动停/起采集。
  304 + scope.launch {
  305 + while (isActive) {
  306 + delay(30_000)
  307 + reconcile()
  308 + }
267 309 }
268 310 Log.i(TAG, "pipeline running (block-based diarization-first)")
269 311  
... ...
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/service/RecorderState.kt
... ... @@ -12,6 +12,10 @@ object RecorderState {
12 12 private val _isPaused = MutableStateFlow(false)
13 13 val isPaused: StateFlow<Boolean> = _isPaused
14 14  
  15 + /** 定时不录时段中(自动暂停采集,到点恢复)。 */
  16 + private val _isQuiet = MutableStateFlow(false)
  17 + val isQuiet: StateFlow<Boolean> = _isQuiet
  18 +
15 19 private val _segmentCount = MutableStateFlow(0)
16 20 val segmentCount: StateFlow<Int> = _segmentCount
17 21  
... ... @@ -44,6 +48,10 @@ object RecorderState {
44 48 _isPaused.value = v
45 49 }
46 50  
  51 + fun setQuiet(v: Boolean) {
  52 + _isQuiet.value = v
  53 + }
  54 +
47 55 /** 采集开始/恢复:开表。 */
48 56 fun onCapturing() {
49 57 _recordingStartedAt.value = System.currentTimeMillis()
... ... @@ -70,6 +78,7 @@ object RecorderState {
70 78 _lastWallMs.value = 0
71 79 _recordingStartedAt.value = 0
72 80 _recordingAccumMs.value = 0
  81 + _isQuiet.value = false
73 82 }
74 83  
75 84 fun setStatus(s: String) {
... ...