fix(bedtime): follow boundaries, open from Settings, keep the user's policy

Settings opens a rule's configuration activity with no action, only the
component and the rule id, so MainActivity now routes on the rule-id
extras. Each engine pass reads the clock once, so the mode's state and the
next slot can't disagree around a boundary, and non-edit passes switch the
mode only when the schedule crossed a boundary since Clockula last set it
(persisted), which also catches up a wake-up missed during a force stop
while leaving a mode switched on by hand alone. A reminder fire that also
ends a window now turns the mode off, and a reminder the schedule moved
away from is withdrawn.

The rule is only rewritten when a field it carries changes, and a rewrite
keeps the existing policy if it still allows alarms, so Android 10-14 no
longer loses the user's Do Not Disturb edits. The bedtime receiver, screen
and fallback alarm call are guarded, the boot-path resync runs even if an
earlier engine failed, and a backup with no nights restores every night.

Refs #1
This commit is contained in:
makiolaj committed 2026-10-22 11:29:33 +02:00
1 parent 69b436cffd
commit 119197106b
18 files changed
+214 -99

No files matched your search

@@ -87,9 +87,9 @@ class ClockulaApp : Application() {
// run, leaving it paused at what it banked (M7 D17).
stopwatchEngine.onBootCompleted()
// Last and guarded, like every bedtime call on a shared path: a
// force stop clears its AlarmManager slot too, so the slot is
// re-armed here. The mode's own state is left as it is.
runCatching { bedtimeEngine.rearm() }
// force stop clears its AlarmManager slot too, and may have slept
// through a bedtime or wake-up, so both are caught up here.
runCatching { bedtimeEngine.resync() }
}
// Never completes on its own — it is the widgets' one refresh trigger
@@ -16,6 +16,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dagger.hilt.android.AndroidEntryPoint
import de.jeanlucmakiola.clockula.bedtime.BedtimeIntents
import de.jeanlucmakiola.clockula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.clockula.interop.InteropIntents
import de.jeanlucmakiola.clockula.ui.crash.CrashReportActivity
@@ -165,7 +166,8 @@ class MainActivity : ComponentActivity() {
.getOrNull()
?.toList()
.orEmpty()
return ShellNavigation.requestFor(intent.action, alarmId, alarmIds)
val forZenRule = runCatching { BedtimeIntents.ZEN_RULE_EXTRAS.any(intent::hasExtra) }.getOrDefault(false)
return ShellNavigation.requestFor(intent.action, alarmId, alarmIds, forZenRule)
}
override fun onResume() {
@@ -101,11 +101,12 @@ class SystemEventReceiver : HiltBroadcastReceiver() {
MidnightRefresh.schedule(context)
}
}
} finally {
// Last, and on its own: every one of these events can move or
// clear the bedtime slot too, but nothing bedtime does may
// delay or break the alarm and timer passes above.
// delay or break the alarm and timer passes above, and a
// failure up there must not cost bedtime its pass either.
runCatching { bedtimeEngine.resync() }
} finally {
pendingResult.finish()
}
}
@@ -8,6 +8,7 @@ import de.jeanlucmakiola.clockula.domain.time.ZoneProvider
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlin.time.Instant
import javax.inject.Inject
import javax.inject.Singleton
@@ -41,50 +42,41 @@ class BedtimeEngine @Inject constructor(
/**
* A change made on the bedtime screen: stored, then applied at once. The
* rule is rewritten from the new settings, and the mode is switched to
* what the new schedule says now, so turning bedtime on at 23:30 for a
* 23:00 bedtime starts it straight away.
* rule is rewritten when a field it carries changed, and the mode is
* switched to what the new schedule says now, so turning bedtime on at
* 23:30 for a 23:00 bedtime starts it straight away.
*/
suspend fun update(transform: (BedtimeSettings) -> BedtimeSettings) = lock.withLock {
val updated = transform(settings.currentBedtime())
val previous = settings.currentBedtime()
val updated = transform(previous)
settings.setBedtime(updated)
apply(updated, refreshRule = true)
apply(updated, refreshRule = previous.ruleFields() != updated.ruleFields(), force = true)
}
/**
* Everything that can invalidate the slot or the rule: a reboot, an app
* update, a clock or zone change, a backup restore, and Do Not Disturb
* access being granted or revoked.
* 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
* 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
* the rule and re-asserts the mode's state.
*/
suspend fun resync(refreshRule: Boolean = false) = lock.withLock {
apply(settings.currentBedtime(), refreshRule = refreshRule)
apply(settings.currentBedtime(), refreshRule = refreshRule, force = refreshRule)
}
/**
* App launch: re-arm the slot only. AlarmManager forgets it on a force
* stop, but the mode's state is left alone here — a launch is not a
* bedtime event, and a mode the user switched on or off by hand should
* stay that way until the next one.
*/
suspend fun rearm() = lock.withLock {
schedule(settings.currentBedtime())
}
/**
* The slot arriving. A reminder fire posts the reminder and leaves the
* mode alone; any other fire switches the mode to what the schedule says
* now. Either way the next event is armed.
* The slot arriving: the mode follows any boundary crossed, a due
* reminder is posted, and the next event is armed. A reminder fire alone
* crosses no boundary, so it leaves the mode as it is.
*/
suspend fun onSlotFired() = lock.withLock {
val current = settings.currentBedtime()
val now = wallClock.now()
val zone = zones.current()
val reminderFor = BedtimeSchedule.dueReminder(current, now, zone)
if (reminderFor != null) {
notifier.showReminder(reminderFor.start, current.reminderMinutes)
schedule(current)
} else {
apply(current, refreshRule = false)
apply(current, refreshRule = false, force = false, now = now)
BedtimeSchedule.dueReminder(current, now, zones.current())?.let { window ->
notifier.showReminder(window.start, current.reminderMinutes)
}
}
@@ -95,7 +87,17 @@ class BedtimeEngine @Inject constructor(
fun ruleId(): String? = mode.ruleId()
private fun apply(current: BedtimeSettings, refreshRule: Boolean) {
/**
* One pass, over one reading of the clock: the mode's state and the next
* slot must agree about which side of a boundary "now" is on.
*/
private suspend fun apply(
current: BedtimeSettings,
refreshRule: Boolean,
force: Boolean,
now: Instant = wallClock.now(),
) {
val zone = zones.current()
if (!current.enabled) {
scheduler.cancel()
notifier.cancelReminder()
@@ -103,20 +105,24 @@ class BedtimeEngine @Inject constructor(
// is nothing Clockula may remove; revoking access in Settings
// already removed the rule.
if (mode.hasAccess()) mode.uninstall()
settings.setBedtimeAppliedActive(false)
return
}
val now = wallClock.now()
val active = BedtimeSchedule.activeWindow(current, now, zones.current()) != null
if (active) notifier.cancelReminder()
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()) {
mode.install(current, refresh = refreshRule)
mode.setActive(active)
if (force || active != settings.bedtimeAppliedActive()) {
mode.setActive(active)
settings.setBedtimeAppliedActive(active)
}
}
schedule(current)
}
private fun schedule(current: BedtimeSettings) {
val next = BedtimeSchedule.nextEvent(current, wallClock.now(), zones.current())
val next = BedtimeSchedule.nextEvent(current, now, zone)
if (next == null) scheduler.cancel() else scheduler.schedule(next)
}
/** What the system rule is built from; the reminder is not part of it. */
private fun BedtimeSettings.ruleFields() = listOf(enabled, bedtime, wakeTime, effects)
}
@@ -11,12 +11,17 @@ object BedtimeIntents {
const val ACTION_SHOW_BEDTIME: String = "de.jeanlucmakiola.clockula.action.SHOW_BEDTIME"
/**
* `NotificationManager.ACTION_AUTOMATIC_ZEN_RULE`: what Settings sends the
* rule's configuration activity (`MainActivity`) when the user taps
* "Clockula" on the mode's page. Spelled out so the shell's pure routing
* table needs no Android import.
* The extras Settings puts on the rule's configuration activity
* (`MainActivity`) when the user taps "Clockula" on the mode's page:
* `NotificationManager.EXTRA_AUTOMATIC_RULE_ID` and
* `ConditionProviderService.EXTRA_RULE_ID`. Settings sends no action with
* them, only the explicit component, so their presence is the signal.
* Spelled out so the shell's pure routing table needs no Android import.
*/
const val ACTION_AUTOMATIC_ZEN_RULE: String = "android.app.action.AUTOMATIC_ZEN_RULE"
val ZEN_RULE_EXTRAS: List<String> = listOf(
"android.app.extra.AUTOMATIC_RULE_ID",
"android.service.notification.extra.RULE_ID",
)
/**
* Apart from every alarm and timer request code, although the explicit
@@ -34,7 +34,7 @@ import javax.inject.Singleton
* media, for a sleep sound) and leaves everything else to the device's own Do
* Not Disturb defaults. A rule needs an owner service or a configuration
* activity; [MainActivity] is the latter, and it opens the bedtime screen when
* Settings sends `ACTION_AUTOMATIC_ZEN_RULE`. It is already exported, so no new
* Settings opens it for the rule. It is already exported, so no new
* component is exposed for this.
*
* Nothing is stored about the rule. [NotificationManager.getAutomaticZenRules]
@@ -70,9 +70,14 @@ class AndroidBedtimeMode @Inject constructor(
if (existing == null) {
manager.addAutomaticZenRule(rule(settings, enabled = true))
} else if (refresh) {
// Keeps the user's own on/off switch for the rule in Settings: a
// change on Clockula's screen must not re-enable a rule they disabled.
manager.updateAutomaticZenRule(existing.key, rule(settings, enabled = existing.value.isEnabled))
// Keeps what the user set for the rule in Settings: its on/off
// switch (a change here must not re-enable a rule they
// disabled) and who may interrupt, which Android 10 to 14
// would otherwise overwrite. Android 15 keeps both itself.
manager.updateAutomaticZenRule(
existing.key,
rule(settings, enabled = existing.value.isEnabled, kept = existing.value.zenPolicy),
)
}
}
}
@@ -104,13 +109,19 @@ class AndroidBedtimeMode @Inject constructor(
private fun ownRules(): Map<String, AutomaticZenRule> =
manager.automaticZenRules.filterValues { it.conditionId == conditionId }
private fun rule(settings: BedtimeSettings, enabled: Boolean): AutomaticZenRule {
/**
* [kept] is the existing rule's policy, reused as long as it lets alarms
* through: a user's edit in Settings survives, and an edit that blocked
* alarms is the one thing replaced.
*/
private fun rule(settings: BedtimeSettings, enabled: Boolean, kept: ZenPolicy? = null): AutomaticZenRule {
val name = context.getString(R.string.bedtime_mode_name)
val configuration = ComponentName(context, MainActivity::class.java)
val policy = ZenPolicy.Builder()
.allowAlarms(true)
.allowMedia(true)
.build()
val policy = kept?.takeIf { it.priorityCategoryAlarms == ZenPolicy.STATE_ALLOW }
?: ZenPolicy.Builder()
.allowAlarms(true)
.allowMedia(true)
.build()
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) {
modeRule(name, configuration, policy, settings, enabled)
} else {
@@ -34,7 +34,9 @@ class AndroidBedtimeScheduler @Inject constructor(
val exact = runCatching {
manager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, millis, pendingIntent)
}.isSuccess
if (!exact) manager.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, millis, pendingIntent)
// Guarded too: bedtime losing its slot is survivable, a crash in the
// receiver's process is not.
if (!exact) runCatching { manager.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, millis, pendingIntent) }
}
override fun cancel() = manager.cancel(slotPendingIntent())
@@ -36,10 +36,14 @@ class BedtimeReceiver : HiltBroadcastReceiver() {
val pendingResult = goAsync()
scope.launch {
try {
when (action) {
BedtimeIntents.ACTION_SLOT -> engine.onSlotFired()
NotificationManager.ACTION_NOTIFICATION_POLICY_ACCESS_GRANTED_CHANGED ->
engine.resync(refreshRule = true)
// 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)
}
}
} finally {
pendingResult.finish()
@@ -154,7 +154,8 @@ object BackupCodec {
enabled = enabled,
bedtime = minutesToTime(bedtimeMinutes),
wakeTime = minutesToTime(wakeMinutes),
days = RepeatDays.fromMask(days),
// The screen never lets the last night go; a file with none gets all.
days = RepeatDays.fromMask(days).takeIf { it.isRepeating } ?: RepeatDays.EVERY_DAY,
reminderMinutes = reminderMinutes.coerceIn(BedtimeLimits.REMINDER_MINUTES),
effects = BedtimeEffects(
grayscale = grayscale,
@@ -1,5 +1,6 @@
package de.jeanlucmakiola.clockula.data.backup
import de.jeanlucmakiola.clockula.domain.bedtime.BedtimeSettings
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@@ -87,13 +88,16 @@ data class SettingsDto(
*/
@Serializable
data class BedtimeDto(
val enabled: Boolean = false,
@SerialName("bedtime_minutes") val bedtimeMinutes: Int = 22 * 60 + 30,
@SerialName("wake_minutes") val wakeMinutes: Int = 7 * 60,
val days: Int = 0b111_1111,
@SerialName("reminder_minutes") val reminderMinutes: Int = 0,
val grayscale: Boolean = true,
@SerialName("dim_wallpaper") val dimWallpaper: Boolean = true,
@SerialName("dark_theme") val darkTheme: Boolean = true,
@SerialName("hide_always_on_display") val hideAlwaysOnDisplay: Boolean = false,
val enabled: Boolean = DEFAULT_BEDTIME.enabled,
@SerialName("bedtime_minutes") val bedtimeMinutes: Int = DEFAULT_BEDTIME.bedtime.minutesOfDay,
@SerialName("wake_minutes") val wakeMinutes: Int = DEFAULT_BEDTIME.wakeTime.minutesOfDay,
val days: Int = DEFAULT_BEDTIME.days.mask,
@SerialName("reminder_minutes") val reminderMinutes: Int = DEFAULT_BEDTIME.reminderMinutes,
val grayscale: Boolean = DEFAULT_BEDTIME.effects.grayscale,
@SerialName("dim_wallpaper") val dimWallpaper: Boolean = DEFAULT_BEDTIME.effects.dimWallpaper,
@SerialName("dark_theme") val darkTheme: Boolean = DEFAULT_BEDTIME.effects.darkTheme,
@SerialName("hide_always_on_display") val hideAlwaysOnDisplay: Boolean = DEFAULT_BEDTIME.effects.hideAlwaysOnDisplay,
)
/** A missing field restores what a fresh install has. */
private val DEFAULT_BEDTIME = BedtimeSettings()
@@ -43,6 +43,14 @@ object BedtimePrefs {
val hideAlwaysOnDisplay: Pref<Boolean> =
booleanPref("bedtime_effect_hide_aod", DEFAULT.effects.hideAlwaysOnDisplay)
/**
* Whether Clockula last switched the mode on or off: device state, not
* configuration, so it is not in the JSON backup. It lets a pass switch
* the mode only when the schedule crossed a boundary since, and leave a
* mode the user switched by hand alone otherwise.
*/
val appliedActive: Pref<Boolean> = booleanPref("bedtime_applied_active", false)
/** Minutes since midnight on disk, clamped into a day both ways. */
private fun Pref<Int>.asTimeOfDay(): Pref<TimeOfDay> = map(
decode = { minutes ->
@@ -178,6 +178,10 @@ class SettingsPrefs @Inject constructor(private val store: PrefStore) {
store.edit { prefs -> prefs.putBedtime(settings) }
}
suspend fun bedtimeAppliedActive(): Boolean = store.get(BedtimePrefs.appliedActive)
suspend fun setBedtimeAppliedActive(active: Boolean) = store.set(BedtimePrefs.appliedActive, active)
private fun MutablePreferences.putBedtime(settings: BedtimeSettings) {
this[BedtimePrefs.enabled] = settings.enabled
this[BedtimePrefs.bedtime] = settings.bedtime
@@ -48,7 +48,7 @@ class BedtimeViewModel @Inject internal constructor(
val changed = granted != access.value
access.value = granted
viewModelScope.launch {
if (changed) engine.resync(refreshRule = true)
if (changed) runCatching { engine.resync(refreshRule = true) }
ruleId.value = engine.ruleId()
}
}
@@ -80,7 +80,7 @@ class BedtimeViewModel @Inject internal constructor(
private fun update(transform: (BedtimeSettings) -> BedtimeSettings) {
viewModelScope.launch {
engine.update(transform)
runCatching { engine.update(transform) }
ruleId.value = engine.ruleId()
}
}
@@ -150,9 +150,16 @@ object ShellNavigation {
* missing or non-positive alarm id, or an empty id list, falls back to the
* Alarms tab (M9 D23).
*/
fun requestFor(action: String?, alarmId: Long?, alarmIds: List<Long>): ShellRequest? =
fun requestFor(
action: String?,
alarmId: Long?,
alarmIds: List<Long>,
forZenRule: Boolean = false,
): ShellRequest? =
when (action) {
null -> null
// Settings opening the bedtime rule's configuration activity: an
// explicit component, a rule id extra, and no action (issue #1).
null -> if (forZenRule) ShellRequest.OpenBedtime else null
InteropIntents.ACTION_OPEN_ALARM_EDITOR ->
if (alarmId != null && alarmId > 0L) {
ShellRequest.OpenAlarmEditor(alarmId)
@@ -160,9 +167,7 @@ object ShellNavigation {
ShellRequest.OpenTab(ClockulaDestination.ALARMS)
}
InteropIntents.ACTION_COMPOSE_TIMER -> ShellRequest.ComposeTimer
BedtimeIntents.ACTION_SHOW_BEDTIME,
BedtimeIntents.ACTION_AUTOMATIC_ZEN_RULE,
-> ShellRequest.OpenBedtime
BedtimeIntents.ACTION_SHOW_BEDTIME -> ShellRequest.OpenBedtime
InteropIntents.ACTION_CHOOSE_ALARM_TO_DISMISS ->
if (alarmIds.isEmpty()) {
ShellRequest.OpenTab(ClockulaDestination.ALARMS)
@@ -163,18 +163,58 @@ class BedtimeEngineTest {
}
@Test
fun `rearming touches the slot and not the mode`(@TempDir tempDir: Path) = runTest {
fun `a resync with no boundary crossed leaves a hand-switched mode alone`(@TempDir tempDir: Path) = runTest {
val h = harness(tempDir)
h.engine.update(nightly)
// Switched on by hand in the afternoon, then the slot lost to a force stop.
h.mode.on = true
h.scheduler.cancel()
h.engine.rearm()
h.engine.resync()
assertThat(h.mode.on).isTrue()
assertThat(h.scheduler.armedAt).isEqualTo(offsetInstant("2026-06-10T23:00+02:00"))
}
@Test
fun `a reminder fire that also ends a window turns the mode off`(@TempDir tempDir: Path) = runTest {
// A 23.5-hour window whose end falls inside the next one's reminder lead.
val h = harness(tempDir, now = instantAt(BERLIN, "2026-06-10T08:00"))
h.engine.update {
it.copy(enabled = true, bedtime = TimeOfDay(7, 30), wakeTime = TimeOfDay(7, 0), reminderMinutes = 30)
}
assertThat(h.mode.on).isTrue()
h.wallClock.instant = instantAt(BERLIN, "2026-06-11T07:00")
h.engine.onSlotFired()
assertThat(h.mode.on).isFalse()
assertThat(h.notifier.reminders).hasSize(1)
}
@Test
fun `a reminder the schedule moved away from is withdrawn`(@TempDir tempDir: Path) = runTest {
val h = harness(tempDir)
h.engine.update { nightly(it).copy(reminderMinutes = 30) }
h.wallClock.instant = instantAt(BERLIN, "2026-06-10T22:30")
h.engine.onSlotFired()
assertThat(h.notifier.showing).isTrue()
h.engine.update { it.copy(bedtime = TimeOfDay(23, 59)) }
assertThat(h.notifier.showing).isFalse()
}
@Test
fun `changing only the reminder does not rewrite the rule`(@TempDir tempDir: Path) = runTest {
val h = harness(tempDir)
h.engine.update(nightly)
h.engine.update { it.copy(reminderMinutes = 45) }
assertThat(h.mode.refreshes).isEqualTo(0)
}
@Test
fun `a missed wake-up fire is caught up by the next resync`(@TempDir tempDir: Path) = runTest {
val h = harness(tempDir, now = instantAt(BERLIN, "2026-06-10T23:30"))
@@ -128,7 +128,10 @@ class ShellNavigationInteropTest {
fun `the bedtime reminder and the mode's settings page both open the bedtime screen`() {
assertThat(ShellNavigation.requestFor(BedtimeIntents.ACTION_SHOW_BEDTIME, null, emptyList()))
.isEqualTo(ShellRequest.OpenBedtime)
assertThat(ShellNavigation.requestFor(BedtimeIntents.ACTION_AUTOMATIC_ZEN_RULE, null, emptyList()))
// Settings opens the rule's configuration activity with no action,
// only the component and the rule id.
assertThat(ShellNavigation.requestFor(null, null, emptyList(), forZenRule = true))
.isEqualTo(ShellRequest.OpenBedtime)
assertThat(ShellNavigation.requestFor(null, null, emptyList())).isNull()
}
}
+33 -14
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` | `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` | `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.
@@ -2726,9 +2726,11 @@ automatic zen rule of its own:
A rule needs an owner `ConditionProviderService` or a configuration activity.
Clockula uses `MainActivity` as the configuration activity: it is already
exported, so no new component is exposed, and when Settings sends it
`NotificationManager.ACTION_AUTOMATIC_ZEN_RULE` the shell opens the bedtime
screen (`ShellRequest.OpenBedtime`). There is no condition provider service:
exported, so no new component is exposed. Settings opens it with the explicit
component, the rule id in `NotificationManager.EXTRA_AUTOMATIC_RULE_ID` /
`ConditionProviderService.EXTRA_RULE_ID`, and **no action**, so the presence of
either extra is what makes the shell open the bedtime screen
(`ShellRequest.OpenBedtime`). There is no condition provider service:
Clockula reports the rule's state itself with `setAutomaticZenRuleState`.
The two modes coexist. A Pixel user who also uses Wellbeing's Bedtime sees two
@@ -2740,9 +2742,12 @@ The rule's `ZenPolicy` sets exactly two things, `allowAlarms(true)` and
`allowMedia(true)` (a sleep sound keeps playing), and leaves every other field
unset, so calls, messages and the rest follow the device's own Do Not Disturb
defaults. Clockula's alarms and timers play with `USAGE_ALARM`, so the
exemption applies. A user who later edits the mode in Settings and blocks alarms
there has made that choice in the system's own UI; Android 15 keeps a user's
edits over an app's updates, and Clockula does not fight them. The self-check
exemption applies. The rule is only rewritten when a field it carries changes
(the times, the effects, the switch; not the reminder), and a rewrite reuses
the rule's existing policy as long as that still allows alarms, so a user's
"who may interrupt" edits in Settings survive on Android 10 to 14 as well
(Android 15 keeps a user's edits over an app's updates by itself). An edit
that blocked alarms is the one thing a rewrite puts back. The self-check
(M10's `SelfCheck`) still reports total silence as a problem whatever caused it.
### The engine
@@ -2771,14 +2776,27 @@ reminder and the next window's start.
| Entry point | Called from | What it does |
|---|---|---|
| `update(transform)` | the bedtime screen | stores the settings, rewrites the rule from them, sets the mode to what the schedule says now (on at 23:30 for a 23:00 bedtime starts it at once), arms the slot. Off removes the rule, which ends the mode, cancels the slot and clears the reminder |
| `onSlotFired()` | `BedtimeReceiver` | a reminder fire posts the reminder and **leaves the mode alone**; any other fire sets the mode on or off. Then arms the next event |
| `resync(refreshRule)` | `SystemEventReceiver` (boot, app update, clock or zone change, exact-alarm permission), Do Not Disturb access changing, a backup restore | creates the rule if it is missing, sets the mode, arms the slot |
| `rearm()` | `ClockulaApp.onCreate` | arms the slot only. A force stop clears it, but a launch is not a bedtime event, so a mode the user switched by hand stays as it is |
| `update(transform)` | the bedtime screen | stores the settings, rewrites the rule if a field it carries changed, sets the mode to what the schedule says now (on at 23:30 for a 23:00 bedtime starts it at once), arms the slot. 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 |
"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. 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
reminder is withdrawn by any pass once no coming bedtime is due within its
lead, so moving the schedule away from it does not leave a stale one.
Every bedtime call on a shared path runs **after** the alarm, timer and
stopwatch passes and inside `runCatching`, so nothing bedtime does can delay
or break them.
stopwatch passes and inside `runCatching` (in `SystemEventReceiver`, in its
`finally`, so a failure up there does not cost bedtime its pass either), so
nothing bedtime does can delay or break them. `BedtimeReceiver` and the
screen guard their calls too.
### No stored rule id
@@ -2806,7 +2824,8 @@ Turning bedtime off removes the rule cleanly.
### Backup
The settings travel in the backup's `settings.bedtime` object
(`docs/BACKUP.md`); a file from before issue #1 restores bedtime as off.
(`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.
+1 -1
View File
@@ -157,7 +157,7 @@ since local midnight, `days` is the same 7-bit mask as an alarm's
reminder. A file without `bedtime` (any file written before it existed)
restores bedtime as off with the default schedule. Like the other settings it
is clamped rather than rejected: times into a day, the reminder into 0–120
minutes. The id of the Do Not Disturb rule a device created is not part of it;
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).
Field names mirror `ClockPrefs`'/`SettingsPrefs`' own on-disk keys. Older