From 8b182c999b7d978a663f2aac92daff9ad01a10f3 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Thu, 22 Oct 2026 12:11:04 +0200 Subject: [PATCH] fix(bedtime): small hours end at 06:00, retry daytime removals Only a bedtime before 06:00 now counts as the small hours after the chosen evening, so a day sleeper's 08:00 bedtime runs on the days picked. A rule whose removal was refused while turning bedtime off in the daytime is removed by the next pass, and a restored reminder length the picker doesn't offer stays choosable. Refs #1 --- .../clockula/bedtime/BedtimeEngine.kt | 6 +++++- .../clockula/domain/bedtime/BedtimeSchedule.kt | 9 +++++---- .../clockula/ui/bedtime/BedtimeScreen.kt | 3 ++- app/src/main/res/values/strings.xml | 2 +- .../clockula/bedtime/BedtimeEngineTest.kt | 14 ++++++++++++++ .../clockula/domain/bedtime/BedtimeScheduleTest.kt | 13 +++++++++++++ .../clockula/testing/FakeBedtimeSeams.kt | 5 ++++- docs/ARCHITECTURE.md | 9 +++++---- 8 files changed, 49 insertions(+), 12 deletions(-) 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 a20c589..496fdd0 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/BedtimeEngine.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/BedtimeEngine.kt @@ -114,7 +114,11 @@ class BedtimeEngine @Inject constructor( 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 + if (!force && !refreshRule && !settings.bedtimeAppliedActive() && + (!mode.hasAccess() || mode.ruleId() == null) + ) { + 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 3cf1caa..fe9c2fa 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 @@ -23,8 +23,9 @@ object BedtimeSchedule { /** * The window for the night of [date], if [date] is one of the schedule's * nights. A night is named by its evening, as people name it: a bedtime - * before noon (00:30, say) is the small hours *after* that evening, so - * "Thursday night" at 00:30 starts on Friday's calendar date. It ends at the first wake time after it starts, so a wake time + * in the small hours (before 06:00; 00:30, say) is *after* that evening, + * so "Thursday night" at 00:30 starts on Friday's calendar date. A later + * one (a day sleeper's 08:00) is on the day itself. It ends at the first wake time after it starts, so a wake time * before the bedtime means the next morning. An equal bedtime and wake * time is no window at all: read as a whole day, every night selected * would join into a Do Not Disturb that never ends. @@ -34,7 +35,7 @@ object BedtimeSchedule { // 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 startDate = if (settings.bedtime.minutesOfDay < NOON) date.plusDays(1) else date + val startDate = if (settings.bedtime.minutesOfDay < SMALL_HOURS_END) date.plusDays(1) else date val endDate = if (settings.wakeTime.minutesOfDay > settings.bedtime.minutesOfDay) startDate else startDate.plusDays(1) val start = AlarmOccurrences.at(startDate, settings.bedtime, zone) @@ -94,7 +95,7 @@ object BedtimeSchedule { private fun reminderAt(settings: BedtimeSettings, window: BedtimeWindow): Instant = window.start - settings.reminderMinutes.minutes - private const val NOON = 12 * 60 + private const val SMALL_HOURS_END = 6 * 60 private fun today(now: Instant, zone: ZoneId): LocalDate = now.toJavaInstant().atZone(zone).toLocalDate() } 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 ca32b6a..7547eee 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 @@ -225,7 +225,8 @@ fun BedtimeScreen( ) BedtimePicker.REMINDER -> OptionPicker( title = stringResource(R.string.bedtime_reminder), - options = BedtimeLimits.REMINDER_CHOICES, + // A restored value the picker does not offer stays choosable. + options = (BedtimeLimits.REMINDER_CHOICES + settings.reminderMinutes).distinct().sorted(), selected = settings.reminderMinutes, label = { reminderText(it) }, onSelect = { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1cff9f1..08e9ead 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -455,7 +455,7 @@ Schedule Bedtime Wake up - The nights bedtime is for, named by their evening: a bedtime after midnight on Thursday night falls early on Friday. A wake-up time before the bedtime is the next morning. + The nights bedtime is for, named by their evening: a bedtime between midnight and 6:00 on Thursday night falls early on Friday. A wake-up time before the bedtime is the next morning. Reminder Remind me before bedtime Off 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 28ccf88..16068d1 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/bedtime/BedtimeEngineTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/bedtime/BedtimeEngineTest.kt @@ -254,6 +254,20 @@ class BedtimeEngineTest { assertThat(h.mode.on).isTrue() } + @Test + fun `a removal refused in the daytime is retried by the next pass`(@TempDir tempDir: Path) = runTest { + val h = harness(tempDir) + h.engine.update(nightly) + h.mode.refuseRemoval = true + h.engine.update { it.copy(enabled = false) } + assertThat(h.mode.rule).isNotNull() + + h.mode.refuseRemoval = false + h.engine.resync() + + assertThat(h.mode.rule).isNull() + } + @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 0846dad..fe31724 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 @@ -156,6 +156,19 @@ class BedtimeScheduleTest { .isEqualTo(offsetInstant("2026-06-15T00:30+02:00")) } + @Test + fun `a day sleeper's morning bedtime stays on the day picked`() { + val daySleep = everyNight.copy( + bedtime = TimeOfDay(8, 0), + wakeTime = TimeOfDay(16, 0), + days = RepeatDays.of(DayOfWeek.MONDAY), + ) + + // 2026-06-15 is a Monday. + assertThat(BedtimeSchedule.activeWindow(daySleep, instantAt(BERLIN, "2026-06-15T09:00"), BERLIN)) + .isNotNull() + } + @Test fun `no nights means nothing to schedule`() { val none = everyNight.copy(days = RepeatDays.NONE) diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeBedtimeSeams.kt b/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeBedtimeSeams.kt index 4858ce9..d84d529 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeBedtimeSeams.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeBedtimeSeams.kt @@ -58,8 +58,11 @@ class FakeBedtimeMode( return true } + /** The system refusing `removeAutomaticZenRule`, as an OEM build might. */ + var refuseRemoval: Boolean = false + override fun uninstall(): Boolean { - if (!access) return false + if (!access || refuseRemoval) return false rule = null on = false return true diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index fae1276..7f42f3e 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -2769,9 +2769,9 @@ 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 nights (named by its evening, so a bedtime before noon such as -00:30 falls on the next calendar day: "Thursday night" at 00:30 is early -Friday) to the first wake time after it (an earlier wake time is +one of the chosen nights (named by its evening, so a bedtime in the small hours, before 06:00, falls +on the next calendar day: "Thursday night" at 00:30 is early Friday, while a +day sleeper's 08:00 stays on the day picked) to the first wake time after it (an earlier wake time is 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 @@ -2832,7 +2832,8 @@ Settings' own confirmation dialog), and Clockula cannot remove a rule without access, so a rule left by a revoke from outside Settings is the platform's. Turning bedtime off removes the rule cleanly; the "last applied" state is cleared only once the removal succeeded, so a refused removal is retried by the -next pass rather than leaving the mode on. +next pass rather than leaving the mode on (an off schedule's cheap early return +still checks that no rule of Clockula's is left). ### Backup