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:
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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user