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
This commit is contained in:
makiolaj committed 2026-10-22 12:11:04 +02:00
1 parent b701859156
commit 8b182c999b
8 files changed
+49 -12

No files matched your search

@@ -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
@@ -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()
}
@@ -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 = {
+1 -1
View File
@@ -455,7 +455,7 @@
<string name="bedtime_section_schedule">Schedule</string>
<string name="bedtime_start">Bedtime</string>
<string name="bedtime_wake">Wake up</string>
<string name="bedtime_days_hint">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.</string>
<string name="bedtime_days_hint">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.</string>
<string name="bedtime_section_reminder">Reminder</string>
<string name="bedtime_reminder">Remind me before bedtime</string>
<string name="bedtime_reminder_off">Off</string>
@@ -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)
@@ -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)
@@ -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
+5 -4
View File
@@ -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