Commit dec066af00bfc8dd48b44f372dfc025f24be6626

Authored by yibo
1 parent e3b7506a

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,6 +250,7 @@ private fun RecordingScreen(
250 private fun HomeTab(activity: Context, repo: Repository, onOpenTimeline: () -> Unit, onOpenBlock: (Long) -> Unit) { 250 private fun HomeTab(activity: Context, repo: Repository, onOpenTimeline: () -> Unit, onOpenBlock: (Long) -> Unit) {
251 val running by RecorderState.isRunning.collectAsState() 251 val running by RecorderState.isRunning.collectAsState()
252 val paused by RecorderState.isPaused.collectAsState() 252 val paused by RecorderState.isPaused.collectAsState()
  253 + val quiet by RecorderState.isQuiet.collectAsState()
253 val status by RecorderState.status.collectAsState() 254 val status by RecorderState.status.collectAsState()
254 val segmentCount by RecorderState.segmentCount.collectAsState() 255 val segmentCount by RecorderState.segmentCount.collectAsState()
255 val lastAudioMs by RecorderState.lastAudioMs.collectAsState() 256 val lastAudioMs by RecorderState.lastAudioMs.collectAsState()
@@ -300,16 +301,19 @@ private fun HomeTab(activity: Context, repo: Repository, onOpenTimeline: () -&gt; U @@ -300,16 +301,19 @@ private fun HomeTab(activity: Context, repo: Repository, onOpenTimeline: () -&gt; U
300 val stateLabel = when { 301 val stateLabel = when {
301 !running -> "○ 已停止" 302 !running -> "○ 已停止"
302 paused -> "⏸ 已暂停(模型常驻)" 303 paused -> "⏸ 已暂停(模型常驻)"
  304 + quiet -> "🌙 静音时段(自动)"
303 else -> "● 录音中" 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 if (running) { 312 if (running) {
309 Text( 313 Text(
310 "⏱ 已录制 ${fmtClock(elapsedMs)}", 314 "⏱ 已录制 ${fmtClock(elapsedMs)}",
311 style = MaterialTheme.typography.headlineSmall, 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,6 +38,7 @@ import androidx.compose.ui.Modifier
38 import androidx.compose.ui.unit.dp 38 import androidx.compose.ui.unit.dp
39 import com.lemon.lifelog.asr.SpeakerClusterer 39 import com.lemon.lifelog.asr.SpeakerClusterer
40 import com.lemon.lifelog.service.BlockBuffer 40 import com.lemon.lifelog.service.BlockBuffer
  41 +import com.lemon.lifelog.service.QuietHours
41 import com.lemon.lifelog.chat.ChatConfig 42 import com.lemon.lifelog.chat.ChatConfig
42 import com.lemon.lifelog.storage.StorageManager 43 import com.lemon.lifelog.storage.StorageManager
43 import com.lemon.lifelog.summary.SummaryScheduler 44 import com.lemon.lifelog.summary.SummaryScheduler
@@ -62,6 +63,9 @@ fun SettingsScreen(activity: Context) { @@ -62,6 +63,9 @@ fun SettingsScreen(activity: Context) {
62 var blockSilenceMin by remember { 63 var blockSilenceMin by remember {
63 mutableStateOf(prefs.getInt(BlockBuffer.KEY_SILENCE_MIN, (BlockBuffer.SILENCE_MS / 60_000).toInt())) 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 var ttlDays by remember { mutableStateOf(prefs.getInt(StorageManager.KEY_TTL_DAYS, StorageManager.DEFAULT_TTL_DAYS)) } 69 var ttlDays by remember { mutableStateOf(prefs.getInt(StorageManager.KEY_TTL_DAYS, StorageManager.DEFAULT_TTL_DAYS)) }
66 var audioUsage by remember { mutableStateOf(-1L) } // -1 = 计算中 70 var audioUsage by remember { mutableStateOf(-1L) } // -1 = 计算中
67 var pruneMsg by remember { mutableStateOf("") } 71 var pruneMsg by remember { mutableStateOf("") }
@@ -144,6 +148,22 @@ fun SettingsScreen(activity: Context) { @@ -144,6 +148,22 @@ fun SettingsScreen(activity: Context) {
144 blockSilenceMin = it; prefs.edit().putInt(BlockBuffer.KEY_SILENCE_MIN, it).apply() 148 blockSilenceMin = it; prefs.edit().putInt(BlockBuffer.KEY_SILENCE_MIN, it).apply()
145 } 149 }
146 Text("改完下次录音服务启动生效(停止再开始 / 重启 app)。", style = MaterialTheme.typography.labelSmall) 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 Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { 167 Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
148 Text(if (batteryExempt) "✓ 已加入电池白名单" else "⚠ 未加入电池白名单(Doze 会杀后台)", 168 Text(if (batteryExempt) "✓ 已加入电池白名单" else "⚠ 未加入电池白名单(Doze 会杀后台)",
149 style = MaterialTheme.typography.bodySmall) 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,7 +29,9 @@ import kotlinx.coroutines.Job
29 import kotlinx.coroutines.SupervisorJob 29 import kotlinx.coroutines.SupervisorJob
30 import kotlinx.coroutines.cancel 30 import kotlinx.coroutines.cancel
31 import kotlinx.coroutines.channels.Channel 31 import kotlinx.coroutines.channels.Channel
  32 +import kotlinx.coroutines.delay
32 import kotlinx.coroutines.flow.collect 33 import kotlinx.coroutines.flow.collect
  34 +import kotlinx.coroutines.isActive
33 import kotlinx.coroutines.launch 35 import kotlinx.coroutines.launch
34 36
35 class RecorderService : Service() { 37 class RecorderService : Service() {
@@ -58,6 +60,12 @@ class RecorderService : Service() { @@ -58,6 +60,12 @@ class RecorderService : Service() {
58 @Volatile 60 @Volatile
59 private var paused = false 61 private var paused = false
60 62
  63 + /** 实际是否在采集(= 非手动暂停 且 非静音时段)。由 reconcile() 维护。 */
  64 + private var capturing = false
  65 +
  66 + /** 当前展示状态文案,去重避免每 30s reconcile 重发通知。 */
  67 + private var statusText = ""
  68 +
61 private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) 69 private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
62 private var pipelineJob: Job? = null 70 private var pipelineJob: Job? = null
63 private var wakeLock: PowerManager.WakeLock? = null 71 private var wakeLock: PowerManager.WakeLock? = null
@@ -105,25 +113,56 @@ class RecorderService : Service() { @@ -105,25 +113,56 @@ class RecorderService : Service() {
105 private fun doPause() { 113 private fun doPause() {
106 if (paused) return 114 if (paused) return
107 paused = true 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 Log.i(TAG, "paused (models kept resident)") 117 Log.i(TAG, "paused (models kept resident)")
115 } 118 }
116 119
117 private fun doResume() { 120 private fun doResume() {
118 if (!paused) return 121 if (!paused) return
119 paused = false 122 paused = false
120 - acquireWakeLock() 123 + reconcile()
  124 + Log.i(TAG, "resumed")
  125 + }
  126 +
  127 + private fun startCapture() {
  128 + if (capturing) return
121 capture.start(scope) 129 capture.start(scope)
122 RecorderState.onCapturing() 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 private fun notify(text: String) { 168 private fun notify(text: String) {
@@ -253,17 +292,20 @@ class RecorderService : Service() { @@ -253,17 +292,20 @@ class RecorderService : Service() {
253 } catch (t: Throwable) { 292 } catch (t: Throwable) {
254 Log.w(TAG, "block ${dir.name} process failed", t) 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 BlockBuffer.listPending(File(filesDir, "blocks")).forEach { pendingBlocks.trySend(it) } 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 Log.i(TAG, "pipeline running (block-based diarization-first)") 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,6 +12,10 @@ object RecorderState {
12 private val _isPaused = MutableStateFlow(false) 12 private val _isPaused = MutableStateFlow(false)
13 val isPaused: StateFlow<Boolean> = _isPaused 13 val isPaused: StateFlow<Boolean> = _isPaused
14 14
  15 + /** 定时不录时段中(自动暂停采集,到点恢复)。 */
  16 + private val _isQuiet = MutableStateFlow(false)
  17 + val isQuiet: StateFlow<Boolean> = _isQuiet
  18 +
15 private val _segmentCount = MutableStateFlow(0) 19 private val _segmentCount = MutableStateFlow(0)
16 val segmentCount: StateFlow<Int> = _segmentCount 20 val segmentCount: StateFlow<Int> = _segmentCount
17 21
@@ -44,6 +48,10 @@ object RecorderState { @@ -44,6 +48,10 @@ object RecorderState {
44 _isPaused.value = v 48 _isPaused.value = v
45 } 49 }
46 50
  51 + fun setQuiet(v: Boolean) {
  52 + _isQuiet.value = v
  53 + }
  54 +
47 /** 采集开始/恢复:开表。 */ 55 /** 采集开始/恢复:开表。 */
48 fun onCapturing() { 56 fun onCapturing() {
49 _recordingStartedAt.value = System.currentTimeMillis() 57 _recordingStartedAt.value = System.currentTimeMillis()
@@ -70,6 +78,7 @@ object RecorderState { @@ -70,6 +78,7 @@ object RecorderState {
70 _lastWallMs.value = 0 78 _lastWallMs.value = 0
71 _recordingStartedAt.value = 0 79 _recordingStartedAt.value = 0
72 _recordingAccumMs.value = 0 80 _recordingAccumMs.value = 0
  81 + _isQuiet.value = false
73 } 82 }
74 83
75 fun setStatus(s: String) { 84 fun setStatus(s: String) {