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