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 | 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: () -> 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) { | ... | ... |