From 817fe77b17af924a884e79ba7f6f542d04c1c843 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Thu, 22 Oct 2026 12:54:02 +0200 Subject: [PATCH] fix(bedtime): record only accepted states, urgent wake-up, keep bedtime on old restores A refused state change is now remembered as owed and forced by every pass until it lands, instead of recording the opposite state, which could hide the next real boundary. Any refusal, in the rule rewrite or the state change, pulls the slot in to a retry, and the slot is armed in a finally so a throwing step can't leave it unarmed. The wake-up that ends the mode is armed allow-while-idle (one such fire a night), so Do Not Disturb no longer lingers into the morning behind a doze deferral. The manifest filter for the access-changed broadcast is gone: the platform only delivers it to runtime-registered receivers, and the screen's resume check covers it. A backup written before bedtime existed now keeps the current bedtime settings, and a restore goes through the engine's own field comparison, so a reminder-only difference leaves a hand-switched mode alone. Refs #1 --- app/src/main/AndroidManifest.xml | 11 +-- .../clockula/backup/BackupEngine.kt | 13 ++- .../clockula/bedtime/BedtimeEngine.kt | 93 +++++++++++++------ .../clockula/bedtime/BedtimeSeams.kt | 6 +- .../android/AndroidBedtimeScheduler.kt | 27 +++--- .../bedtime/receiver/BedtimeReceiver.kt | 18 +--- .../clockula/data/backup/BackupCodec.kt | 4 +- .../clockula/data/backup/BackupDto.kt | 2 +- .../clockula/data/prefs/BedtimePrefs.kt | 3 + .../clockula/data/prefs/SettingsPrefs.kt | 7 +- .../clockula/domain/backup/BackupContent.kt | 4 +- .../clockula/backup/BackupEngineTest.kt | 28 ++++++ .../clockula/bedtime/BedtimeEngineTest.kt | 33 +++++++ .../clockula/data/backup/BackupCodecTest.kt | 6 +- .../clockula/testing/FakeBedtimeSeams.kt | 6 +- docs/ARCHITECTURE.md | 55 +++++------ docs/BACKUP.md | 3 +- 17 files changed, 215 insertions(+), 104 deletions(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index fc436c2..db065b6 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -279,16 +279,11 @@ - + - - - - + android:exported="false" /> diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/backup/BackupEngine.kt b/app/src/main/java/de/jeanlucmakiola/clockula/backup/BackupEngine.kt index 42da075..3cdc85a 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/backup/BackupEngine.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/backup/BackupEngine.kt @@ -98,13 +98,12 @@ class BackupEngine @Inject constructor( // The bedtime settings came with the rest (or were rolled back // with them), so the slot and the Do Not Disturb rule follow. // Its platform calls are guarded already; a failure here is - // not a failed import. Nothing to follow if they never changed. - // Forced only when the bedtime settings really changed, so a - // restore of the same schedule leaves a hand-switched mode be. - if (settingsApplied) { - // After a rollback the stored settings are the previous ones. - val changed = rowsReplaced && previousSettings.bedtime != content.settings.bedtime - runCatching { bedtimeEngine.resync(refreshRule = changed) } + // not a failed import. The engine compares against what was + // stored before (after a rollback, the same again), so only a + // real change to the schedule or the rule re-asserts the mode. + val previousBedtime = previousSettings.bedtime + if (settingsApplied && previousBedtime != null) { + runCatching { bedtimeEngine.resyncAfterRestore(previousBedtime) } } } } 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 3bc6622..0dea305 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/BedtimeEngine.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/BedtimeEngine.kt @@ -66,7 +66,8 @@ class BedtimeEngine @Inject constructor( /** * Everything that can invalidate the slot or the rule: app launch (a * force stop clears the slot), a reboot, an app update, a clock or zone - * change, a backup restore, and Do Not Disturb access changing. The mode + * change, and Do Not Disturb access changing (a restore has + * [resyncAfterRestore]). The mode * is only switched when the schedule crossed a boundary since Clockula * last switched it, so a mode the user turned on or off by hand stays * that way until the next bedtime or wake-up. [refreshRule] also rewrites @@ -131,36 +132,74 @@ class BedtimeEngine @Inject constructor( } 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) + scheduler.schedule(now + RETRY_AFTER, urgent = false) } return } - val active = BedtimeSchedule.activeWindow(current, now, zone) != null - // A reminder that no longer points at a coming bedtime (it started, - // or the schedule changed under it) is stale. - if (BedtimeSchedule.dueReminder(current, now, zone) == null) notifier.cancelReminder() - if (mode.hasAccess()) { - // A rule that was just (re)created starts off, whatever was last - // applied to its predecessor, so it is switched regardless. - // 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 window = BedtimeSchedule.activeWindow(current, now, zone) val next = BedtimeSchedule.nextEvent(current, now, zone) - if (next == null) scheduler.cancel() else scheduler.schedule(next) + // Anything below may throw or be refused; the slot is armed whatever + // happens, and pulled in to a retry when the mode is not where it + // should be. + var retry = true + try { + // A reminder that no longer points at a coming bedtime (it started, + // or the schedule changed under it) is stale. + if (BedtimeSchedule.dueReminder(current, now, zone) == null) notifier.cancelReminder() + retry = mode.hasAccess() && !syncMode(current, active = window != null, refreshRule, force) + } finally { + arm(next, now, retry = retry, urgent = window != null && next == window.end) + } + } + + /** + * Brings the rule and its state in line; false when something is still + * owed. Only what the system accepted is recorded as applied. A refused + * rewrite or state change is remembered as owed and forced by every pass + * until it lands, so a refusal neither hides the next real boundary nor + * rests on the one caller that asked. + */ + private suspend fun syncMode(current: BedtimeSettings, active: Boolean, refreshRule: Boolean, force: Boolean): Boolean { + 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 + val owed = settings.bedtimeStateOwed() + var stateOk = true + if (force || reset || owed || active != settings.bedtimeAppliedActive()) { + stateOk = mode.setActive(active) + if (stateOk) settings.setBedtimeAppliedActive(active) + if (stateOk != !owed) settings.setBedtimeStateOwed(!stateOk) + } + return outcome != InstallOutcome.FAILED && stateOk + } + + /** + * The next event, or sooner as a retry. Only the wake-up boundary, which + * ends the mode, is [urgent]: one allow-while-idle fire a night, so the + * mode cannot linger into the morning behind a doze deferral. + */ + private fun arm(next: Instant?, now: Instant, retry: Boolean, urgent: Boolean) { + val retryAt = now + RETRY_AFTER + when { + retry && (next == null || retryAt < next) -> scheduler.schedule(retryAt, urgent = false) + next != null -> scheduler.schedule(next, urgent = urgent) + else -> scheduler.cancel() + } + } + + /** + * After a backup restore: the same field comparison an edit makes, against + * what was stored before, so restoring the same schedule (or one that only + * differs in its reminder) leaves a hand-switched mode alone. + */ + suspend fun resyncAfterRestore(previous: BedtimeSettings) = lock.withLock { + val current = settings.currentBedtime() + val refreshRule = previous.ruleFields() != current.ruleFields() + apply(current, refreshRule = refreshRule, force = refreshRule || previous.scheduleFields() != current.scheduleFields()) } private companion object { 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 57931a6..10bdbee 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/BedtimeSeams.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/BedtimeSeams.kt @@ -9,7 +9,11 @@ import kotlin.time.Instant * registration. */ interface BedtimeScheduler { - fun schedule(at: Instant) + /** + * [urgent] is the one event a night that may draw on the platform's + * allow-while-idle ration: the wake-up that ends the mode. + */ + fun schedule(at: Instant, urgent: Boolean) fun cancel() } diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/android/AndroidBedtimeScheduler.kt b/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/android/AndroidBedtimeScheduler.kt index 9002657..1d87f0d 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/android/AndroidBedtimeScheduler.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/android/AndroidBedtimeScheduler.kt @@ -16,16 +16,14 @@ import javax.inject.Singleton * One wall-clock slot for the schedule's next event. Never `setAlarmClock`: * that slot is the status bar's "next alarm", and bedtime is not an alarm. * - * And never *allow-while-idle*. Android rations allow-while-idle wake-ups - * per app while the device dozes (one per nine minutes on Android 10 and 11, - * and for the inexact fallback without exact-alarm access), and the timer's - * expiry slot and the alarm's fallback draw on the same ration. A 07:00 - * wake-up fire would hold back a timer due at 07:04 or a fallback alarm at - * 07:05. So the slot is exact but doze-deferrable: in deep doze it waits for - * the next maintenance window or for the next alarm clock to wake the device, - * which only moves a mode boundary, never a ring. It is its own PendingIntent - * (its own receiver, action and request code), so arming or cancelling it can - * never touch an alarm's or a timer's registration. + * Only the wake-up that ends the mode is allow-while-idle, so Do Not Disturb + * cannot linger hours into the morning behind a doze deferral. That is one + * such fire a night, well within the per-app ration the timer's expiry slot + * also uses (alarms themselves use `setAlarmClock`, which is not rationed). + * Bedtime's start, the reminder and retries are exact but doze-deferrable: + * a late start in a dozing, untouched phone costs nothing. It is its own + * PendingIntent (its own receiver, action and request code), so arming or + * cancelling it can never touch an alarm's or a timer's registration. */ @Singleton class AndroidBedtimeScheduler @Inject constructor( @@ -34,9 +32,16 @@ class AndroidBedtimeScheduler @Inject constructor( private val manager: AlarmManager = context.getSystemService(AlarmManager::class.java) - override fun schedule(at: Instant) { + override fun schedule(at: Instant, urgent: Boolean) { val millis = at.toEpochMilliseconds() val pendingIntent = slotPendingIntent() + if (urgent) { + val exact = runCatching { + manager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, millis, pendingIntent) + }.isSuccess + if (!exact) runCatching { manager.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, millis, pendingIntent) } + return + } val exact = runCatching { manager.setExact(AlarmManager.RTC_WAKEUP, millis, pendingIntent) }.isSuccess // Guarded too: bedtime losing its slot is survivable, a crash in the // receiver's process is not. diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/receiver/BedtimeReceiver.kt b/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/receiver/BedtimeReceiver.kt index 1f6ad1f..9da7ab2 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/receiver/BedtimeReceiver.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/receiver/BedtimeReceiver.kt @@ -1,6 +1,5 @@ package de.jeanlucmakiola.clockula.bedtime.receiver -import android.app.NotificationManager import android.content.Context import android.content.Intent import dagger.hilt.android.AndroidEntryPoint @@ -13,11 +12,10 @@ import kotlinx.coroutines.launch import javax.inject.Inject /** - * The bedtime slot arriving, and Do Not Disturb access changing. The second - * is sent by the system to this package only, so an unexported receiver gets - * it: granting access creates the rule at once, and revoking it leaves the - * slot running for the reminder (Settings removes the rule itself when the - * user revokes access there). + * The bedtime slot arriving. Do Not Disturb access changing is not heard + * here: the platform delivers `ACTION_NOTIFICATION_POLICY_ACCESS_GRANTED_CHANGED` + * to runtime-registered receivers only, so the bedtime screen re-reads access + * on every `ON_RESUME` instead, and any later pass picks a grant up. */ @AndroidEntryPoint class BedtimeReceiver : HiltBroadcastReceiver() { @@ -38,13 +36,7 @@ class BedtimeReceiver : HiltBroadcastReceiver() { try { // Guarded: a platform refusal here must not crash the process, // which is the process an alarm may be about to ring in. - runCatching { - when (action) { - BedtimeIntents.ACTION_SLOT -> engine.onSlotFired() - NotificationManager.ACTION_NOTIFICATION_POLICY_ACCESS_GRANTED_CHANGED -> - engine.resync(refreshRule = true) - } - } + if (action == BedtimeIntents.ACTION_SLOT) runCatching { engine.onSlotFired() } } finally { pendingResult.finish() } diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupCodec.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupCodec.kt index 025bc8c..1c2361c 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupCodec.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupCodec.kt @@ -131,7 +131,7 @@ object BackupCodec { dismissChallenge = defaults.dismissChallenge.name, homeZoneId = defaults.homeZoneId, timerPresets = timerPresets.map { it.inWholeMilliseconds }, - bedtime = bedtime.toDto(), + bedtime = bedtime?.toDto(), ) private fun BedtimeSettings.toDto() = BedtimeDto( @@ -274,7 +274,7 @@ object BackupCodec { homeZoneId = settingsDto.homeZoneId?.let(Zones::normalise), ), timerPresets = settingsDto.timerPresets?.map { it.milliseconds } ?: TimerPresets.BUILT_IN, - bedtime = settingsDto.bedtime?.toBedtime() ?: BedtimeSettings(), + bedtime = settingsDto.bedtime?.toBedtime(), ) return BackupResult.Success( diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupDto.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupDto.kt index f664ca4..ff20a7f 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupDto.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupDto.kt @@ -78,7 +78,7 @@ data class SettingsDto( * built-in ones. */ @SerialName("timer_presets") val timerPresets: List? = null, - /** Absent from files written before issue #1; restores as "off" with the default schedule. */ + /** Absent from files written before issue #1; restoring one keeps the current bedtime settings. */ val bedtime: BedtimeDto? = null, ) 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 74d6f62..702ed0c 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 @@ -61,6 +61,9 @@ object BedtimePrefs { /** A rule rewrite the system refused, still owed; device state like the above. */ val ruleStale: Pref = booleanPref("bedtime_rule_stale", false) + /** A state change the system refused, still owed; device state like the above. */ + val stateOwed: Pref = booleanPref("bedtime_state_owed", 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 b653303..781d86b 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 @@ -96,7 +96,8 @@ class SettingsPrefs @Inject constructor(private val store: PrefStore) { prefs[ClockPrefs.dismissChallenge] = backup.defaults.dismissChallenge prefs[ClockPrefs.homeZoneId] = backup.defaults.homeZoneId prefs[TimerPrefs.presets] = backup.timerPresets - prefs.putBedtime(backup.bedtime) + // Absent from a file written before bedtime existed: keep it. + backup.bedtime?.let { prefs.putBedtime(it) } } } @@ -186,6 +187,10 @@ class SettingsPrefs @Inject constructor(private val store: PrefStore) { suspend fun setBedtimeRuleStale(stale: Boolean) = store.set(BedtimePrefs.ruleStale, stale) + suspend fun bedtimeStateOwed(): Boolean = store.get(BedtimePrefs.stateOwed) + + suspend fun setBedtimeStateOwed(owed: Boolean) = store.set(BedtimePrefs.stateOwed, owed) + private fun MutablePreferences.putBedtime(settings: BedtimeSettings) { this[BedtimePrefs.enabled] = settings.enabled this[BedtimePrefs.bedtime] = settings.bedtime diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/backup/BackupContent.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/backup/BackupContent.kt index e20b821..04779cb 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/domain/backup/BackupContent.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/backup/BackupContent.kt @@ -57,6 +57,6 @@ data class BackupSettings( val dynamicColor: Boolean, val defaults: ClockDefaults, val timerPresets: List, - /** Absent from older backups, which restore it as [BedtimeSettings]' defaults (off). */ - val bedtime: BedtimeSettings = BedtimeSettings(), + /** Null for a file written before bedtime existed: restoring it keeps the current schedule. */ + val bedtime: BedtimeSettings? = null, ) diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/backup/BackupEngineTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/backup/BackupEngineTest.kt index 43b7261..5408624 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/backup/BackupEngineTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/backup/BackupEngineTest.kt @@ -93,6 +93,34 @@ class BackupEngineTest { assertThat(target.bedtimeScheduler.armedAt).isEqualTo(instantAt(BERLIN, "2026-06-10T07:00")) } + @Test + fun `a backup from before bedtime existed keeps the current bedtime`(@TempDir tempDir: Path) = runTest { + val harness = backupHarness(tempDir.resolve("bedtimeKept").also { it.toFile().mkdirs() }) + harness.bedtimeEngine.update { it.copy(enabled = true, bedtime = TimeOfDay(23, 0), wakeTime = TimeOfDay(7, 0)) } + val before = harness.settings.currentBedtime() + val ruleBefore = harness.bedtimeMode.rule + + harness.backupEngine.import(harness.backupEngine.export().let { it.copy(settings = it.settings.copy(bedtime = null)) }) + + assertThat(harness.settings.currentBedtime()).isEqualTo(before) + assertThat(harness.bedtimeMode.rule).isEqualTo(ruleBefore) + } + + @Test + fun `restoring a reminder-only change leaves a hand-switched mode alone`(@TempDir tempDir: Path) = runTest { + val harness = backupHarness(tempDir.resolve("bedtimeManual").also { it.toFile().mkdirs() }) + harness.bedtimeEngine.update { it.copy(enabled = true, bedtime = TimeOfDay(23, 0), wakeTime = TimeOfDay(7, 0)) } + // 05:00 is inside last night's window; the user switched it off by hand. + harness.bedtimeMode.on = false + val exported = harness.backupEngine.export() + + harness.backupEngine.import( + exported.copy(settings = exported.settings.copy(bedtime = exported.settings.bedtime!!.copy(reminderMinutes = 30))), + ) + + assertThat(harness.bedtimeMode.on).isFalse() + } + @Test fun `import never carries ring state`(@TempDir tempDir: Path) = runTest { val source = backupHarness(tempDir.resolve("source2").also { it.toFile().mkdirs() }) 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 f350083..767d6d0 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/bedtime/BedtimeEngineTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/bedtime/BedtimeEngineTest.kt @@ -313,6 +313,39 @@ class BedtimeEngineTest { assertThat(h.mode.rule?.effects?.grayscale).isFalse() } + @Test + fun `a refused state change arms a retry and does not hide the next boundary`(@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() + assertThat(h.scheduler.armedAt).isEqualTo(instantAt(BERLIN, "2026-06-10T23:15")) + + h.mode.refuseState = false + h.wallClock.instant = instantAt(BERLIN, "2026-06-10T23:15") + h.engine.onSlotFired() + assertThat(h.mode.on).isTrue() + assertThat(h.scheduler.armedAt).isEqualTo(offsetInstant("2026-06-11T07:00+02:00")) + } + + @Test + fun `only the wake-up that ends the mode is urgent`(@TempDir tempDir: Path) = runTest { + val h = harness(tempDir) + h.engine.update(nightly) + assertThat(h.scheduler.urgent).isFalse() + + h.wallClock.instant = instantAt(BERLIN, "2026-06-10T23:00") + h.engine.onSlotFired() + + assertThat(h.scheduler.armedAt).isEqualTo(offsetInstant("2026-06-11T07:00+02:00")) + assertThat(h.scheduler.urgent).isTrue() + } + @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/data/backup/BackupCodecTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/data/backup/BackupCodecTest.kt index f4bbe52..5584361 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/data/backup/BackupCodecTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/data/backup/BackupCodecTest.kt @@ -279,9 +279,9 @@ class BackupCodecTest { } @Test - fun `a file from before bedtime existed restores it off`() { + fun `a file from before bedtime existed carries no bedtime, so restoring it keeps the current one`() { val settings = (BackupCodec.decode(minimalDocument()) as BackupResult.Success).content.settings - assertThat(settings.bedtime).isEqualTo(BedtimeSettings()) + assertThat(settings.bedtime).isNull() } @Test @@ -291,7 +291,7 @@ class BackupCodecTest { "\"settings\": {\"bedtime\": {\"enabled\": true, \"bedtime_minutes\": 99999, " + "\"wake_minutes\": -5, \"days\": 255, \"reminder_minutes\": 999}}", ) - val bedtime = (BackupCodec.decode(json) as BackupResult.Success).content.settings.bedtime + val bedtime = (BackupCodec.decode(json) as BackupResult.Success).content.settings.bedtime!! assertThat(bedtime.enabled).isTrue() assertThat(bedtime.bedtime).isEqualTo(TimeOfDay(23, 59)) assertThat(bedtime.wakeTime).isEqualTo(TimeOfDay(0, 0)) 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 a592679..c5bca82 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeBedtimeSeams.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeBedtimeSeams.kt @@ -11,13 +11,17 @@ import kotlin.time.Instant class FakeBedtimeScheduler : BedtimeScheduler { var armedAt: Instant? = null private set + var urgent: Boolean = false + private set - override fun schedule(at: Instant) { + override fun schedule(at: Instant, urgent: Boolean) { armedAt = at + this.urgent = urgent } override fun cancel() { armedAt = null + urgent = false } } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 7a0c414..89f1d5e 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`, `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) | +| 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`, `bedtime_state_owed` | `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. @@ -2759,17 +2759,16 @@ engines (`ArchitectureRulesTest` holds it to no `android.*` import): - `BedtimeScheduler`, **one** AlarmManager slot of its own. Its PendingIntent targets `BedtimeReceiver` with its own action and request code, so arming or cancelling it cannot replace or cancel an alarm's or a timer's registration. - It is `RTC_WAKEUP` and `setExact` (a ten-minute `setWindow` without - exact-alarm access, since plain `set`'s slack grows with the distance), - and deliberately **not** allow-while-idle: Android rations allow-while-idle - wake-ups per app in doze (one per nine minutes on Android 10 and 11, and for - the inexact fallback without exact-alarm access), and the timer's expiry slot - and the alarm's fallback draw on that same ration, so a 07:00 wake-up fire - could hold back a timer at 07:04 or a fallback alarm at 07:05. In deep doze - the slot therefore waits for a maintenance window or for the next alarm - clock to wake the device. That can move a mode boundary, never a ring. It - never uses `setAlarmClock` either: that slot is the status bar's next - *alarm*. + It is `RTC_WAKEUP`. Only the wake-up that ends the mode is allow-while-idle + (`setExactAndAllowWhileIdle`, falling back to `setAndAllowWhileIdle`), so + Do Not Disturb cannot linger hours into the morning behind a doze deferral. + That is one such fire a night, well inside the per-app allow-while-idle + ration the timer's expiry slot also draws on (alarms themselves use + `setAlarmClock`, which is not rationed). Bedtime's start, the reminder and + retries are `setExact` (a ten-minute `setWindow` without exact-alarm + access), so they never spend that ration; in deep doze they wait for a + maintenance window, which only delays a start in a phone nobody is using. + It never uses `setAlarmClock`: that slot is the status bar's next *alarm*. - `BedtimeMode`, the rule. Every call is guarded and a no-op without access. - `BedtimeNotifier`, the reminder, on its own `bedtime_reminder` channel. @@ -2791,19 +2790,22 @@ reminder and the next window's start. | `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 or the rule changed sets the mode to what it says now (on at 23:30 for a 23:00 bedtime starts it at once; a reminder edit leaves a hand-switched mode alone), arms the slot. The screen's writes start undispatched, so they queue on the engine's fair lock in the order they were made. 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. A restore re-runs it only if it got as far as applying settings | +| `resync(refreshRule = true)` | Do Not Disturb access changing (the screen's `ON_RESUME` check) | the same, but also rewrites the rule and re-asserts the mode's state | +| `resyncAfterRestore(previous)` | a backup restore that got as far as applying settings | compares what is stored now with what was stored before, field by field as an edit does, so restoring the same schedule, or one that differs only in its reminder, leaves a hand-switched mode alone | "Follows any boundary crossed" is the one rule that lets a hand-switched mode 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) 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 +off) or rewritten (Android 10 to 14 clear the condition on an update). Only +what the system accepted is recorded as applied. A refused state change is +remembered as owed (`bedtime_state_owed`) and a refused rule rewrite as stale +(`bedtime_rule_stale`), and every pass forces them until they land; meanwhile +the slot is pulled in to a retry fifteen minutes out, and a refused removal +when turning bedtime off keeps the slot armed the same way. The slot is armed +in a `finally`, so a throwing step (a DataStore write, a cancel) cannot leave +it unarmed. 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 @@ -2834,9 +2836,10 @@ onboarding row while it is missing, deep-linking to `Settings.ACTION_NOTIFICATION_POLICY_ACCESS_SETTINGS`, and re-reads access on every `ON_RESUME`; a change found there runs `resync(refreshRule = true)` at once (also when access is there but no rule exists yet, for a grant made while -the screen was closed), so coming back from granting it creates the mode without waiting for the -system's `ACTION_NOTIFICATION_POLICY_ACCESS_GRANTED_CHANGED` (which -`BedtimeReceiver` also answers). Without access the schedule still runs for the +the screen was closed), so coming back from granting it creates the mode. +The system's `ACTION_NOTIFICATION_POLICY_ACCESS_GRANTED_CHANGED` is not +listened for: it reaches runtime-registered receivers only, and the next +slot fire or launch picks a grant up anyway. Without access the schedule still runs for the reminder. Revoking access in Settings removes the app's rules with it (that is Settings' own confirmation dialog), and Clockula cannot remove a rule without access, so a rule left by a revoke from outside Settings is the platform's. @@ -2848,8 +2851,8 @@ still checks that no rule of Clockula's is left). ### Backup The settings travel in the backup's `settings.bedtime` object -(`docs/BACKUP.md`); a file from before issue #1 restores bedtime as off, and -one with no nights at all restores every night. -`BackupEngine.import` resyncs the engine after the replace, or after rolling -it back. +(`docs/BACKUP.md`); a file from before issue #1 carries no `bedtime` and +keeps the device's current bedtime settings, and one with no nights at all +restores every night. `BackupEngine.import` hands the engine what was stored +before (`resyncAfterRestore`), after the replace or after rolling it back. diff --git a/docs/BACKUP.md b/docs/BACKUP.md index f212994..9be0f44 100644 --- a/docs/BACKUP.md +++ b/docs/BACKUP.md @@ -155,7 +155,8 @@ device or after a reboot (`docs/PLAN.md` §5). Every imported timer lands since local midnight, `days` is the same 7-bit mask as an alarm's `repeat_days` (the nights bedtime is for, named by their evening), and `reminder_minutes` 0 means no reminder. A file without `bedtime` (any file written before it existed) -restores bedtime as off with the default schedule. Like the other settings it +leaves the device's current bedtime settings as they are, the one setting a +restore does not replace when its file is silent about it. Like the other settings it is clamped rather than rejected: times into a day, the reminder into 0–120 minutes, and a `days` of 0 (which the screen never allows) into every night. The id of the Do Not Disturb rule a device created is not part of it; the importing device creates its own (`docs/ARCHITECTURE.md` §18).