From 1a443c066fc5ca810e779f2a8d3bcf4f2f7a9e4c Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Thu, 22 Oct 2026 11:53:39 +0200 Subject: [PATCH] fix(bedtime): DST-safe windows, ordered edits, quieter passes Whether the wake time is the same night or the next morning is now decided on the local times, so a bedtime in a spring-forward gap no longer turns a 45-minute window into a day-long one. Screen edits start undispatched so they reach the engine's lock in the order they were made, and an edit that rewrites the rule also re-asserts the mode, in case the platform reset its state. The launch-time bedtime pass runs even if an earlier engine pass failed, a restore that never applied settings no longer forces the mode, and a user who never turned bedtime on pays nothing for it at launch or boot. The screen says when equal bedtime and wake-up times mean it never starts. Refs #1 --- .../de/jeanlucmakiola/clockula/ClockulaApp.kt | 42 +++++++++++-------- .../clockula/backup/BackupEngine.kt | 4 +- .../clockula/bedtime/BedtimeEngine.kt | 16 ++++--- .../domain/bedtime/BedtimeSchedule.kt | 9 ++-- .../clockula/ui/bedtime/BedtimeScreen.kt | 13 ++++-- .../clockula/ui/bedtime/BedtimeViewModel.kt | 5 ++- app/src/main/res/values/strings.xml | 1 + .../clockula/bedtime/BedtimeEngineTest.kt | 12 ++++++ .../domain/bedtime/BedtimeScheduleTest.kt | 11 +++++ docs/ARCHITECTURE.md | 10 +++-- docs/PLAN.md | 2 +- 11 files changed, 87 insertions(+), 38 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ClockulaApp.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ClockulaApp.kt index 1cb639a..b816a6e 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ClockulaApp.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ClockulaApp.kt @@ -73,27 +73,35 @@ class ClockulaApp : Application() { // a cold start caused by the fire broadcast must not interleave its // read-modify-write over `alarm_states` with the fire's own. scope.launch { - engine.onBootCompleted() - // After the alarm engine's pass, and idempotent within a boot: the - // boot-id gate makes the second repair a no-op, so neither engine - // depends on the other's ordering (M6 D21). Process death costs the - // timers nothing — the anchors and the AlarmManager slot survive it — - // but the service may not have, so this brings it back. - timerEngine.onBootCompleted() - // And the third, after the other two. Process death costs the - // stopwatch nothing — the run is in DataStore and the laps in Room — - // but the service may not have survived it, so this brings it back; - // and if this is a new boot the gate above has already repaired the - // run, leaving it paused at what it banked (M7 D17). - stopwatchEngine.onBootCompleted() - // Last and guarded, like every bedtime call on a shared path: a - // force stop clears its AlarmManager slot too, and may have slept - // through a bedtime or wake-up, so both are caught up here. - runCatching { bedtimeEngine.resync() } + try { + launchPasses() + } finally { + // Last and guarded, like every bedtime call on a shared path, + // and in a finally so a failure above cannot cost it its pass: + // a force stop clears its AlarmManager slot too, and may have + // slept through a bedtime or wake-up, so both are caught up here. + runCatching { bedtimeEngine.resync() } + } } // Never completes on its own — it is the widgets' one refresh trigger // for as long as the process lives (M10, docs/PLAN.md §9). scope.launch { widgetSync.run() } } + + private suspend fun launchPasses() { + engine.onBootCompleted() + // After the alarm engine's pass, and idempotent within a boot: the + // boot-id gate makes the second repair a no-op, so neither engine + // depends on the other's ordering (M6 D21). Process death costs the + // timers nothing — the anchors and the AlarmManager slot survive it — + // but the service may not have, so this brings it back. + timerEngine.onBootCompleted() + // And the third, after the other two. Process death costs the + // stopwatch nothing — the run is in DataStore and the laps in Room — + // but the service may not have survived it, so this brings it back; + // and if this is a new boot the gate above has already repaired the + // run, leaving it paused at what it banked (M7 D17). + stopwatchEngine.onBootCompleted() + } } 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 1d167a5..327d812 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/backup/BackupEngine.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/backup/BackupEngine.kt @@ -98,8 +98,8 @@ class BackupEngine @Inject constructor( // The bedtime settings came with the rest (or were rolled back // 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. - runCatching { bedtimeEngine.resync(refreshRule = true) } + // not a failed import. Nothing to follow if they never changed. + if (settingsApplied) runCatching { bedtimeEngine.resync(refreshRule = true) } } } failure?.let { throw it } diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/BedtimeEngine.kt b/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/BedtimeEngine.kt index 22a0d88..6acd839 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/BedtimeEngine.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/BedtimeEngine.kt @@ -46,19 +46,20 @@ class BedtimeEngine @Inject constructor( /** * A change made on the bedtime screen: stored, then applied at once. The * rule is rewritten when a field it carries changed. When the schedule - * itself changed, the mode is switched to what it says now, so turning - * bedtime on at 23:30 for a 23:00 bedtime starts it straight away; an - * edit that cannot move a boundary (the reminder, an effect) leaves a - * mode switched by hand alone. + * or the rule changed, the mode is switched to what it says now, so + * turning bedtime on at 23:30 for a 23:00 bedtime starts it straight away + * (and a rewritten rule cannot be left off if the platform reset its + * state); a reminder edit leaves a mode switched by hand alone. */ suspend fun update(transform: (BedtimeSettings) -> BedtimeSettings) = lock.withLock { val previous = settings.currentBedtime() val updated = transform(previous) settings.setBedtime(updated) + val refreshRule = previous.ruleFields() != updated.ruleFields() apply( updated, - refreshRule = previous.ruleFields() != updated.ruleFields(), - force = previous.scheduleFields() != updated.scheduleFields(), + refreshRule = refreshRule, + force = refreshRule || previous.scheduleFields() != updated.scheduleFields(), ) } @@ -111,6 +112,9 @@ class BedtimeEngine @Inject constructor( zone: ZoneId = zones.current(), ) { if (!current.enabled) { + // Already off and cleaned up: nothing to do, which keeps every + // launch and boot of a user who never turned bedtime on cheap. + if (!force && !refreshRule && !settings.bedtimeAppliedActive()) return scheduler.cancel() notifier.cancelReminder() // Removing the rule ends the mode if it is on. Without access there diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/bedtime/BedtimeSchedule.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/bedtime/BedtimeSchedule.kt index 4517a4f..e14adbd 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/domain/bedtime/BedtimeSchedule.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/bedtime/BedtimeSchedule.kt @@ -29,10 +29,13 @@ object BedtimeSchedule { */ fun windowOn(settings: BedtimeSettings, date: LocalDate, zone: ZoneId): BedtimeWindow? { if (date.dayOfWeek !in settings.days || settings.bedtime == settings.wakeTime) return null + // Next morning or same day is decided on the local times, before DST + // resolution: a bedtime in a spring-forward gap shifts later and must + // not push a same-night wake time to the following day. + val endDate = if (settings.wakeTime.minutesOfDay > settings.bedtime.minutesOfDay) date else date.plusDays(1) val start = AlarmOccurrences.at(date, settings.bedtime, zone) - val sameDayEnd = AlarmOccurrences.at(date, settings.wakeTime, zone) - val end = if (sameDayEnd > start) sameDayEnd else AlarmOccurrences.at(date.plusDays(1), settings.wakeTime, zone) - return BedtimeWindow(start, end) + val end = AlarmOccurrences.at(endDate, settings.wakeTime, zone) + return if (end > start) BedtimeWindow(start, end) else null } /** The window [now] falls in, or null when it is daytime or the schedule is off. */ diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/bedtime/BedtimeScreen.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/bedtime/BedtimeScreen.kt index 9102513..ca32b6a 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/bedtime/BedtimeScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/bedtime/BedtimeScreen.kt @@ -88,10 +88,15 @@ fun BedtimeScreen( GroupedRow( title = stringResource(R.string.bedtime_enabled), - summary = if (settings.enabled) { - stringResource(R.string.bedtime_enabled_on, formatTime(settings.bedtime), formatTime(settings.wakeTime)) - } else { - stringResource(R.string.bedtime_enabled_off) + summary = when { + !settings.enabled -> stringResource(R.string.bedtime_enabled_off) + // No window at all (BedtimeSchedule): say so rather than "On". + settings.bedtime == settings.wakeTime -> stringResource(R.string.bedtime_enabled_same_time) + else -> stringResource( + R.string.bedtime_enabled_on, + formatTime(settings.bedtime), + formatTime(settings.wakeTime), + ) }, position = Position.Alone, trailing = { 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 4c0a011..afd1a83 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 @@ -9,6 +9,7 @@ import de.jeanlucmakiola.clockula.domain.bedtime.BedtimeEffects import de.jeanlucmakiola.clockula.domain.bedtime.BedtimeSettings import de.jeanlucmakiola.floret.di.ApplicationScope import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -87,7 +88,9 @@ class BedtimeViewModel @Inject internal constructor( * and arming the slot or creating the rule. */ private fun update(transform: (BedtimeSettings) -> BedtimeSettings) { - appScope.launch { + // Undispatched, so each change queues on the engine's (fair) lock in + // the order the user made it: two quick taps cannot land reversed. + appScope.launch(start = CoroutineStart.UNDISPATCHED) { runCatching { engine.update(transform) } ruleId.value = engine.ruleId() } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ebcb347..9887ccd 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -448,6 +448,7 @@ Bedtime mode On, %1$s to %2$s Off + Bedtime and wake-up are the same time, so it never starts Allow Do Not Disturb access Clockula needs it to turn its bedtime mode on and off. Find Clockula in the list and allow it. Without it, only the reminder works. Allow diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/bedtime/BedtimeEngineTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/bedtime/BedtimeEngineTest.kt index 2e9126e..28ccf88 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/bedtime/BedtimeEngineTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/bedtime/BedtimeEngineTest.kt @@ -242,6 +242,18 @@ class BedtimeEngineTest { assertThat(h.mode.on).isFalse() } + @Test + fun `an effect edit at night re-asserts the mode`(@TempDir tempDir: Path) = runTest { + val h = harness(tempDir, now = instantAt(BERLIN, "2026-06-10T23:30")) + h.engine.update(nightly) + // As if rewriting the rule reset its state. + h.mode.on = false + + h.engine.update { it.copy(effects = it.effects.copy(grayscale = false)) } + + assertThat(h.mode.on).isTrue() + } + @Test fun `changing only the reminder does not rewrite the rule`(@TempDir tempDir: Path) = runTest { val h = harness(tempDir) diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/domain/bedtime/BedtimeScheduleTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/domain/bedtime/BedtimeScheduleTest.kt index 49fcc58..04d53ca 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/domain/bedtime/BedtimeScheduleTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/domain/bedtime/BedtimeScheduleTest.kt @@ -9,6 +9,7 @@ import de.jeanlucmakiola.clockula.testing.offsetInstant import org.junit.jupiter.api.Test import java.time.DayOfWeek import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.minutes /** 2026-06-10 is a Wednesday. */ class BedtimeScheduleTest { @@ -119,6 +120,16 @@ class BedtimeScheduleTest { assertThat(window.end).isEqualTo(offsetInstant("2026-10-25T07:00+01:00")) } + @Test + fun `a bedtime in a spring-forward gap keeps its same-night wake time`() { + // Berlin springs forward on 2026-03-29: 02:30 does not exist. + val nightShift = everyNight.copy(bedtime = TimeOfDay(2, 30), wakeTime = TimeOfDay(3, 15)) + + assertThat(BedtimeSchedule.windowOn(nightShift, java.time.LocalDate.of(2026, 3, 29), BERLIN)).isNull() + val ordinary = BedtimeSchedule.windowOn(nightShift, java.time.LocalDate.of(2026, 3, 30), BERLIN)!! + assertThat(ordinary.end - ordinary.start).isEqualTo(45.minutes) + } + @Test fun `no nights means nothing to schedule`() { val none = everyNight.copy(days = RepeatDays.NONE) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 40e18a1..d566922 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -2770,18 +2770,20 @@ engines (`ArchitectureRulesTest` holds it to no `android.*` import): Every entry point is an idempotent pass over "where does the schedule stand now", computed by `BedtimeSchedule`: a night's window runs from the bedtime on one of the chosen days to the first wake time after it (an earlier wake time is -the next morning; an equal one is no window at all, since a day-long window on -every night would be a Do Not Disturb that never ends), resolved from local times and the +the next morning, decided on the local times so a bedtime in a spring-forward +gap cannot push a same-night wake time a day later; an equal one is no window +at all, since a day-long window on every night would be a Do Not Disturb that +never ends, and the screen says so), resolved from local times and the zone like an alarm, so a DST night is as long as the wall clock says. The slot is armed for the earliest of the current window's end, the next window's reminder and the next window's start. | Entry point | Called from | What it does | |---|---|---| -| `update(transform)` | the bedtime screen, on the application scope so leaving the screen cannot cut a write short | stores the settings, rewrites the rule if a field it carries changed, and when the schedule itself changed sets the mode to what it says now (on at 23:30 for a 23:00 bedtime starts it at once; a reminder or effect edit leaves a hand-switched mode alone), arms the slot. Off removes the rule, which ends the mode, cancels the slot and clears the reminder | +| `update(transform)` | the bedtime screen, on the application scope so leaving the screen cannot cut a write short | stores the settings, rewrites the rule if a field it carries changed, and when the schedule or the rule changed sets the mode to what it says now (on at 23:30 for a 23:00 bedtime starts it at once; a reminder edit leaves a hand-switched mode alone), arms the slot. The screen's writes start undispatched, so they queue on the engine's fair lock in the order they were made. Off removes the rule, which ends the mode, cancels the slot and clears the reminder | | `onSlotFired()` | `BedtimeReceiver` | follows any boundary crossed, posts a due reminder, arms the next event. A reminder fire alone crosses no boundary, so it leaves a mode switched on by hand alone | | `resync()` | `ClockulaApp.onCreate` (a force stop clears the slot), `SystemEventReceiver` (boot, app update, clock or zone change, exact-alarm permission) | creates the rule if it is missing, follows any boundary crossed, arms the slot | -| `resync(refreshRule = true)` | Do Not Disturb access changing (the screen's `ON_RESUME` check and the broadcast), a backup restore | the same, but also rewrites the rule and re-asserts the mode's state | +| `resync(refreshRule = true)` | Do Not Disturb access changing (the screen's `ON_RESUME` check and the broadcast), a backup restore | the same, but also rewrites the rule and re-asserts the mode's state. A restore re-runs it only if it got as far as applying settings | "Follows any boundary crossed" is the one rule that lets a hand-switched mode coexist with a schedule. The last state Clockula set is persisted diff --git a/docs/PLAN.md b/docs/PLAN.md index 25015e9..a8dcc8c 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -164,7 +164,7 @@ generic advice: exact-alarm permission, notification permission, full-screen-int permission, battery-optimisation exemption, **whether DND currently blocks alarms** (read via `NotificationManager.getCurrentInterruptionFilter()`; the self-check never changes DND itself, it only reports what the user already has. The one place Clockula -touches DND is the opt-in bedtime mode added after v1, which needs the user to grant +touches DND is the opt-in bedtime mode (issue #1), which needs the user to grant `ACCESS_NOTIFICATION_POLICY` by hand and always lets alarms through; see `ARCHITECTURE.md` §18), whether the OEM has Clockula under an aggressive app-killer, and the next scheduled fire time as the system sees it (`getNextAlarmClock()`), compared