diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ClockulaApp.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ClockulaApp.kt index 00fde8c..1cb639a 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ClockulaApp.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ClockulaApp.kt @@ -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 diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/MainActivity.kt b/app/src/main/java/de/jeanlucmakiola/clockula/MainActivity.kt index 958be6d..a817945 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/MainActivity.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/MainActivity.kt @@ -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() { diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/alarm/receiver/SystemEventReceiver.kt b/app/src/main/java/de/jeanlucmakiola/clockula/alarm/receiver/SystemEventReceiver.kt index 09df3ff..05469f6 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/alarm/receiver/SystemEventReceiver.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/alarm/receiver/SystemEventReceiver.kt @@ -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() } } 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 1fdbcb4..fac51ee 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/BedtimeEngine.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/BedtimeEngine.kt @@ -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) } diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/BedtimeIntents.kt b/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/BedtimeIntents.kt index 4eb52a0..f9a2758 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/BedtimeIntents.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/BedtimeIntents.kt @@ -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 = listOf( + "android.app.extra.AUTOMATIC_RULE_ID", + "android.service.notification.extra.RULE_ID", + ) /** * Apart from every alarm and timer request code, although the explicit diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/android/AndroidBedtimeMode.kt b/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/android/AndroidBedtimeMode.kt index ce71253..0319d06 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/android/AndroidBedtimeMode.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/android/AndroidBedtimeMode.kt @@ -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 = 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 { 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 11ba260..10c7c37 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 @@ -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()) 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 2d6b448..1f6ad1f 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 @@ -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() 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 3fd7069..e82852c 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 @@ -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, 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 b225f4a..f664ca4 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 @@ -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() 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 ab1704c..68810ef 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 @@ -43,6 +43,14 @@ object BedtimePrefs { val hideAlwaysOnDisplay: Pref = 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 = booleanPref("bedtime_applied_active", 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 b802eca..a85904e 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 @@ -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 diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/bedtime/BedtimeViewModel.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/bedtime/BedtimeViewModel.kt index d05c397..a50eddc 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/bedtime/BedtimeViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/bedtime/BedtimeViewModel.kt @@ -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() } } diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/shell/ShellNavigation.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/shell/ShellNavigation.kt index b0bb5bd..13cce4b 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/shell/ShellNavigation.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/shell/ShellNavigation.kt @@ -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): ShellRequest? = + fun requestFor( + action: String?, + alarmId: Long?, + alarmIds: List, + 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) 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 d961b20..aeba63b 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/bedtime/BedtimeEngineTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/bedtime/BedtimeEngineTest.kt @@ -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")) diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/ui/shell/ShellNavigationInteropTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/ui/shell/ShellNavigationInteropTest.kt index cbe63e3..bc3ce5c 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/ui/shell/ShellNavigationInteropTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/ui/shell/ShellNavigationInteropTest.kt @@ -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() } } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 363dc39..10c3e25 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` | `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` | `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. @@ -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. diff --git a/docs/BACKUP.md b/docs/BACKUP.md index e58eb1f..6a26075 100644 --- a/docs/BACKUP.md +++ b/docs/BACKUP.md @@ -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