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:
1 parent
74c0c278ef
commit
817fe77b17
17 files changed
+215
-104
No files matched your search
@@ -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
-11
@@ -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
@@ -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
@@ -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).
|
||||
|
||||
Reference in new issue
Block a user