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:
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 {
|
||||
|
||||
+3
-1
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user