From 1a5552a22146a40d2507dcb0b42e96d7d7fd6790 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Thu, 22 Oct 2026 12:20:25 +0200 Subject: [PATCH] fix(bedtime): keep the slot off the allow-while-idle ration Android rations allow-while-idle wake-ups per app in doze, and the timer's expiry slot and the alarm's fallback share that ration, so a 07:00 wake-up fire could hold back a timer due at 07:04. The bedtime slot is now a plain exact alarm that doze may defer, which can move a mode boundary but never a ring. A restore of unchanged bedtime settings no longer forces the mode, and the screen skips its rule check while bedtime is off. Refs #1 --- .../clockula/backup/BackupEngine.kt | 7 +++++- .../android/AndroidBedtimeScheduler.kt | 22 +++++++++++-------- .../clockula/ui/bedtime/BedtimeViewModel.kt | 3 ++- docs/ARCHITECTURE.md | 15 ++++++++----- 4 files changed, 31 insertions(+), 16 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/backup/BackupEngine.kt b/app/src/main/java/de/jeanlucmakiola/clockula/backup/BackupEngine.kt index 327d812..bfeb275 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/backup/BackupEngine.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/backup/BackupEngine.kt @@ -99,7 +99,12 @@ class BackupEngine @Inject constructor( // with them), so the slot and the Do Not Disturb rule follow. // Its platform calls are guarded already; a failure here is // not a failed import. Nothing to follow if they never changed. - if (settingsApplied) runCatching { bedtimeEngine.resync(refreshRule = true) } + // Forced only when the bedtime settings really changed, so a + // restore of the same schedule leaves a hand-switched mode be. + if (settingsApplied) { + val changed = previousSettings.bedtime != content.settings.bedtime + runCatching { bedtimeEngine.resync(refreshRule = changed) } + } } } failure?.let { throw it } diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/android/AndroidBedtimeScheduler.kt b/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/android/AndroidBedtimeScheduler.kt index 10c7c37..487b0ff 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/android/AndroidBedtimeScheduler.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/android/AndroidBedtimeScheduler.kt @@ -15,11 +15,17 @@ import javax.inject.Singleton /** * One wall-clock slot for the schedule's next event. Never `setAlarmClock`: * that slot is the status bar's "next alarm", and bedtime is not an alarm. - * Exact while idle where the platform allows it, the same two branches the - * timer's slot uses: a wake-up time that ends the mode an hour late, after a - * doze maintenance window, is the failure this avoids. It is its own - * PendingIntent (its own receiver, action and request code), so arming or - * cancelling it can never touch an alarm's or a timer's registration. + * + * And never *allow-while-idle*. Android rations allow-while-idle wake-ups + * per app while the device dozes (one per nine minutes on Android 10 and 11, + * and for the inexact fallback without exact-alarm access), and the timer's + * expiry slot and the alarm's fallback draw on the same ration. A 07:00 + * wake-up fire would hold back a timer due at 07:04 or a fallback alarm at + * 07:05. So the slot is exact but doze-deferrable: in deep doze it waits for + * the next maintenance window or for the next alarm clock to wake the device, + * which only moves a mode boundary, never a ring. It is its own PendingIntent + * (its own receiver, action and request code), so arming or cancelling it can + * never touch an alarm's or a timer's registration. */ @Singleton class AndroidBedtimeScheduler @Inject constructor( @@ -31,12 +37,10 @@ class AndroidBedtimeScheduler @Inject constructor( override fun schedule(at: Instant) { val millis = at.toEpochMilliseconds() val pendingIntent = slotPendingIntent() - val exact = runCatching { - manager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, millis, pendingIntent) - }.isSuccess + val exact = runCatching { manager.setExact(AlarmManager.RTC_WAKEUP, millis, pendingIntent) }.isSuccess // Guarded too: bedtime losing its slot is survivable, a crash in the // receiver's process is not. - if (!exact) runCatching { manager.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, millis, pendingIntent) } + if (!exact) runCatching { manager.set(AlarmManager.RTC_WAKEUP, millis, pendingIntent) } } override fun cancel() = manager.cancel(slotPendingIntent()) diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/bedtime/BedtimeViewModel.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/bedtime/BedtimeViewModel.kt index 6a98974..db0c391 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/bedtime/BedtimeViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/bedtime/BedtimeViewModel.kt @@ -54,7 +54,8 @@ class BedtimeViewModel @Inject internal constructor( appScope.launch { // Also when access was granted while this screen was closed and // the system's broadcast did not reach the app: no rule yet. - if (changed || (granted && engine.ruleId() == null)) { + val enabled = (state.value as? BedtimeUiState.Loaded)?.settings?.enabled == true + if (changed || (granted && enabled && engine.ruleId() == null)) { runCatching { engine.resync(refreshRule = true) } } ruleId.value = engine.ruleId() diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 7f42f3e..165a8cb 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -2759,11 +2759,16 @@ engines (`ArchitectureRulesTest` holds it to no `android.*` import): - `BedtimeScheduler`, **one** AlarmManager slot of its own. Its PendingIntent targets `BedtimeReceiver` with its own action and request code, so arming or cancelling it cannot replace or cancel an alarm's or a timer's registration. - It is `RTC_WAKEUP`, exact while idle when the platform allows it and - `setAndAllowWhileIdle` otherwise, the timer slot's two branches. It never - uses `setAlarmClock`: that slot is the status bar's next *alarm*. Alarms - themselves use `setAlarmClock`, which the while-idle quota does not touch, - and bedtime adds at most three fires a night. + It is `RTC_WAKEUP` and `setExact` (plain `set` without exact-alarm access), + and deliberately **not** allow-while-idle: Android rations allow-while-idle + wake-ups per app in doze (one per nine minutes on Android 10 and 11, and for + the inexact fallback without exact-alarm access), and the timer's expiry slot + and the alarm's fallback draw on that same ration, so a 07:00 wake-up fire + could hold back a timer at 07:04 or a fallback alarm at 07:05. In deep doze + the slot therefore waits for a maintenance window or for the next alarm + clock to wake the device. That can move a mode boundary, never a ring. It + never uses `setAlarmClock` either: that slot is the status bar's next + *alarm*. - `BedtimeMode`, the rule. Every call is guarded and a no-op without access. - `BedtimeNotifier`, the reminder, on its own `bedtime_reminder` channel.