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:
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)
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user