Commit dec066af00bfc8dd48b44f372dfc025f24be6626
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>
Showing
5 changed files
with
134 additions
and
19 deletions
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: () -> U | @@ -300,16 +301,19 @@ private fun HomeTab(activity: Context, repo: Repository, onOpenTimeline: () -> 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) { |