fix(bedtime): retry refused removals, state changes and rule rewrites

A removal the system refuses while turning bedtime off now keeps the slot
armed fifteen minutes out as its retry, instead of cancelling it and
leaving Do Not Disturb on until some unrelated launch. A refused state
change records the opposite state so the next pass retries it, and a
rule that was rewritten (which clears its condition on Android 10-14) is
switched regardless. A refused rewrite is remembered and retried by every
pass until it lands, rather than resting on the one edit that asked.

Refs #1
This commit is contained in:
makiolaj committed 2026-10-22 12:41:15 +02:00
1 parent 131a28c1be
commit 74c0c278ef
8 files changed
+113 -29

No files matched your search

@@ -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)
@@ -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)
@@ -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 =
@@ -58,6 +58,9 @@ object BedtimePrefs {
*/
val appliedActive: Pref<Boolean> = booleanPref("bedtime_applied_active", false)
/** A rule rewrite the system refused, still owed; device state like the above. */
val ruleStale: Pref<Boolean> = booleanPref("bedtime_rule_stale", false)
/** Minutes since midnight on disk, clamped into a day both ways. */
private fun Pref<Int>.asTimeOfDay(): Pref<TimeOfDay> = map(
decode = { minutes ->
@@ -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
@@ -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)
@@ -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
}
+7 -3
View File
@@ -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<BedtimeSettings>` 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<BedtimeSettings>` 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