fix(bedtime): switch a recreated rule, keep manual overrides, finish edits

A rule recreated by a pass (deleted in Settings, access re-granted) now
gets its state set even when no boundary was crossed, and the applied
state is recorded only once the system accepted it. Edits that can't move
a boundary (reminder, effects) no longer re-assert the mode over a manual
override, and the screen's writes run on the application scope so leaving
right after a change can't cut it short. A late reminder counts the
minutes actually left, and one slot fire reads the zone once.

An equal bedtime and wake time is now no window instead of a day-long one,
which on every night meant Do Not Disturb never ended, and an empty days
mask in prefs reads as every night, as a backup's does. The rule-id extras
are translated to the bedtime action in MainActivity, keeping the routing
table keyed on the action alone.

Refs #1
This commit is contained in:
makiolaj committed 2026-10-22 11:38:56 +02:00
1 parent 119197106b
commit 2dce78281a
14 files changed
+148 -67

No files matched your search

@@ -166,8 +166,12 @@ class MainActivity : ComponentActivity() {
.getOrNull()
?.toList()
.orEmpty()
val forZenRule = runCatching { BedtimeIntents.ZEN_RULE_EXTRAS.any(intent::hasExtra) }.getOrDefault(false)
return ShellNavigation.requestFor(intent.action, alarmId, alarmIds, forZenRule)
// Settings opens the bedtime rule's configuration activity (this one)
// with the rule id and no action; that is the bedtime screen (issue #1).
val forZenRule = intent.action == null &&
runCatching { BedtimeIntents.ZEN_RULE_EXTRAS.any(intent::hasExtra) }.getOrDefault(false)
val action = if (forZenRule) BedtimeIntents.ACTION_SHOW_BEDTIME else intent.action
return ShellNavigation.requestFor(action, alarmId, alarmIds)
}
override fun onResume() {
@@ -8,6 +8,9 @@ import de.jeanlucmakiola.clockula.domain.time.ZoneProvider
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.time.ZoneId
import kotlin.math.ceil
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Instant
import javax.inject.Inject
import javax.inject.Singleton
@@ -42,15 +45,21 @@ 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, and the mode is
* switched to what the new schedule says now, so turning bedtime on at
* 23:30 for a 23:00 bedtime starts it straight away.
* 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.
*/
suspend fun update(transform: (BedtimeSettings) -> BedtimeSettings) = lock.withLock {
val previous = settings.currentBedtime()
val updated = transform(previous)
settings.setBedtime(updated)
apply(updated, refreshRule = previous.ruleFields() != updated.ruleFields(), force = true)
apply(
updated,
refreshRule = previous.ruleFields() != updated.ruleFields(),
force = previous.scheduleFields() != updated.scheduleFields(),
)
}
/**
@@ -74,9 +83,12 @@ class BedtimeEngine @Inject constructor(
suspend fun onSlotFired() = lock.withLock {
val current = settings.currentBedtime()
val now = wallClock.now()
apply(current, refreshRule = false, force = false, now = now)
BedtimeSchedule.dueReminder(current, now, zones.current())?.let { window ->
notifier.showReminder(window.start, current.reminderMinutes)
val zone = zones.current()
apply(current, refreshRule = false, force = false, now = now, zone = zone)
BedtimeSchedule.dueReminder(current, now, zone)?.let { window ->
// What is actually left: a late fire must not promise the full lead.
val minutesLeft = ceil((window.start - now) / 1.minutes).toInt().coerceAtLeast(1)
notifier.showReminder(window.start, minutesLeft)
}
}
@@ -96,8 +108,8 @@ class BedtimeEngine @Inject constructor(
refreshRule: Boolean,
force: Boolean,
now: Instant = wallClock.now(),
zone: ZoneId = zones.current(),
) {
val zone = zones.current()
if (!current.enabled) {
scheduler.cancel()
notifier.cancelReminder()
@@ -113,10 +125,13 @@ class BedtimeEngine @Inject constructor(
// or the schedule changed under it) is stale.
if (BedtimeSchedule.dueReminder(current, now, zone) == null) notifier.cancelReminder()
if (mode.hasAccess()) {
mode.install(current, refresh = refreshRule)
if (force || active != settings.bedtimeAppliedActive()) {
mode.setActive(active)
settings.setBedtimeAppliedActive(active)
// A rule that was just (re)created starts off, whatever was last
// applied to its predecessor, so it is switched regardless.
val created = mode.install(current, refresh = refreshRule)
if (force || created || active != settings.bedtimeAppliedActive()) {
// Recorded only once the system took it, so a refused call is
// retried by the next pass rather than forgotten.
if (mode.setActive(active)) settings.setBedtimeAppliedActive(active)
}
}
val next = BedtimeSchedule.nextEvent(current, now, zone)
@@ -125,4 +140,7 @@ class BedtimeEngine @Inject constructor(
/** What the system rule is built from; the reminder is not part of it. */
private fun BedtimeSettings.ruleFields() = listOf(enabled, bedtime, wakeTime, effects)
/** What decides whether the mode should be on right now. */
private fun BedtimeSettings.scheduleFields() = listOf(enabled, bedtime, wakeTime, days)
}
@@ -28,13 +28,14 @@ interface BedtimeMode {
val supportsEffects: Boolean
/**
* Creates the rule when it is missing. [refresh] also rewrites an existing
* one from [settings]; otherwise an existing rule is left exactly as it is.
* Creates the rule when it is missing, and returns whether it did. [refresh]
* also rewrites an existing one from [settings]; otherwise an existing
* rule is left exactly as it is.
*/
fun install(settings: BedtimeSettings, refresh: Boolean)
fun install(settings: BedtimeSettings, refresh: Boolean): Boolean
/** Turns the mode on or off. Does nothing when there is no rule. */
fun setActive(active: Boolean)
/** 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()
@@ -60,8 +60,8 @@ class AndroidBedtimeMode @Inject constructor(
override fun hasAccess(): Boolean = guarded(false) { manager.isNotificationPolicyAccessGranted }
override fun install(settings: BedtimeSettings, refresh: Boolean) {
guarded(Unit) {
override fun install(settings: BedtimeSettings, refresh: Boolean): Boolean =
guarded(false) {
val ours = ownRules()
// Only ever one. A second (two passes racing on another process,
// say) is removed rather than left to fight the first.
@@ -69,7 +69,9 @@ class AndroidBedtimeMode @Inject constructor(
val existing = ours.entries.firstOrNull()
if (existing == null) {
manager.addAutomaticZenRule(rule(settings, enabled = true))
} else if (refresh) {
return@guarded true
}
if (refresh) {
// Keeps what the user set for the rule in Settings: its on/off
// switch (a change here must not re-enable a rule they
// disabled) and who may interrupt, which Android 10 to 14
@@ -79,12 +81,12 @@ class AndroidBedtimeMode @Inject constructor(
rule(settings, enabled = existing.value.isEnabled, kept = existing.value.zenPolicy),
)
}
false
}
}
override fun setActive(active: Boolean) {
guarded(Unit) {
val id = ownRules().keys.firstOrNull() ?: return@guarded
override fun setActive(active: Boolean): Boolean =
guarded(false) {
val id = ownRules().keys.firstOrNull() ?: return@guarded false
val state = if (active) Condition.STATE_TRUE else Condition.STATE_FALSE
val summary = context.getString(R.string.bedtime_mode_name)
val condition = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) {
@@ -95,8 +97,8 @@ class AndroidBedtimeMode @Inject constructor(
Condition(conditionId, summary, state)
}
manager.setAutomaticZenRuleState(id, condition)
true
}
}
override fun uninstall() {
guarded(Unit) {
@@ -24,9 +24,16 @@ object BedtimePrefs {
val wakeTime: Pref<TimeOfDay> = intPref("bedtime_wake_minutes", DEFAULT.wakeTime.minutesOfDay).asTimeOfDay()
/** The 7-bit mask `RepeatDays` uses; a corrupt value cannot widen the set. */
/**
* The 7-bit mask `RepeatDays` uses; a corrupt value cannot widen the set.
* The screen never lets the last night go, so an empty mask (corrupt, or
* written around it) reads as every night, as a backup's does.
*/
val days: Pref<RepeatDays> = intPref("bedtime_days", DEFAULT.days.mask)
.map(decode = RepeatDays::fromMask, encode = { it.mask })
.map(
decode = { mask -> RepeatDays.fromMask(mask).takeIf { it.isRepeating } ?: RepeatDays.EVERY_DAY },
encode = { it.mask },
)
val reminderMinutes: Pref<Int> = intPref("bedtime_reminder_minutes", DEFAULT.reminderMinutes)
.map(
@@ -23,11 +23,12 @@ 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
* at or before the bedtime means the next morning, and an equal one a
* full day.
* 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.
*/
fun windowOn(settings: BedtimeSettings, date: LocalDate, zone: ZoneId): BedtimeWindow? {
if (date.dayOfWeek !in settings.days) return null
if (date.dayOfWeek !in settings.days || settings.bedtime == settings.wakeTime) return null
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)
@@ -7,6 +7,8 @@ import de.jeanlucmakiola.clockula.bedtime.BedtimeEngine
import de.jeanlucmakiola.clockula.domain.TimeOfDay
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.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
@@ -29,6 +31,7 @@ import javax.inject.Inject
@HiltViewModel
class BedtimeViewModel @Inject internal constructor(
private val engine: BedtimeEngine,
@param:ApplicationScope private val appScope: CoroutineScope,
) : ViewModel() {
private val access = MutableStateFlow(engine.hasAccess())
@@ -47,7 +50,7 @@ class BedtimeViewModel @Inject internal constructor(
val granted = engine.hasAccess()
val changed = granted != access.value
access.value = granted
viewModelScope.launch {
appScope.launch {
if (changed) runCatching { engine.resync(refreshRule = true) }
ruleId.value = engine.ruleId()
}
@@ -78,8 +81,13 @@ class BedtimeViewModel @Inject internal constructor(
private fun updateEffects(transform: (BedtimeEffects) -> BedtimeEffects) =
update { it.copy(effects = transform(it.effects)) }
/**
* On the application scope, like the editors' writes: leaving the screen
* right after a change must not cancel it between storing the settings
* and arming the slot or creating the rule.
*/
private fun update(transform: (BedtimeSettings) -> BedtimeSettings) {
viewModelScope.launch {
appScope.launch {
runCatching { engine.update(transform) }
ruleId.value = engine.ruleId()
}
@@ -150,16 +150,9 @@ object ShellNavigation {
* missing or non-positive alarm id, or an empty id list, falls back to the
* Alarms tab (M9 D23).
*/
fun requestFor(
action: String?,
alarmId: Long?,
alarmIds: List<Long>,
forZenRule: Boolean = false,
): ShellRequest? =
fun requestFor(action: String?, alarmId: Long?, alarmIds: List<Long>): ShellRequest? =
when (action) {
// Settings opening the bedtime rule's configuration activity: an
// explicit component, a rule id extra, and no action (issue #1).
null -> if (forZenRule) ShellRequest.OpenBedtime else null
null -> null
InteropIntents.ACTION_OPEN_ALARM_EDITOR ->
if (alarmId != null && alarmId > 0L) {
ShellRequest.OpenAlarmEditor(alarmId)
+1 -1
View File
@@ -469,7 +469,7 @@
<string name="bedtime_effect_hide_aod">Hide the always-on display</string>
<string name="bedtime_effects_unavailable">Screen effects need Android 15 or later. On this phone, bedtime silences notifications and calls the way Do Not Disturb does.</string>
<string name="bedtime_open_mode">Open in system settings</string>
<string name="bedtime_open_mode_summary">Who can reach you, and more effects, under Settings › Modes. Changes you make there are kept.</string>
<string name="bedtime_open_mode_summary">Who can reach you, and more effects, under Settings › Modes. Changes you make there are kept while bedtime stays on.</string>
<string name="bedtime_alarms_note">Bedtime never silences an alarm or a timer: its mode always lets alarms through.</string>
<string name="bedtime_wellbeing_note">This is not Digital Wellbeing\'s Bedtime mode. Android lets only the system turn that one on, so if you use it too you will see two modes in Settings › Modes. Use one of them.</string>
<string name="bedtime_mode_name">Clockula bedtime</string>
@@ -205,6 +205,43 @@ class BedtimeEngineTest {
assertThat(h.notifier.showing).isFalse()
}
@Test
fun `a rule recreated during the night is switched on`(@TempDir tempDir: Path) = runTest {
val h = harness(tempDir, now = instantAt(BERLIN, "2026-06-10T23:30"))
h.engine.update(nightly)
// Deleted in the system's settings, then the app is launched.
h.mode.rule = null
h.mode.on = false
h.engine.resync()
assertThat(h.mode.rule).isNotNull()
assertThat(h.mode.on).isTrue()
}
@Test
fun `a late reminder says how long is actually left`(@TempDir tempDir: Path) = runTest {
val h = harness(tempDir)
h.engine.update { nightly(it).copy(reminderMinutes = 60) }
h.wallClock.instant = instantAt(BERLIN, "2026-06-10T22:41")
h.engine.onSlotFired()
assertThat(h.notifier.reminders.single().second).isEqualTo(19)
}
@Test
fun `an edit that cannot move a boundary leaves a hand-switched mode alone`(@TempDir tempDir: Path) = runTest {
val h = harness(tempDir, now = instantAt(BERLIN, "2026-06-10T23:30"))
h.engine.update(nightly)
// Switched off by hand for tonight.
h.mode.on = false
h.engine.update { it.copy(reminderMinutes = 30) }
assertThat(h.mode.on).isFalse()
}
@Test
fun `changing only the reminder does not rewrite the rule`(@TempDir tempDir: Path) = runTest {
val h = harness(tempDir)
@@ -103,14 +103,11 @@ class BedtimeScheduleTest {
}
@Test
fun `an equal bedtime and wake time is a whole day, back to back`() {
val always = everyNight.copy(wakeTime = TimeOfDay(23, 0))
fun `an equal bedtime and wake time is no window, never one that never ends`() {
val same = everyNight.copy(wakeTime = TimeOfDay(23, 0))
val window = BedtimeSchedule.activeWindow(always, instantAt(BERLIN, "2026-06-10T22:00"), BERLIN)
assertThat(window?.start).isEqualTo(offsetInstant("2026-06-09T23:00+02:00"))
assertThat(window?.end).isEqualTo(offsetInstant("2026-06-10T23:00+02:00"))
assertThat(BedtimeSchedule.activeWindow(always, instantAt(BERLIN, "2026-06-10T23:00"), BERLIN)).isNotNull()
assertThat(BedtimeSchedule.activeWindow(same, instantAt(BERLIN, "2026-06-10T23:30"), BERLIN)).isNull()
assertThat(BedtimeSchedule.nextEvent(same, instantAt(BERLIN, "2026-06-10T12:00"), BERLIN)).isNull()
}
@Test
@@ -37,19 +37,25 @@ class FakeBedtimeMode(
override fun hasAccess(): Boolean = access
override fun install(settings: BedtimeSettings, refresh: Boolean) {
if (!access) return
override fun install(settings: BedtimeSettings, refresh: Boolean): Boolean {
if (!access) return false
if (rule == null) {
rule = settings
} else if (refresh) {
// A new rule starts off, as the platform's does.
on = false
return true
}
if (refresh) {
rule = settings
refreshes++
}
return false
}
override fun setActive(active: Boolean) {
if (!access || rule == null) return
override fun setActive(active: Boolean): Boolean {
if (!access || rule == null) return false
on = active
return true
}
override fun uninstall() {
@@ -128,10 +128,11 @@ class ShellNavigationInteropTest {
fun `the bedtime reminder and the mode's settings page both open the bedtime screen`() {
assertThat(ShellNavigation.requestFor(BedtimeIntents.ACTION_SHOW_BEDTIME, null, emptyList()))
.isEqualTo(ShellRequest.OpenBedtime)
// Settings opens the rule's configuration activity with no action,
// only the component and the rule id.
assertThat(ShellNavigation.requestFor(null, null, emptyList(), forZenRule = true))
.isEqualTo(ShellRequest.OpenBedtime)
assertThat(ShellNavigation.requestFor(null, null, emptyList())).isNull()
// MainActivity turns Settings' action-less, rule-id-carrying intent
// into the same action; the extras themselves are its business.
assertThat(BedtimeIntents.ZEN_RULE_EXTRAS).containsExactly(
"android.app.extra.AUTOMATIC_RULE_ID",
"android.service.notification.extra.RULE_ID",
)
}
}
+12 -6
View File
@@ -2728,8 +2728,9 @@ A rule needs an owner `ConditionProviderService` or a configuration activity.
Clockula uses `MainActivity` as the configuration activity: it is already
exported, so no new component is exposed. Settings opens it with the explicit
component, the rule id in `NotificationManager.EXTRA_AUTOMATIC_RULE_ID` /
`ConditionProviderService.EXTRA_RULE_ID`, and **no action**, so the presence of
either extra is what makes the shell open the bedtime screen
`ConditionProviderService.EXTRA_RULE_ID`, and **no action**, so `MainActivity`
turns an action-less intent carrying either extra into the reminder's own
`ACTION_SHOW_BEDTIME`, and the shell opens the bedtime screen
(`ShellRequest.OpenBedtime`). There is no condition provider service:
Clockula reports the rule's state itself with `setAutomaticZenRuleState`.
@@ -2769,14 +2770,15 @@ 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 a full day), resolved from local times and the
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
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 | stores the settings, rewrites the rule if a field it carries changed, sets the mode to what the schedule says now (on at 23:30 for a 23:00 bedtime starts it at once), 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 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 |
| `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 |
@@ -2785,12 +2787,16 @@ reminder and the next window's start.
coexist with a schedule. The last state Clockula set is persisted
(`bedtime_applied_active`, device state, not in the JSON backup), and a pass
that is not an edit switches the mode only when the schedule now says
something different. A launch in the afternoon leaves a mode the user turned
something different, or when the rule was just recreated (a new rule starts
off). The state is recorded only once the system accepted it, so a refused
call is retried by the next pass. A launch in the afternoon leaves a mode the user turned
on alone; a launch after a force stop that slept through the wake-up time
turns it off. Each pass reads the clock **once**, so the mode's state and the
next slot always agree about which side of a boundary "now" is on. A posted
reminder is withdrawn by any pass once no coming bedtime is due within its
lead, so moving the schedule away from it does not leave a stale one.
lead, so moving the schedule away from it does not leave a stale one, and its
title counts the minutes actually left, so a late fire does not promise the
full lead.
Every bedtime call on a shared path runs **after** the alarm, timer and
stopwatch passes and inside `runCatching` (in `SystemEventReceiver`, in its