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 496fdd0..3bc6622 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/BedtimeEngine.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/BedtimeEngine.kt @@ -117,17 +117,22 @@ class BedtimeEngine @Inject constructor( if (!force && !refreshRule && !settings.bedtimeAppliedActive() && (!mode.hasAccess() || mode.ruleId() == null) ) { + scheduler.cancel() return } - scheduler.cancel() notifier.cancelReminder() // 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. - // 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) + if (removed) { + settings.setBedtimeAppliedActive(false) + scheduler.cancel() + } else { + // Refused: keep the slot as the retry, so the mode cannot stay + // on until some unrelated launch or reboot happens along. + scheduler.schedule(now + RETRY_AFTER) + } return } val active = BedtimeSchedule.activeWindow(current, now, zone) != null @@ -137,17 +142,31 @@ class BedtimeEngine @Inject constructor( if (mode.hasAccess()) { // 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) + // A rewrite that failed is remembered and retried by every pass + // until it lands, rather than resting on the one caller that asked. + val stale = refreshRule || settings.bedtimeRuleStale() + val outcome = mode.install(current, refresh = stale) + if (stale) settings.setBedtimeRuleStale(outcome == InstallOutcome.FAILED) + // A rule that was just (re)created or rewritten may start off + // (Android 10 to 14 clear the condition on an update), so it is + // switched regardless of what was last applied. + val reset = outcome == InstallOutcome.CREATED || outcome == InstallOutcome.UPDATED + if (force || reset || active != settings.bedtimeAppliedActive()) { + // A refused call records the opposite, so the next pass sees a + // difference and retries instead of trusting a state that never + // reached the system. + val accepted = mode.setActive(active) + settings.setBedtimeAppliedActive(if (accepted) active else !active) } } val next = BedtimeSchedule.nextEvent(current, now, zone) if (next == null) scheduler.cancel() else scheduler.schedule(next) } + private companion object { + val RETRY_AFTER = 15.minutes + } + /** What the system rule is built from; the reminder is not part of it. */ private fun BedtimeSettings.ruleFields() = listOf(enabled, bedtime, wakeTime, effects) 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 ed3187b..57931a6 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/BedtimeSeams.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/BedtimeSeams.kt @@ -28,11 +28,10 @@ interface BedtimeMode { val supportsEffects: Boolean /** - * 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. + * 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. */ - fun install(settings: BedtimeSettings, refresh: Boolean): Boolean + fun install(settings: BedtimeSettings, refresh: Boolean): InstallOutcome /** Turns the mode on or off; false when there is no rule or the system refused. */ fun setActive(active: Boolean): Boolean @@ -44,6 +43,9 @@ interface BedtimeMode { fun ruleId(): String? } +/** What [BedtimeMode.install] did. */ +enum class InstallOutcome { UNCHANGED, CREATED, UPDATED, FAILED } + /** The reminder notification. */ interface BedtimeNotifier { fun showReminder(start: Instant, minutesBefore: Int) 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 3d42c39..7139919 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 @@ -14,6 +14,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext import de.jeanlucmakiola.clockula.MainActivity import de.jeanlucmakiola.clockula.R import de.jeanlucmakiola.clockula.bedtime.BedtimeMode +import de.jeanlucmakiola.clockula.bedtime.InstallOutcome import de.jeanlucmakiola.clockula.domain.TimeOfDay import de.jeanlucmakiola.clockula.domain.bedtime.BedtimeSettings import java.time.LocalTime @@ -60,8 +61,8 @@ class AndroidBedtimeMode @Inject constructor( override fun hasAccess(): Boolean = guarded(false) { manager.isNotificationPolicyAccessGranted } - override fun install(settings: BedtimeSettings, refresh: Boolean): Boolean = - guarded(false) { + override fun install(settings: BedtimeSettings, refresh: Boolean): InstallOutcome = + guarded(InstallOutcome.FAILED) { 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 +70,7 @@ class AndroidBedtimeMode @Inject constructor( val existing = ours.entries.firstOrNull() if (existing == null) { manager.addAutomaticZenRule(rule(settings, enabled = true)) - return@guarded true + return@guarded InstallOutcome.CREATED } if (refresh) { // Keeps what the user set for the rule in Settings: its on/off @@ -80,8 +81,9 @@ class AndroidBedtimeMode @Inject constructor( existing.key, rule(settings, enabled = existing.value.isEnabled, kept = existing.value.zenPolicy), ) + return@guarded InstallOutcome.UPDATED } - false + InstallOutcome.UNCHANGED } override fun setActive(active: Boolean): Boolean = 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 0f88a1f..74d6f62 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 @@ -58,6 +58,9 @@ object BedtimePrefs { */ val appliedActive: Pref = booleanPref("bedtime_applied_active", false) + /** A rule rewrite the system refused, still owed; device state like the above. */ + val ruleStale: Pref = booleanPref("bedtime_rule_stale", false) + /** Minutes since midnight on disk, clamped into a day both ways. */ private fun Pref.asTimeOfDay(): Pref = map( decode = { minutes -> diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefs.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefs.kt index a85904e..b653303 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefs.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefs.kt @@ -182,6 +182,10 @@ class SettingsPrefs @Inject constructor(private val store: PrefStore) { suspend fun setBedtimeAppliedActive(active: Boolean) = store.set(BedtimePrefs.appliedActive, active) + suspend fun bedtimeRuleStale(): Boolean = store.get(BedtimePrefs.ruleStale) + + suspend fun setBedtimeRuleStale(stale: Boolean) = store.set(BedtimePrefs.ruleStale, stale) + private fun MutablePreferences.putBedtime(settings: BedtimeSettings) { this[BedtimePrefs.enabled] = settings.enabled this[BedtimePrefs.bedtime] = settings.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 16068d1..f350083 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/bedtime/BedtimeEngineTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/bedtime/BedtimeEngineTest.kt @@ -268,6 +268,51 @@ class BedtimeEngineTest { assertThat(h.mode.rule).isNull() } + @Test + fun `a refused removal at night keeps a retry slot`(@TempDir tempDir: Path) = runTest { + val h = harness(tempDir, now = instantAt(BERLIN, "2026-06-10T23:30")) + h.engine.update(nightly) + h.mode.refuseRemoval = true + + h.engine.update { it.copy(enabled = false) } + + assertThat(h.scheduler.armedAt).isEqualTo(instantAt(BERLIN, "2026-06-10T23:45")) + h.mode.refuseRemoval = false + h.wallClock.instant = instantAt(BERLIN, "2026-06-10T23:45") + h.engine.onSlotFired() + assertThat(h.mode.rule).isNull() + assertThat(h.scheduler.armedAt).isNull() + } + + @Test + fun `a refused state change is retried by the next pass`(@TempDir tempDir: Path) = runTest { + val h = harness(tempDir) + h.engine.update(nightly) + h.mode.refuseState = true + h.wallClock.instant = instantAt(BERLIN, "2026-06-10T23:00") + h.engine.onSlotFired() + assertThat(h.mode.on).isFalse() + + h.mode.refuseState = false + h.engine.resync() + + assertThat(h.mode.on).isTrue() + } + + @Test + fun `a refused rule rewrite is retried by the next pass`(@TempDir tempDir: Path) = runTest { + val h = harness(tempDir) + h.engine.update(nightly) + h.mode.refuseUpdate = true + h.engine.update { it.copy(effects = it.effects.copy(grayscale = false)) } + assertThat(h.mode.rule?.effects?.grayscale).isTrue() + + h.mode.refuseUpdate = false + h.engine.resync() + + assertThat(h.mode.rule?.effects?.grayscale).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/testing/FakeBedtimeSeams.kt b/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeBedtimeSeams.kt index d84d529..a592679 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeBedtimeSeams.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeBedtimeSeams.kt @@ -3,6 +3,7 @@ package de.jeanlucmakiola.clockula.testing import de.jeanlucmakiola.clockula.bedtime.BedtimeMode import de.jeanlucmakiola.clockula.bedtime.BedtimeNotifier import de.jeanlucmakiola.clockula.bedtime.BedtimeScheduler +import de.jeanlucmakiola.clockula.bedtime.InstallOutcome import de.jeanlucmakiola.clockula.domain.bedtime.BedtimeSettings import kotlin.time.Instant @@ -37,23 +38,27 @@ class FakeBedtimeMode( override fun hasAccess(): Boolean = access - override fun install(settings: BedtimeSettings, refresh: Boolean): Boolean { - if (!access) return false + /** The system refusing a rewrite or a state change. */ + var refuseUpdate: Boolean = false + var refuseState: Boolean = false + + override fun install(settings: BedtimeSettings, refresh: Boolean): InstallOutcome { + if (!access) return InstallOutcome.FAILED if (rule == null) { rule = settings // A new rule starts off, as the platform's does. on = false - return true + return InstallOutcome.CREATED } - if (refresh) { - rule = settings - refreshes++ - } - return false + if (!refresh) return InstallOutcome.UNCHANGED + if (refuseUpdate) return InstallOutcome.FAILED + rule = settings + refreshes++ + return InstallOutcome.UPDATED } override fun setActive(active: Boolean): Boolean { - if (!access || rule == null) return false + if (!access || rule == null || refuseState) return false on = active return true } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 19d4346..7a0c414 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -602,7 +602,7 @@ Four slices: | Stopwatch run record (M2) | `stopwatch_state`, `stopwatch_started_elapsed_millis`, `stopwatch_accumulated_millis`, `stopwatch_last_lap_cumulative_millis` (M7 is the first caller of the last, for the in-progress lap's split) | `StopwatchPrefs`, behind `StopwatchStateStore` | | System facts (M3) | `last_boot_id` | `SystemPrefs`, behind `BootStateStore`, the persisted half of the reboot gate | | Timers (M6) | `timer_presets`, `timer_ring_sounding_since_millis` | `TimerPrefs`: the presets surfaced as `SettingsPrefs.timerPresets`, the ring session's anchor behind `TimerRingStateStore` | -| Bedtime (issue #1) | `bedtime_enabled`, `bedtime_start_minutes`, `bedtime_wake_minutes`, `bedtime_days`, `bedtime_reminder_minutes`, `bedtime_effect_grayscale`, `bedtime_effect_dim_wallpaper`, `bedtime_effect_dark_theme`, `bedtime_effect_hide_aod`, `bedtime_applied_active` | `BedtimePrefs`, surfaced as `SettingsPrefs.bedtime: Flow` and written as one record by `BedtimeEngine` only. Times clamp into a day, the reminder into 0–120 minutes, the days through `RepeatDays.fromMask`. The system rule's id is **not** stored (§18) | +| Bedtime (issue #1) | `bedtime_enabled`, `bedtime_start_minutes`, `bedtime_wake_minutes`, `bedtime_days`, `bedtime_reminder_minutes`, `bedtime_effect_grayscale`, `bedtime_effect_dim_wallpaper`, `bedtime_effect_dark_theme`, `bedtime_effect_hide_aod`, `bedtime_applied_active`, `bedtime_rule_stale` | `BedtimePrefs`, surfaced as `SettingsPrefs.bedtime: Flow` and written as one record by `BedtimeEngine` only. Times clamp into a day, the reminder into 0–120 minutes, the days through `RepeatDays.fromMask`. The system rule's id is **not** stored (§18) | The stopwatch's *run* is a single record, so it lives in DataStore rather than as a one-row table (`PLAN.md` §5); its *laps* are a list, so they live in Room. @@ -2798,8 +2798,12 @@ 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, 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 +off) or rewritten (Android 10 to 14 clear the condition on an update). A +refused state change records the opposite state, so the next pass sees a +difference and retries; a refused rule rewrite is remembered +(`bedtime_rule_stale`) and retried by every pass until it lands; and a refused +removal when turning bedtime off keeps the slot armed fifteen minutes out as +its retry. 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