Files
clockula/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/BedtimeEngine.kt
T
makiolaj 2dce78281a 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
2026-10-22 11:38:56 +02:00

147 lines
6.3 KiB
Kotlin

package de.jeanlucmakiola.clockula.bedtime
import de.jeanlucmakiola.clockula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.clockula.domain.bedtime.BedtimeSchedule
import de.jeanlucmakiola.clockula.domain.bedtime.BedtimeSettings
import de.jeanlucmakiola.clockula.domain.time.WallClock
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
/**
* The bedtime schedule (issue #1), orchestrated the way the other engines
* orchestrate theirs: plain Kotlin, the platform behind three seams.
*
* It owns one AlarmManager slot, armed for the schedule's next event (the
* reminder, bedtime, or the wake time), and one Do Not Disturb rule, which it
* creates while the schedule is on and Clockula has Do Not Disturb access,
* and removes when the schedule is turned off. Every entry point is an
* idempotent pass over "where does the schedule stand right now", so a late,
* missed or repeated fire settles into the right state.
*
* It never touches an alarm: the rule lets alarms through, and its slot is
* its own.
*/
@Singleton
class BedtimeEngine @Inject constructor(
private val settings: SettingsPrefs,
private val scheduler: BedtimeScheduler,
private val mode: BedtimeMode,
private val notifier: BedtimeNotifier,
private val wallClock: WallClock,
private val zones: ZoneProvider,
) {
private val lock = Mutex()
val bedtime: Flow<BedtimeSettings> = settings.bedtime
/**
* A change made on the bedtime screen: stored, then applied at once. The
* 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 = previous.scheduleFields() != updated.scheduleFields(),
)
}
/**
* 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, force = refreshRule)
}
/**
* 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()
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)
}
}
/** Whether Clockula may change Do Not Disturb, for the screen's onboarding row. */
fun hasAccess(): Boolean = mode.hasAccess()
val supportsEffects: Boolean get() = mode.supportsEffects
fun ruleId(): String? = mode.ruleId()
/**
* 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(),
zone: ZoneId = zones.current(),
) {
if (!current.enabled) {
scheduler.cancel()
notifier.cancelReminder()
// Removing the rule ends the mode if it is on. Without access there
// is nothing Clockula may remove; revoking access in Settings
// already removed the rule.
if (mode.hasAccess()) mode.uninstall()
settings.setBedtimeAppliedActive(false)
return
}
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()) {
// 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)
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)
/** What decides whether the mode should be on right now. */
private fun BedtimeSettings.scheduleFields() = listOf(enabled, bedtime, wakeTime, days)
}