fix(bedtime): switch a recreated rule, keep manual overrides, finish edits

A rule recreated by a pass (deleted in Settings, access re-granted) now
gets its state set even when no boundary was crossed, and the applied
state is recorded only once the system accepted it. Edits that can't move
a boundary (reminder, effects) no longer re-assert the mode over a manual
override, and the screen's writes run on the application scope so leaving
right after a change can't cut it short. A late reminder counts the
minutes actually left, and one slot fire reads the zone once.

An equal bedtime and wake time is now no window instead of a day-long one,
which on every night meant Do Not Disturb never ended, and an empty days
mask in prefs reads as every night, as a backup's does. The rule-id extras
are translated to the bedtime action in MainActivity, keeping the routing
table keyed on the action alone.

Refs #1
This commit is contained in:
makiolaj committed 2026-10-22 11:38:56 +02:00
1 parent 119197106b
commit 2dce78281a
14 files changed
+148 -67

No files matched your search

@@ -166,8 +166,12 @@ class MainActivity : ComponentActivity() {
.getOrNull()
?.toList()
.orEmpty()
val forZenRule = runCatching { BedtimeIntents.ZEN_RULE_EXTRAS.any(intent::hasExtra) }.getOrDefault(false)
return ShellNavigation.requestFor(intent.action, alarmId, alarmIds, forZenRule)
// Settings opens the bedtime rule's configuration activity (this one)
// with the rule id and no action; that is the bedtime screen (issue #1).
val forZenRule = intent.action == null &&
runCatching { BedtimeIntents.ZEN_RULE_EXTRAS.any(intent::hasExtra) }.getOrDefault(false)
val action = if (forZenRule) BedtimeIntents.ACTION_SHOW_BEDTIME else intent.action
return ShellNavigation.requestFor(action, alarmId, alarmIds)
}
override fun onResume() {
@@ -8,6 +8,9 @@ import de.jeanlucmakiola.clockula.domain.time.ZoneProvider
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.time.ZoneId
import kotlin.math.ceil
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Instant
import javax.inject.Inject
import javax.inject.Singleton
@@ -42,15 +45,21 @@ class BedtimeEngine @Inject constructor(
/**
* A change made on the bedtime screen: stored, then applied at once. The
* 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.
* rule is rewritten when a field it carries changed. When the schedule
* itself changed, the mode is switched to what it says now, so turning
* bedtime on at 23:30 for a 23:00 bedtime starts it straight away; an
* edit that cannot move a boundary (the reminder, an effect) leaves a
* mode switched by hand alone.
*/
suspend fun update(transform: (BedtimeSettings) -> BedtimeSettings) = lock.withLock {
val previous = settings.currentBedtime()
val updated = transform(previous)
settings.setBedtime(updated)
apply(updated, refreshRule = previous.ruleFields() != updated.ruleFields(), force = true)
apply(
updated,
refreshRule = previous.ruleFields() != updated.ruleFields(),
force = previous.scheduleFields() != updated.scheduleFields(),
)
}
/**
@@ -74,9 +83,12 @@ class BedtimeEngine @Inject constructor(
suspend fun onSlotFired() = lock.withLock {
val current = settings.currentBedtime()
val now = wallClock.now()
apply(current, refreshRule = false, force = false, now = now)
BedtimeSchedule.dueReminder(current, now, zones.current())?.let { window ->
notifier.showReminder(window.start, current.reminderMinutes)
val zone = zones.current()
apply(current, refreshRule = false, force = false, now = now, zone = zone)
BedtimeSchedule.dueReminder(current, now, zone)?.let { window ->
// What is actually left: a late fire must not promise the full lead.
val minutesLeft = ceil((window.start - now) / 1.minutes).toInt().coerceAtLeast(1)
notifier.showReminder(window.start, minutesLeft)
}
}
@@ -96,8 +108,8 @@ class BedtimeEngine @Inject constructor(
refreshRule: Boolean,
force: Boolean,
now: Instant = wallClock.now(),
zone: ZoneId = zones.current(),
) {
val zone = zones.current()
if (!current.enabled) {
scheduler.cancel()
notifier.cancelReminder()
@@ -113,10 +125,13 @@ class BedtimeEngine @Inject constructor(
// 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)
if (force || active != settings.bedtimeAppliedActive()) {
mode.setActive(active)
settings.setBedtimeAppliedActive(active)
// A rule that was just (re)created starts off, whatever was last
// applied to its predecessor, so it is switched regardless.
val created = mode.install(current, refresh = refreshRule)
if (force || created || active != settings.bedtimeAppliedActive()) {
// Recorded only once the system took it, so a refused call is
// retried by the next pass rather than forgotten.
if (mode.setActive(active)) settings.setBedtimeAppliedActive(active)
}
}
val next = BedtimeSchedule.nextEvent(current, now, zone)
@@ -125,4 +140,7 @@ class BedtimeEngine @Inject constructor(
/** What the system rule is built from; the reminder is not part of it. */
private fun BedtimeSettings.ruleFields() = listOf(enabled, bedtime, wakeTime, effects)
/** What decides whether the mode should be on right now. */
private fun BedtimeSettings.scheduleFields() = listOf(enabled, bedtime, wakeTime, days)
}
@@ -28,13 +28,14 @@ interface BedtimeMode {
val supportsEffects: Boolean
/**
* Creates the rule when it is missing. [refresh] also rewrites an existing
* one from [settings]; otherwise an existing rule is left exactly as it is.
* Creates the rule when it is missing, and returns whether it did. [refresh]
* also rewrites an existing one from [settings]; otherwise an existing
* rule is left exactly as it is.
*/
fun install(settings: BedtimeSettings, refresh: Boolean)
fun install(settings: BedtimeSettings, refresh: Boolean): Boolean
/** Turns the mode on or off. Does nothing when there is no rule. */
fun setActive(active: Boolean)
/** Turns the mode on or off; false when there is no rule or the system refused. */
fun setActive(active: Boolean): Boolean
/** Removes the rule, which also ends it if it is on. */
fun uninstall()
@@ -60,8 +60,8 @@ class AndroidBedtimeMode @Inject constructor(
override fun hasAccess(): Boolean = guarded(false) { manager.isNotificationPolicyAccessGranted }
override fun install(settings: BedtimeSettings, refresh: Boolean) {
guarded(Unit) {
override fun install(settings: BedtimeSettings, refresh: Boolean): Boolean =
guarded(false) {
val ours = ownRules()
// Only ever one. A second (two passes racing on another process,
// say) is removed rather than left to fight the first.
@@ -69,7 +69,9 @@ class AndroidBedtimeMode @Inject constructor(
val existing = ours.entries.firstOrNull()
if (existing == null) {
manager.addAutomaticZenRule(rule(settings, enabled = true))
} else if (refresh) {
return@guarded true
}
if (refresh) {
// 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
@@ -79,12 +81,12 @@ class AndroidBedtimeMode @Inject constructor(
rule(settings, enabled = existing.value.isEnabled, kept = existing.value.zenPolicy),
)
}
false
}
}
override fun setActive(active: Boolean) {
guarded(Unit) {
val id = ownRules().keys.firstOrNull() ?: return@guarded
override fun setActive(active: Boolean): Boolean =
guarded(false) {
val id = ownRules().keys.firstOrNull() ?: return@guarded false
val state = if (active) Condition.STATE_TRUE else Condition.STATE_FALSE
val summary = context.getString(R.string.bedtime_mode_name)
val condition = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) {
@@ -95,8 +97,8 @@ class AndroidBedtimeMode @Inject constructor(
Condition(conditionId, summary, state)
}
manager.setAutomaticZenRuleState(id, condition)
true
}
}
override fun uninstall() {
guarded(Unit) {
@@ -24,9 +24,16 @@ object BedtimePrefs {
val wakeTime: Pref<TimeOfDay> = intPref("bedtime_wake_minutes", DEFAULT.wakeTime.minutesOfDay).asTimeOfDay()
/** The 7-bit mask `RepeatDays` uses; a corrupt value cannot widen the set. */
/**
* The 7-bit mask `RepeatDays` uses; a corrupt value cannot widen the set.
* The screen never lets the last night go, so an empty mask (corrupt, or
* written around it) reads as every night, as a backup's does.
*/
val days: Pref<RepeatDays> = intPref("bedtime_days", DEFAULT.days.mask)
.map(decode = RepeatDays::fromMask, encode = { it.mask })
.map(
decode = { mask -> RepeatDays.fromMask(mask).takeIf { it.isRepeating } ?: RepeatDays.EVERY_DAY },
encode = { it.mask },
)
val reminderMinutes: Pref<Int> = intPref("bedtime_reminder_minutes", DEFAULT.reminderMinutes)
.map(
@@ -23,11 +23,12 @@ object BedtimeSchedule {
/**
* The window that starts on [date], if [date] is one of the schedule's
* nights. It ends at the first wake time after it starts, so a wake time
* at or before the bedtime means the next morning, and an equal one a
* full day.
* before the bedtime means the next morning. An equal bedtime and wake
* time is no window at all: read as a whole day, every night selected
* would join into a Do Not Disturb that never ends.
*/
fun windowOn(settings: BedtimeSettings, date: LocalDate, zone: ZoneId): BedtimeWindow? {
if (date.dayOfWeek !in settings.days) return null
if (date.dayOfWeek !in settings.days || settings.bedtime == settings.wakeTime) return null
val start = AlarmOccurrences.at(date, settings.bedtime, zone)
val sameDayEnd = AlarmOccurrences.at(date, settings.wakeTime, zone)
val end = if (sameDayEnd > start) sameDayEnd else AlarmOccurrences.at(date.plusDays(1), settings.wakeTime, zone)
@@ -7,6 +7,8 @@ import de.jeanlucmakiola.clockula.bedtime.BedtimeEngine
import de.jeanlucmakiola.clockula.domain.TimeOfDay
import de.jeanlucmakiola.clockula.domain.bedtime.BedtimeEffects
import de.jeanlucmakiola.clockula.domain.bedtime.BedtimeSettings
import de.jeanlucmakiola.floret.di.ApplicationScope
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
@@ -29,6 +31,7 @@ import javax.inject.Inject
@HiltViewModel
class BedtimeViewModel @Inject internal constructor(
private val engine: BedtimeEngine,
@param:ApplicationScope private val appScope: CoroutineScope,
) : ViewModel() {
private val access = MutableStateFlow(engine.hasAccess())
@@ -47,7 +50,7 @@ class BedtimeViewModel @Inject internal constructor(
val granted = engine.hasAccess()
val changed = granted != access.value
access.value = granted
viewModelScope.launch {
appScope.launch {
if (changed) runCatching { engine.resync(refreshRule = true) }
ruleId.value = engine.ruleId()
}
@@ -78,8 +81,13 @@ class BedtimeViewModel @Inject internal constructor(
private fun updateEffects(transform: (BedtimeEffects) -> BedtimeEffects) =
update { it.copy(effects = transform(it.effects)) }
/**
* On the application scope, like the editors' writes: leaving the screen
* right after a change must not cancel it between storing the settings
* and arming the slot or creating the rule.
*/
private fun update(transform: (BedtimeSettings) -> BedtimeSettings) {
viewModelScope.launch {
appScope.launch {
runCatching { engine.update(transform) }
ruleId.value = engine.ruleId()
}
@@ -150,16 +150,9 @@ 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>,
forZenRule: Boolean = false,
): ShellRequest? =
fun requestFor(action: String?, alarmId: Long?, alarmIds: List<Long>): ShellRequest? =
when (action) {
// 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
null -> null
InteropIntents.ACTION_OPEN_ALARM_EDITOR ->
if (alarmId != null && alarmId > 0L) {
ShellRequest.OpenAlarmEditor(alarmId)
+1 -1
View File
@@ -469,7 +469,7 @@
<string name="bedtime_effect_hide_aod">Hide the always-on display</string>
<string name="bedtime_effects_unavailable">Screen effects need Android 15 or later. On this phone, bedtime silences notifications and calls the way Do Not Disturb does.</string>
<string name="bedtime_open_mode">Open in system settings</string>
<string name="bedtime_open_mode_summary">Who can reach you, and more effects, under Settings › Modes. Changes you make there are kept.</string>
<string name="bedtime_open_mode_summary">Who can reach you, and more effects, under Settings › Modes. Changes you make there are kept while bedtime stays on.</string>
<string name="bedtime_alarms_note">Bedtime never silences an alarm or a timer: its mode always lets alarms through.</string>
<string name="bedtime_wellbeing_note">This is not Digital Wellbeing\'s Bedtime mode. Android lets only the system turn that one on, so if you use it too you will see two modes in Settings › Modes. Use one of them.</string>
<string name="bedtime_mode_name">Clockula bedtime</string>
@@ -205,6 +205,43 @@ class BedtimeEngineTest {
assertThat(h.notifier.showing).isFalse()
}
@Test
fun `a rule recreated during the night is switched on`(@TempDir tempDir: Path) = runTest {
val h = harness(tempDir, now = instantAt(BERLIN, "2026-06-10T23:30"))
h.engine.update(nightly)
// Deleted in the system's settings, then the app is launched.
h.mode.rule = null
h.mode.on = false
h.engine.resync()
assertThat(h.mode.rule).isNotNull()
assertThat(h.mode.on).isTrue()
}
@Test
fun `a late reminder says how long is actually left`(@TempDir tempDir: Path) = runTest {
val h = harness(tempDir)
h.engine.update { nightly(it).copy(reminderMinutes = 60) }
h.wallClock.instant = instantAt(BERLIN, "2026-06-10T22:41")
h.engine.onSlotFired()
assertThat(h.notifier.reminders.single().second).isEqualTo(19)
}
@Test
fun `an edit that cannot move a boundary leaves a hand-switched mode alone`(@TempDir tempDir: Path) = runTest {
val h = harness(tempDir, now = instantAt(BERLIN, "2026-06-10T23:30"))
h.engine.update(nightly)
// Switched off by hand for tonight.
h.mode.on = false
h.engine.update { it.copy(reminderMinutes = 30) }
assertThat(h.mode.on).isFalse()
}
@Test
fun `changing only the reminder does not rewrite the rule`(@TempDir tempDir: Path) = runTest {
val h = harness(tempDir)
@@ -103,14 +103,11 @@ class BedtimeScheduleTest {
}
@Test
fun `an equal bedtime and wake time is a whole day, back to back`() {
val always = everyNight.copy(wakeTime = TimeOfDay(23, 0))
fun `an equal bedtime and wake time is no window, never one that never ends`() {
val same = everyNight.copy(wakeTime = TimeOfDay(23, 0))
val window = BedtimeSchedule.activeWindow(always, instantAt(BERLIN, "2026-06-10T22:00"), BERLIN)
assertThat(window?.start).isEqualTo(offsetInstant("2026-06-09T23:00+02:00"))
assertThat(window?.end).isEqualTo(offsetInstant("2026-06-10T23:00+02:00"))
assertThat(BedtimeSchedule.activeWindow(always, instantAt(BERLIN, "2026-06-10T23:00"), BERLIN)).isNotNull()
assertThat(BedtimeSchedule.activeWindow(same, instantAt(BERLIN, "2026-06-10T23:30"), BERLIN)).isNull()
assertThat(BedtimeSchedule.nextEvent(same, instantAt(BERLIN, "2026-06-10T12:00"), BERLIN)).isNull()
}
@Test
@@ -37,19 +37,25 @@ class FakeBedtimeMode(
override fun hasAccess(): Boolean = access
override fun install(settings: BedtimeSettings, refresh: Boolean) {
if (!access) return
override fun install(settings: BedtimeSettings, refresh: Boolean): Boolean {
if (!access) return false
if (rule == null) {
rule = settings
} else if (refresh) {
// A new rule starts off, as the platform's does.
on = false
return true
}
if (refresh) {
rule = settings
refreshes++
}
return false
}
override fun setActive(active: Boolean) {
if (!access || rule == null) return
override fun setActive(active: Boolean): Boolean {
if (!access || rule == null) return false
on = active
return true
}
override fun uninstall() {
@@ -128,10 +128,11 @@ 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)
// 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()
// MainActivity turns Settings' action-less, rule-id-carrying intent
// into the same action; the extras themselves are its business.
assertThat(BedtimeIntents.ZEN_RULE_EXTRAS).containsExactly(
"android.app.extra.AUTOMATIC_RULE_ID",
"android.service.notification.extra.RULE_ID",
)
}
}