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
This commit is contained in:
makiolaj committed 2026-10-22 11:53:39 +02:00
1 parent 2dce78281a
commit 1a443c066f
11 files changed
+87 -38

No files matched your search

@@ -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()
}
}
@@ -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 }
@@ -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
@@ -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. */
@@ -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 = {
@@ -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()
}
+1
View File
@@ -448,6 +448,7 @@
<string name="bedtime_enabled">Bedtime mode</string>
<string name="bedtime_enabled_on">On, %1$s to %2$s</string>
<string name="bedtime_enabled_off">Off</string>
<string name="bedtime_enabled_same_time">Bedtime and wake-up are the same time, so it never starts</string>
<string name="bedtime_access_title">Allow Do Not Disturb access</string>
<string name="bedtime_access_summary">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.</string>
<string name="bedtime_access_allow">Allow</string>
@@ -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)
@@ -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)
+6 -4
View File
@@ -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
+1 -1
View File
@@ -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