fix(bedtime): name nights by their evening, retry a refused removal

A bedtime before noon now belongs to the evening before, so Sunday to
Thursday nights at 00:30 run early Monday to early Friday, not Saturday
night through Wednesday night. Turning bedtime off clears the "last
applied" state only once the rule is really gone, so a refused removal
is retried instead of leaving Do Not Disturb on. Reopening the screen
after granting access elsewhere creates the rule even if the system's
broadcast never arrived. The empty-mask rule lives in one place.

Refs #1
This commit is contained in:
makiolaj committed 2026-10-22 12:02:39 +02:00
1 parent 1a443c066f
commit b701859156
13 files changed
+80 -29

No files matched your search

@@ -120,8 +120,10 @@ class BedtimeEngine @Inject constructor(
// Removing the rule ends the mode if it is on. Without access there
// is nothing Clockula may remove; revoking access in Settings
// already removed the rule.
if (mode.hasAccess()) mode.uninstall()
settings.setBedtimeAppliedActive(false)
// Recorded only once the rule is really gone, so a refused removal
// is retried by the next pass instead of leaving the mode on.
val removed = !mode.hasAccess() || mode.uninstall()
if (removed) settings.setBedtimeAppliedActive(false)
return
}
val active = BedtimeSchedule.activeWindow(current, now, zone) != null
@@ -37,8 +37,8 @@ interface BedtimeMode {
/** Turns the mode on or off; false when there is no rule or the system refused. */
fun setActive(active: Boolean): Boolean
/** Removes the rule, which also ends it if it is on. */
fun uninstall()
/** Removes the rule, which also ends it if it is on; false when the system refused. */
fun uninstall(): Boolean
/** The rule's id, for a deep link into the system's settings; null when there is none. */
fun ruleId(): String?
@@ -100,11 +100,11 @@ class AndroidBedtimeMode @Inject constructor(
true
}
override fun uninstall() {
guarded(Unit) {
override fun uninstall(): Boolean =
guarded(false) {
ownRules().keys.forEach(manager::removeAutomaticZenRule)
true
}
}
override fun ruleId(): String? = guarded(null) { ownRules().keys.firstOrNull() }
@@ -154,8 +154,7 @@ object BackupCodec {
enabled = enabled,
bedtime = minutesToTime(bedtimeMinutes),
wakeTime = minutesToTime(wakeMinutes),
// The screen never lets the last night go; a file with none gets all.
days = RepeatDays.fromMask(days).takeIf { it.isRepeating } ?: RepeatDays.EVERY_DAY,
days = BedtimeLimits.nights(days),
reminderMinutes = reminderMinutes.coerceIn(BedtimeLimits.REMINDER_MINUTES),
effects = BedtimeEffects(
grayscale = grayscale,
@@ -31,7 +31,7 @@ object BedtimePrefs {
*/
val days: Pref<RepeatDays> = intPref("bedtime_days", DEFAULT.days.mask)
.map(
decode = { mask -> RepeatDays.fromMask(mask).takeIf { it.isRepeating } ?: RepeatDays.EVERY_DAY },
decode = BedtimeLimits::nights,
encode = { it.mask },
)
@@ -21,8 +21,10 @@ data class BedtimeWindow(val start: Instant, val end: Instant) {
object BedtimeSchedule {
/**
* The window that starts on [date], if [date] is one of the schedule's
* nights. It ends at the first wake time after it starts, so a wake time
* 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
* 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.
@@ -32,8 +34,10 @@ 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 endDate = if (settings.wakeTime.minutesOfDay > settings.bedtime.minutesOfDay) date else date.plusDays(1)
val start = AlarmOccurrences.at(date, settings.bedtime, zone)
val startDate = if (settings.bedtime.minutesOfDay < NOON) 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)
val end = AlarmOccurrences.at(endDate, settings.wakeTime, zone)
return if (end > start) BedtimeWindow(start, end) else null
}
@@ -42,9 +46,10 @@ object BedtimeSchedule {
fun activeWindow(settings: BedtimeSettings, now: Instant, zone: ZoneId): BedtimeWindow? {
if (!settings.enabled) return null
val today = today(now, zone)
// A window is at most a day long, so one that contains now started
// today or the day before.
return listOf(today, today.minusDays(1))
// A window is at most a day long and starts on its night's date or
// the day after, so one that contains now belongs to one of the last
// three nights.
return listOf(today, today.minusDays(1), today.minusDays(2))
.firstNotNullOfOrNull { date -> windowOn(settings, date, zone)?.takeIf { now in it } }
}
@@ -52,7 +57,7 @@ object BedtimeSchedule {
fun nextWindow(settings: BedtimeSettings, now: Instant, zone: ZoneId): BedtimeWindow? {
if (!settings.enabled) return null
val today = today(now, zone)
return (0L..7L)
return (-1L..7L)
.firstNotNullOfOrNull { offset ->
windowOn(settings, today.plusDays(offset), zone)?.takeIf { it.start > now }
}
@@ -89,5 +94,7 @@ object BedtimeSchedule {
private fun reminderAt(settings: BedtimeSettings, window: BedtimeWindow): Instant =
window.start - settings.reminderMinutes.minutes
private const val NOON = 12 * 60
private fun today(now: Instant, zone: ZoneId): LocalDate = now.toJavaInstant().atZone(zone).toLocalDate()
}
@@ -36,4 +36,11 @@ object BedtimeLimits {
/** Anything read from disk or a backup is clamped into this. */
val REMINDER_MINUTES: IntRange = 0..120
/**
* The nights from a stored mask. The screen never lets the last night go,
* so an empty mask (corrupt, or written around it) reads as every night,
* on disk and in a backup alike.
*/
fun nights(mask: Int): RepeatDays = RepeatDays.fromMask(mask).takeIf { it.isRepeating } ?: RepeatDays.EVERY_DAY
}
@@ -52,7 +52,11 @@ class BedtimeViewModel @Inject internal constructor(
val changed = granted != access.value
access.value = granted
appScope.launch {
if (changed) runCatching { engine.resync(refreshRule = true) }
// 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)) {
runCatching { engine.resync(refreshRule = true) }
}
ruleId.value = engine.ruleId()
}
}
+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 starts on. 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 after midnight 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>
@@ -122,14 +122,40 @@ class BedtimeScheduleTest {
@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.
// Berlin springs forward on 2026-03-29: 02:30 does not exist. That is
// the small hours of Saturday 28's night.
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(BedtimeSchedule.windowOn(nightShift, java.time.LocalDate.of(2026, 3, 28), BERLIN)).isNull()
val ordinary = BedtimeSchedule.windowOn(nightShift, java.time.LocalDate.of(2026, 3, 29), BERLIN)!!
assertThat(ordinary.end - ordinary.start).isEqualTo(45.minutes)
}
@Test
fun `a bedtime after midnight belongs to the evening before`() {
// Sunday to Thursday nights, 00:30 to 07:00. 2026-06-12 is a Friday.
val schoolNights = everyNight.copy(
bedtime = TimeOfDay(0, 30),
days = RepeatDays.of(
DayOfWeek.SUNDAY,
DayOfWeek.MONDAY,
DayOfWeek.TUESDAY,
DayOfWeek.WEDNESDAY,
DayOfWeek.THURSDAY,
),
)
// Thursday night: Friday 00:30.
assertThat(BedtimeSchedule.activeWindow(schoolNights, instantAt(BERLIN, "2026-06-12T01:00"), BERLIN))
.isNotNull()
// Friday night: Saturday 00:30, not one.
assertThat(BedtimeSchedule.activeWindow(schoolNights, instantAt(BERLIN, "2026-06-13T01:00"), BERLIN))
.isNull()
// From Friday afternoon, the next is Sunday night: Monday 00:30.
assertThat(BedtimeSchedule.nextWindow(schoolNights, instantAt(BERLIN, "2026-06-12T15:00"), BERLIN)?.start)
.isEqualTo(offsetInstant("2026-06-15T00:30+02:00"))
}
@Test
fun `no nights means nothing to schedule`() {
val none = everyNight.copy(days = RepeatDays.NONE)
@@ -58,10 +58,11 @@ class FakeBedtimeMode(
return true
}
override fun uninstall() {
if (!access) return
override fun uninstall(): Boolean {
if (!access) return false
rule = null
on = false
return true
}
override fun ruleId(): String? = if (access && rule != null) "rule" else null
+8 -3
View File
@@ -2769,7 +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 days to the first wake time after it (an earlier wake time is
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
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
@@ -2821,13 +2823,16 @@ Do Not Disturb access cannot be requested with a dialog. The screen shows an
onboarding row while it is missing, deep-linking to
`Settings.ACTION_NOTIFICATION_POLICY_ACCESS_SETTINGS`, and re-reads access on
every `ON_RESUME`; a change found there runs `resync(refreshRule = true)` at
once, so coming back from granting it creates the mode without waiting for the
once (also when access is there but no rule exists yet, for a grant made while
the screen was closed), so coming back from granting it creates the mode without waiting for the
system's `ACTION_NOTIFICATION_POLICY_ACCESS_GRANTED_CHANGED` (which
`BedtimeReceiver` also answers). Without access the schedule still runs for the
reminder. Revoking access in Settings removes the app's rules with it (that is
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.
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.
### Backup
+1 -1
View File
@@ -153,7 +153,7 @@ device or after a reboot (`docs/PLAN.md` §5). Every imported timer lands
`bedtime` is the opt-in bedtime schedule (issue #1). Its times are minutes
since local midnight, `days` is the same 7-bit mask as an alarm's
`repeat_days` (the nights bedtime starts on), and `reminder_minutes` 0 means no
`repeat_days` (the nights bedtime is for, named by their evening), and `reminder_minutes` 0 means no
reminder. A file without `bedtime` (any file written before it existed)
restores bedtime as off with the default schedule. Like the other settings it
is clamped rather than rejected: times into a day, the reminder into 0–120