diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/MainActivity.kt b/app/src/main/java/de/jeanlucmakiola/clockula/MainActivity.kt index a817945..35b6ffa 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/MainActivity.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/MainActivity.kt @@ -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() { 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 fac51ee..22a0d88 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/BedtimeEngine.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/BedtimeEngine.kt @@ -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) } diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/BedtimeSeams.kt b/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/BedtimeSeams.kt index 8192ed5..673aa21 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/BedtimeSeams.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/BedtimeSeams.kt @@ -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() diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/android/AndroidBedtimeMode.kt b/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/android/AndroidBedtimeMode.kt index 0319d06..4ce2c30 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/android/AndroidBedtimeMode.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/android/AndroidBedtimeMode.kt @@ -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) { diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/BedtimePrefs.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/BedtimePrefs.kt index 68810ef..9693dad 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/BedtimePrefs.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/BedtimePrefs.kt @@ -24,9 +24,16 @@ object BedtimePrefs { val wakeTime: Pref = 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 = 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 = intPref("bedtime_reminder_minutes", DEFAULT.reminderMinutes) .map( 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 09e5b67..4517a4f 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,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) diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/bedtime/BedtimeViewModel.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/bedtime/BedtimeViewModel.kt index a50eddc..4c0a011 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/bedtime/BedtimeViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/bedtime/BedtimeViewModel.kt @@ -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() } diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/shell/ShellNavigation.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/shell/ShellNavigation.kt index 13cce4b..c3d0209 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/shell/ShellNavigation.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/shell/ShellNavigation.kt @@ -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, - forZenRule: Boolean = false, - ): ShellRequest? = + fun requestFor(action: String?, alarmId: Long?, alarmIds: List): 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) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 06d57cb..ebcb347 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -469,7 +469,7 @@ Hide the always-on display Screen effects need Android 15 or later. On this phone, bedtime silences notifications and calls the way Do Not Disturb does. Open in system settings - Who can reach you, and more effects, under Settings › Modes. Changes you make there are kept. + Who can reach you, and more effects, under Settings › Modes. Changes you make there are kept while bedtime stays on. Bedtime never silences an alarm or a timer: its mode always lets alarms through. 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. Clockula bedtime 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 aeba63b..2e9126e 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/bedtime/BedtimeEngineTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/bedtime/BedtimeEngineTest.kt @@ -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) 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 9b48ad6..49fcc58 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 @@ -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 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 6cef44e..7114b22 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeBedtimeSeams.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeBedtimeSeams.kt @@ -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() { diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/ui/shell/ShellNavigationInteropTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/ui/shell/ShellNavigationInteropTest.kt index bc3ce5c..581051c 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/ui/shell/ShellNavigationInteropTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/ui/shell/ShellNavigationInteropTest.kt @@ -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", + ) } } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 10c3e25..40e18a1 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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