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
This commit is contained in:
makiolaj committed 2026-10-22 12:54:02 +02:00
1 parent 74c0c278ef
commit 817fe77b17
17 files changed
+215 -104

No files matched your search

+3 -8
View File
@@ -279,16 +279,11 @@
</intent-filter>
</receiver>
<!-- The bedtime slot arriving, and Do Not Disturb access changing. The
second is a system broadcast addressed to this package, so the
receiver stays unexported (issue #1). -->
<!-- The bedtime slot arriving (issue #1). Unexported, like the alarm's
and the timer's: only the PendingIntent the app made reaches it. -->
<receiver
android:name=".bedtime.receiver.BedtimeReceiver"
android:exported="false">
<intent-filter>
<action android:name="android.app.action.NOTIFICATION_POLICY_ACCESS_GRANTED_CHANGED" />
</intent-filter>
</receiver>
android:exported="false" />
<!-- Persists the per-app language on API < 33, where the platform
per-app-languages API is unavailable. On 33+ this is a no-op. -->
@@ -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) }
}
}
}
@@ -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 {
@@ -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()
}
@@ -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.
@@ -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()
}
@@ -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(
@@ -78,7 +78,7 @@ data class SettingsDto(
* built-in ones.
*/
@SerialName("timer_presets") val timerPresets: List<Long>? = 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,
)
@@ -61,6 +61,9 @@ object BedtimePrefs {
/** A rule rewrite the system refused, still owed; device state like the above. */
val ruleStale: Pref<Boolean> = booleanPref("bedtime_rule_stale", false)
/** A state change the system refused, still owed; device state like the above. */
val stateOwed: Pref<Boolean> = booleanPref("bedtime_state_owed", false)
/** Minutes since midnight on disk, clamped into a day both ways. */
private fun Pref<Int>.asTimeOfDay(): Pref<TimeOfDay> = map(
decode = { minutes ->
@@ -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
@@ -57,6 +57,6 @@ data class BackupSettings(
val dynamicColor: Boolean,
val defaults: ClockDefaults,
val timerPresets: List<Duration>,
/** 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,
)
@@ -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() })
@@ -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)
@@ -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))
@@ -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
}
}
+29 -26
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`, `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) |
| 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<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.
@@ -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.
+2 -1
View File
@@ -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).