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
147 lines
6.3 KiB
Kotlin
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)
|
|
}
|