Merge branch 'feat/1-bedtime-mode' into release/v1.0.0
# Conflicts: # docs/ARCHITECTURE.md
This commit is contained in:
commit
ea8e159b39
47 files changed
+2703
-30
No files matched your search
@@ -50,6 +50,12 @@
|
||||
<!-- The alarm vibrates, and vibrates on its own when no audio source opens. -->
|
||||
<uses-permission android:name="android.permission.VIBRATE" />
|
||||
|
||||
<!-- The opt-in bedtime mode (issue #1): lists Clockula under Settings ›
|
||||
Special app access › Do Not Disturb, where the user grants it by hand.
|
||||
Without that grant nothing changes; with it, Clockula adds one Do Not
|
||||
Disturb rule of its own, which always lets alarms through. -->
|
||||
<uses-permission android:name="android.permission.ACCESS_NOTIFICATION_POLICY" />
|
||||
|
||||
<!-- "Choose from a music app…": package visibility for the apps that share
|
||||
a library through Android's standard MediaBrowserService, and for
|
||||
nothing else. A visibility declaration, not a permission — and no
|
||||
@@ -273,6 +279,12 @@
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<!-- The bedtime slot arriving (issue #1). Unexported, like the alarm's
|
||||
and the timer's: only the PendingIntent the app made reaches it. -->
|
||||
<receiver
|
||||
android:name=".bedtime.receiver.BedtimeReceiver"
|
||||
android:exported="false" />
|
||||
|
||||
<!-- Persists the per-app language on API < 33, where the platform
|
||||
per-app-languages API is unavailable. On 33+ this is a no-op. -->
|
||||
<service
|
||||
|
||||
@@ -4,6 +4,8 @@ import android.app.Application
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
import de.jeanlucmakiola.clockula.alarm.AlarmEngine
|
||||
import de.jeanlucmakiola.clockula.alarm.ring.RingNotifications
|
||||
import de.jeanlucmakiola.clockula.bedtime.BedtimeEngine
|
||||
import de.jeanlucmakiola.clockula.bedtime.android.AndroidBedtimeNotifier
|
||||
import de.jeanlucmakiola.clockula.stopwatch.StopwatchEngine
|
||||
import de.jeanlucmakiola.clockula.stopwatch.service.StopwatchNotifications
|
||||
import de.jeanlucmakiola.clockula.timer.TimerEngine
|
||||
@@ -38,6 +40,9 @@ class ClockulaApp : Application() {
|
||||
@Inject
|
||||
lateinit var stopwatchEngine: StopwatchEngine
|
||||
|
||||
@Inject
|
||||
lateinit var bedtimeEngine: BedtimeEngine
|
||||
|
||||
@Inject
|
||||
lateinit var widgetSync: WidgetSync
|
||||
|
||||
@@ -61,29 +66,42 @@ class ClockulaApp : Application() {
|
||||
RingNotifications.createChannels(this)
|
||||
TimerNotifications.createChannel(this)
|
||||
StopwatchNotifications.createChannel(this)
|
||||
AndroidBedtimeNotifier.createChannel(this)
|
||||
|
||||
// The same three steps as a BOOT_COMPLETED, and taken through the engine
|
||||
// rather than one by one so the whole pass runs under the engine's lock:
|
||||
// a cold start caused by the fire broadcast must not interleave its
|
||||
// read-modify-write over `alarm_states` with the fire's own.
|
||||
scope.launch {
|
||||
engine.onBootCompleted()
|
||||
// After the alarm engine's pass, and idempotent within a boot: the
|
||||
// boot-id gate makes the second repair a no-op, so neither engine
|
||||
// depends on the other's ordering (M6 D21). Process death costs the
|
||||
// timers nothing — the anchors and the AlarmManager slot survive it —
|
||||
// but the service may not have, so this brings it back.
|
||||
timerEngine.onBootCompleted()
|
||||
// And the third, after the other two. Process death costs the
|
||||
// stopwatch nothing — the run is in DataStore and the laps in Room —
|
||||
// but the service may not have survived it, so this brings it back;
|
||||
// and if this is a new boot the gate above has already repaired the
|
||||
// run, leaving it paused at what it banked (M7 D17).
|
||||
stopwatchEngine.onBootCompleted()
|
||||
try {
|
||||
launchPasses()
|
||||
} finally {
|
||||
// Last and guarded, like every bedtime call on a shared path,
|
||||
// and in a finally so a failure above cannot cost it its pass:
|
||||
// a 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
|
||||
// for as long as the process lives (M10, docs/PLAN.md §9).
|
||||
scope.launch { widgetSync.run() }
|
||||
}
|
||||
|
||||
private suspend fun launchPasses() {
|
||||
engine.onBootCompleted()
|
||||
// After the alarm engine's pass, and idempotent within a boot: the
|
||||
// boot-id gate makes the second repair a no-op, so neither engine
|
||||
// depends on the other's ordering (M6 D21). Process death costs the
|
||||
// timers nothing — the anchors and the AlarmManager slot survive it —
|
||||
// but the service may not have, so this brings it back.
|
||||
timerEngine.onBootCompleted()
|
||||
// And the third, after the other two. Process death costs the
|
||||
// stopwatch nothing — the run is in DataStore and the laps in Room —
|
||||
// but the service may not have survived it, so this brings it back;
|
||||
// and if this is a new boot the gate above has already repaired the
|
||||
// run, leaving it paused at what it banked (M7 D17).
|
||||
stopwatchEngine.onBootCompleted()
|
||||
}
|
||||
}
|
||||
@@ -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,12 @@ class MainActivity : ComponentActivity() {
|
||||
.getOrNull()
|
||||
?.toList()
|
||||
.orEmpty()
|
||||
return ShellNavigation.requestFor(intent.action, alarmId, alarmIds)
|
||||
// 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() {
|
||||
|
||||
@@ -5,6 +5,7 @@ import android.content.Intent
|
||||
import androidx.glance.appwidget.updateAll
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import de.jeanlucmakiola.clockula.alarm.AlarmEngine
|
||||
import de.jeanlucmakiola.clockula.bedtime.BedtimeEngine
|
||||
import de.jeanlucmakiola.clockula.stopwatch.StopwatchEngine
|
||||
import de.jeanlucmakiola.clockula.timer.TimerEngine
|
||||
import de.jeanlucmakiola.clockula.widget.AnalogClockWidget
|
||||
@@ -22,7 +23,7 @@ import javax.inject.Inject
|
||||
* granting "Alarms & reminders" again, which turns an inexact registration
|
||||
* back into an exact one.
|
||||
*
|
||||
* Both engines are told, in that order. For the timers a clock change
|
||||
* Both engines are told, in that order, and the bedtime schedule after them. For the timers a clock change
|
||||
* reschedules nothing (their slot is elapsed-realtime anchored); it re-anchors
|
||||
* the wall-clock fallback, which is what the notification's chronometer counts
|
||||
* to and what a reboot would fall back on.
|
||||
@@ -42,6 +43,9 @@ class SystemEventReceiver : HiltBroadcastReceiver() {
|
||||
@Inject
|
||||
lateinit var stopwatchEngine: StopwatchEngine
|
||||
|
||||
@Inject
|
||||
lateinit var bedtimeEngine: BedtimeEngine
|
||||
|
||||
@Inject
|
||||
@ApplicationScope
|
||||
lateinit var scope: CoroutineScope
|
||||
@@ -98,6 +102,11 @@ class SystemEventReceiver : HiltBroadcastReceiver() {
|
||||
}
|
||||
}
|
||||
} 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, and a
|
||||
// failure up there must not cost bedtime its pass either.
|
||||
runCatching { bedtimeEngine.resync() }
|
||||
pendingResult.finish()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package de.jeanlucmakiola.clockula.backup
|
||||
|
||||
import de.jeanlucmakiola.clockula.alarm.AlarmEngine
|
||||
import de.jeanlucmakiola.clockula.bedtime.BedtimeEngine
|
||||
import de.jeanlucmakiola.clockula.data.backup.BackupRepository
|
||||
import de.jeanlucmakiola.clockula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.clockula.domain.backup.BackupContent
|
||||
@@ -26,6 +27,7 @@ class BackupEngine @Inject constructor(
|
||||
private val settings: SettingsPrefs,
|
||||
private val alarmEngine: AlarmEngine,
|
||||
private val timerEngine: TimerEngine,
|
||||
private val bedtimeEngine: BedtimeEngine,
|
||||
private val wallClock: WallClock,
|
||||
) {
|
||||
|
||||
@@ -93,6 +95,16 @@ class BackupEngine @Inject constructor(
|
||||
}
|
||||
if (failure == null) failure = reported else failure?.addSuppressed(reported)
|
||||
}
|
||||
// The bedtime settings came with the rest (or were rolled back
|
||||
// with them), so the slot and the Do Not Disturb rule follow.
|
||||
// Its platform calls are guarded already; a failure here is
|
||||
// not a failed import. The engine compares against what was
|
||||
// stored before (after a rollback, the same again), so only a
|
||||
// real change to the schedule or the rule re-asserts the mode.
|
||||
val previousBedtime = previousSettings.bedtime
|
||||
if (settingsApplied && previousBedtime != null) {
|
||||
runCatching { bedtimeEngine.resyncAfterRestore(previousBedtime) }
|
||||
}
|
||||
}
|
||||
}
|
||||
failure?.let { throw it }
|
||||
@@ -103,5 +115,6 @@ class BackupEngine @Inject constructor(
|
||||
dynamicColor = settings.dynamicColor.first(),
|
||||
defaults = settings.currentDefaults(),
|
||||
timerPresets = settings.timerPresets.first(),
|
||||
bedtime = settings.currentBedtime(),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
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
|
||||
* or the rule 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
|
||||
* (and a rewritten rule cannot be left off if the platform reset its
|
||||
* state); a reminder edit 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)
|
||||
val refreshRule = previous.ruleFields() != updated.ruleFields()
|
||||
apply(
|
||||
updated,
|
||||
refreshRule = refreshRule,
|
||||
force = refreshRule || 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, and Do Not Disturb access changing (a restore has
|
||||
* [resyncAfterRestore]). 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) {
|
||||
// Already off and cleaned up: nothing to do, which keeps every
|
||||
// launch and boot of a user who never turned bedtime on cheap.
|
||||
if (!force && !refreshRule && !settings.bedtimeAppliedActive() &&
|
||||
(!mode.hasAccess() || mode.ruleId() == null)
|
||||
) {
|
||||
scheduler.cancel()
|
||||
return
|
||||
}
|
||||
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.
|
||||
val removed = !mode.hasAccess() || mode.uninstall()
|
||||
if (removed) {
|
||||
settings.setBedtimeAppliedActive(false)
|
||||
scheduler.cancel()
|
||||
} else {
|
||||
// Refused: keep the slot as the retry, so the mode cannot stay
|
||||
// on until some unrelated launch or reboot happens along.
|
||||
scheduler.schedule(now + RETRY_AFTER, urgent = false)
|
||||
}
|
||||
return
|
||||
}
|
||||
val window = BedtimeSchedule.activeWindow(current, now, zone)
|
||||
val next = BedtimeSchedule.nextEvent(current, now, zone)
|
||||
// Anything below may throw or be refused; the slot is armed whatever
|
||||
// happens, and pulled in to a retry when the mode is not where it
|
||||
// should be.
|
||||
var retry = true
|
||||
try {
|
||||
// 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()
|
||||
retry = mode.hasAccess() && !syncMode(current, active = window != null, refreshRule, force)
|
||||
} finally {
|
||||
arm(next, now, retry = retry, urgent = window != null && next == window.end)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Brings the rule and its state in line; false when something is still
|
||||
* owed. Only what the system accepted is recorded as applied. A refused
|
||||
* rewrite or state change is remembered as owed and forced by every pass
|
||||
* until it lands, so a refusal neither hides the next real boundary nor
|
||||
* rests on the one caller that asked.
|
||||
*/
|
||||
private suspend fun syncMode(current: BedtimeSettings, active: Boolean, refreshRule: Boolean, force: Boolean): Boolean {
|
||||
val stale = refreshRule || settings.bedtimeRuleStale()
|
||||
val outcome = mode.install(current, refresh = stale)
|
||||
if (stale) settings.setBedtimeRuleStale(outcome == InstallOutcome.FAILED)
|
||||
// A rule that was just (re)created or rewritten may start off
|
||||
// (Android 10 to 14 clear the condition on an update), so it is
|
||||
// switched regardless of what was last applied.
|
||||
val reset = outcome == InstallOutcome.CREATED || outcome == InstallOutcome.UPDATED
|
||||
val owed = settings.bedtimeStateOwed()
|
||||
var stateOk = true
|
||||
if (force || reset || owed || active != settings.bedtimeAppliedActive()) {
|
||||
stateOk = mode.setActive(active)
|
||||
if (stateOk) settings.setBedtimeAppliedActive(active)
|
||||
if (stateOk != !owed) settings.setBedtimeStateOwed(!stateOk)
|
||||
}
|
||||
return outcome != InstallOutcome.FAILED && stateOk
|
||||
}
|
||||
|
||||
/**
|
||||
* The next event, or sooner as a retry. Only the wake-up boundary, which
|
||||
* ends the mode, is [urgent]: one allow-while-idle fire a night, so the
|
||||
* mode cannot linger into the morning behind a doze deferral.
|
||||
*/
|
||||
private fun arm(next: Instant?, now: Instant, retry: Boolean, urgent: Boolean) {
|
||||
val retryAt = now + RETRY_AFTER
|
||||
when {
|
||||
retry && (next == null || retryAt < next) -> scheduler.schedule(retryAt, urgent = false)
|
||||
next != null -> scheduler.schedule(next, urgent = urgent)
|
||||
else -> scheduler.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* After a backup restore: the same field comparison an edit makes, against
|
||||
* what was stored before, so restoring the same schedule (or one that only
|
||||
* differs in its reminder) leaves a hand-switched mode alone.
|
||||
*/
|
||||
suspend fun resyncAfterRestore(previous: BedtimeSettings) = lock.withLock {
|
||||
val current = settings.currentBedtime()
|
||||
val refreshRule = previous.ruleFields() != current.ruleFields()
|
||||
apply(current, refreshRule = refreshRule, force = refreshRule || previous.scheduleFields() != current.scheduleFields())
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val RETRY_AFTER = 15.minutes
|
||||
}
|
||||
|
||||
/** 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)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package de.jeanlucmakiola.clockula.bedtime
|
||||
|
||||
/**
|
||||
* The bedtime slot's broadcast. Internal and unexported, like the alarm's and
|
||||
* the timer's: only the PendingIntent this app created can deliver it.
|
||||
*/
|
||||
object BedtimeIntents {
|
||||
const val ACTION_SLOT: String = "de.jeanlucmakiola.clockula.bedtime.action.SLOT"
|
||||
|
||||
/** Opens the bedtime screen, from the reminder notification. */
|
||||
const val ACTION_SHOW_BEDTIME: String = "de.jeanlucmakiola.clockula.action.SHOW_BEDTIME"
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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
|
||||
* receiver and the action already keep these PendingIntents distinct.
|
||||
*/
|
||||
const val REQUEST_SLOT: Int = 40
|
||||
|
||||
const val REQUEST_SHOW: Int = 41
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package de.jeanlucmakiola.clockula.bedtime
|
||||
|
||||
import de.jeanlucmakiola.clockula.domain.bedtime.BedtimeSettings
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* The AlarmManager seam: one slot of its own, on its own receiver and request
|
||||
* code, so nothing bedtime does can replace or cancel an alarm's or a timer's
|
||||
* registration.
|
||||
*/
|
||||
interface BedtimeScheduler {
|
||||
/**
|
||||
* [urgent] is the one event a night that may draw on the platform's
|
||||
* allow-while-idle ration: the wake-up that ends the mode.
|
||||
*/
|
||||
fun schedule(at: Instant, urgent: Boolean)
|
||||
|
||||
fun cancel()
|
||||
}
|
||||
|
||||
/**
|
||||
* The Do Not Disturb seam: Clockula's one automatic zen rule (a "mode" on
|
||||
* Android 15 and later). Every call is a no-op without Do Not Disturb access,
|
||||
* and none of them throws: a failure here must never stop the slot from being
|
||||
* rescheduled.
|
||||
*/
|
||||
interface BedtimeMode {
|
||||
/** Whether the user granted Do Not Disturb access ("Special app access"). */
|
||||
fun hasAccess(): Boolean
|
||||
|
||||
/** Whether the mode can carry screen effects (Android 15 and later). */
|
||||
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.
|
||||
*/
|
||||
fun install(settings: BedtimeSettings, refresh: Boolean): InstallOutcome
|
||||
|
||||
/** 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; false when the system refused. */
|
||||
fun uninstall(): Boolean
|
||||
|
||||
/** The rule's id, for a deep link into the system's settings; null when there is none. */
|
||||
fun ruleId(): String?
|
||||
}
|
||||
|
||||
/** What [BedtimeMode.install] did. */
|
||||
enum class InstallOutcome { UNCHANGED, CREATED, UPDATED, FAILED }
|
||||
|
||||
/** The reminder notification. */
|
||||
interface BedtimeNotifier {
|
||||
fun showReminder(start: Instant, minutesBefore: Int)
|
||||
|
||||
fun cancelReminder()
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
package de.jeanlucmakiola.clockula.bedtime.android
|
||||
|
||||
import android.app.AutomaticZenRule
|
||||
import android.app.NotificationManager
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.service.notification.Condition
|
||||
import android.service.notification.ZenDeviceEffects
|
||||
import android.service.notification.ZenPolicy
|
||||
import androidx.annotation.RequiresApi
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.jeanlucmakiola.clockula.MainActivity
|
||||
import de.jeanlucmakiola.clockula.R
|
||||
import de.jeanlucmakiola.clockula.bedtime.BedtimeMode
|
||||
import de.jeanlucmakiola.clockula.bedtime.InstallOutcome
|
||||
import de.jeanlucmakiola.clockula.domain.TimeOfDay
|
||||
import de.jeanlucmakiola.clockula.domain.bedtime.BedtimeSettings
|
||||
import java.time.LocalTime
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Clockula's one automatic zen rule. On Android 15 and later it is a mode in
|
||||
* Settings › Modes, typed as a time schedule, under Clockula's name and icon,
|
||||
* carrying the screen effects the user picked. On Android 10 to 14 it is a
|
||||
* plain Do Not Disturb schedule rule.
|
||||
*
|
||||
* Digital Wellbeing's own Bedtime mode is off limits: the platform refuses
|
||||
* `TYPE_BEDTIME` from any package but the system's wellbeing app, and no app
|
||||
* can read its schedule. This is a separate mode next to it.
|
||||
*
|
||||
* **Alarms always get through.** The rule's [ZenPolicy] allows alarms (and
|
||||
* 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 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]
|
||||
* lists only this app's own rules, and the condition id tells Clockula's apart,
|
||||
* so a reinstall, a cleared app or a restore onto another device finds or
|
||||
* recreates it without a stale id. Every call is guarded: a revoked access or
|
||||
* an OEM quirk costs the mode, never the caller.
|
||||
*/
|
||||
@Singleton
|
||||
class AndroidBedtimeMode @Inject constructor(
|
||||
@param:ApplicationContext private val context: Context,
|
||||
) : BedtimeMode {
|
||||
|
||||
private val manager: NotificationManager = context.getSystemService(NotificationManager::class.java)
|
||||
|
||||
private val conditionId: Uri = Uri.Builder()
|
||||
.scheme(Condition.SCHEME)
|
||||
.authority(context.packageName)
|
||||
.appendPath("bedtime")
|
||||
.build()
|
||||
|
||||
override val supportsEffects: Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM
|
||||
|
||||
override fun hasAccess(): Boolean = guarded(false) { manager.isNotificationPolicyAccessGranted }
|
||||
|
||||
override fun install(settings: BedtimeSettings, refresh: Boolean): InstallOutcome =
|
||||
guarded(InstallOutcome.FAILED) {
|
||||
val ours = ownRules()
|
||||
// Only ever one. A second (two passes racing on another process,
|
||||
// say) is removed rather than left to fight the first.
|
||||
ours.keys.drop(1).forEach(manager::removeAutomaticZenRule)
|
||||
val existing = ours.entries.firstOrNull()
|
||||
if (existing == null) {
|
||||
manager.addAutomaticZenRule(rule(settings, enabled = true))
|
||||
return@guarded InstallOutcome.CREATED
|
||||
}
|
||||
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
|
||||
// would otherwise overwrite. Android 15 keeps both itself.
|
||||
manager.updateAutomaticZenRule(
|
||||
existing.key,
|
||||
rule(settings, enabled = existing.value.isEnabled, kept = existing.value.zenPolicy),
|
||||
)
|
||||
return@guarded InstallOutcome.UPDATED
|
||||
}
|
||||
InstallOutcome.UNCHANGED
|
||||
}
|
||||
|
||||
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) {
|
||||
// A schedule's own transition, so Android 15 keeps a user's manual
|
||||
// override of the mode until the schedule's next one.
|
||||
Condition(conditionId, summary, state, Condition.SOURCE_SCHEDULE)
|
||||
} else {
|
||||
Condition(conditionId, summary, state)
|
||||
}
|
||||
manager.setAutomaticZenRuleState(id, condition)
|
||||
true
|
||||
}
|
||||
|
||||
override fun uninstall(): Boolean =
|
||||
guarded(false) {
|
||||
ownRules().keys.forEach(manager::removeAutomaticZenRule)
|
||||
true
|
||||
}
|
||||
|
||||
override fun ruleId(): String? = guarded(null) { ownRules().keys.firstOrNull() }
|
||||
|
||||
private fun ownRules(): Map<String, AutomaticZenRule> =
|
||||
manager.automaticZenRules.filterValues { it.conditionId == conditionId }
|
||||
|
||||
/**
|
||||
* [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 = 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 {
|
||||
AutomaticZenRule(
|
||||
name,
|
||||
null,
|
||||
configuration,
|
||||
conditionId,
|
||||
policy,
|
||||
NotificationManager.INTERRUPTION_FILTER_PRIORITY,
|
||||
enabled,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
|
||||
private fun modeRule(
|
||||
name: String,
|
||||
configuration: ComponentName,
|
||||
policy: ZenPolicy,
|
||||
settings: BedtimeSettings,
|
||||
enabled: Boolean,
|
||||
): AutomaticZenRule = AutomaticZenRule.Builder(name, conditionId)
|
||||
.setType(AutomaticZenRule.TYPE_SCHEDULE_TIME)
|
||||
.setConfigurationActivity(configuration)
|
||||
.setZenPolicy(policy)
|
||||
.setInterruptionFilter(NotificationManager.INTERRUPTION_FILTER_PRIORITY)
|
||||
.setDeviceEffects(
|
||||
ZenDeviceEffects.Builder()
|
||||
.setShouldDisplayGrayscale(settings.effects.grayscale)
|
||||
.setShouldDimWallpaper(settings.effects.dimWallpaper)
|
||||
.setShouldUseNightMode(settings.effects.darkTheme)
|
||||
.setShouldSuppressAmbientDisplay(settings.effects.hideAlwaysOnDisplay)
|
||||
.build(),
|
||||
)
|
||||
.setTriggerDescription(
|
||||
context.getString(
|
||||
R.string.bedtime_mode_trigger,
|
||||
formatTime(settings.bedtime),
|
||||
formatTime(settings.wakeTime),
|
||||
),
|
||||
)
|
||||
.setIconResId(R.drawable.ic_bedtime)
|
||||
.setManualInvocationAllowed(true)
|
||||
.setEnabled(enabled)
|
||||
.build()
|
||||
|
||||
private fun formatTime(time: TimeOfDay): String = formatBedtimeTime(context, LocalTime.of(time.hour, time.minute))
|
||||
|
||||
private inline fun <T> guarded(fallback: T, block: () -> T): T = try {
|
||||
block()
|
||||
} catch (_: RuntimeException) {
|
||||
fallback
|
||||
}
|
||||
}
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
package de.jeanlucmakiola.clockula.bedtime.android
|
||||
|
||||
import android.Manifest
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.jeanlucmakiola.clockula.MainActivity
|
||||
import de.jeanlucmakiola.clockula.R
|
||||
import de.jeanlucmakiola.clockula.bedtime.BedtimeIntents
|
||||
import de.jeanlucmakiola.clockula.bedtime.BedtimeNotifier
|
||||
import de.jeanlucmakiola.clockula.domain.time.ZoneProvider
|
||||
import kotlin.time.Instant
|
||||
import kotlin.time.toJavaInstant
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* The "bedtime soon" reminder, on a channel of its own so it can be silenced
|
||||
* without touching anything an alarm or timer posts. Denied the notification
|
||||
* permission, the user loses the reminder and nothing else.
|
||||
*/
|
||||
@Singleton
|
||||
class AndroidBedtimeNotifier @Inject constructor(
|
||||
@param:ApplicationContext private val context: Context,
|
||||
private val zones: ZoneProvider,
|
||||
) : BedtimeNotifier {
|
||||
|
||||
private val notifications = NotificationManagerCompat.from(context)
|
||||
|
||||
override fun showReminder(start: Instant, minutesBefore: Int) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) !=
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
return
|
||||
}
|
||||
val time = formatBedtimeTime(context, start.toJavaInstant().atZone(zones.current()).toLocalTime())
|
||||
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_bedtime)
|
||||
.setContentTitle(
|
||||
context.resources.getQuantityString(R.plurals.bedtime_reminder_title, minutesBefore, minutesBefore),
|
||||
)
|
||||
.setContentText(context.getString(R.string.bedtime_reminder_text, time))
|
||||
.setCategory(NotificationCompat.CATEGORY_REMINDER)
|
||||
.setAutoCancel(true)
|
||||
.setContentIntent(showPendingIntent())
|
||||
.build()
|
||||
runCatching { notifications.notify(NOTIFICATION_ID, notification) }
|
||||
}
|
||||
|
||||
override fun cancelReminder() = notifications.cancel(NOTIFICATION_ID)
|
||||
|
||||
private fun showPendingIntent(): PendingIntent = PendingIntent.getActivity(
|
||||
context,
|
||||
BedtimeIntents.REQUEST_SHOW,
|
||||
Intent(context, MainActivity::class.java)
|
||||
.setAction(BedtimeIntents.ACTION_SHOW_BEDTIME)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
|
||||
companion object {
|
||||
const val CHANNEL_ID: String = "bedtime_reminder"
|
||||
|
||||
/** Next to the other fixed ids (1_001..1_004), below the snoozed alarms' 2_000 + id. */
|
||||
private const val NOTIFICATION_ID: Int = 1_005
|
||||
|
||||
/** Idempotent; called from the app class on every start, like the other channels. */
|
||||
fun createChannel(context: Context) {
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
context.getString(R.string.channel_bedtime_name),
|
||||
NotificationManager.IMPORTANCE_DEFAULT,
|
||||
).apply {
|
||||
description = context.getString(R.string.channel_bedtime_description)
|
||||
}
|
||||
context.getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
|
||||
}
|
||||
}
|
||||
}
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
package de.jeanlucmakiola.clockula.bedtime.android
|
||||
|
||||
import android.app.AlarmManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.jeanlucmakiola.clockula.bedtime.BedtimeIntents
|
||||
import de.jeanlucmakiola.clockula.bedtime.BedtimeScheduler
|
||||
import de.jeanlucmakiola.clockula.bedtime.receiver.BedtimeReceiver
|
||||
import kotlin.time.Instant
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* One wall-clock slot for the schedule's next event. Never `setAlarmClock`:
|
||||
* that slot is the status bar's "next alarm", and bedtime is not an alarm.
|
||||
*
|
||||
* Only the wake-up that ends the mode is allow-while-idle, so Do Not Disturb
|
||||
* cannot linger hours into the morning behind a doze deferral. That is one
|
||||
* such fire a night, well within the per-app ration the timer's expiry slot
|
||||
* also uses (alarms themselves use `setAlarmClock`, which is not rationed).
|
||||
* Bedtime's start, the reminder and retries are exact but doze-deferrable:
|
||||
* a late start in a dozing, untouched phone costs nothing. It is its own
|
||||
* PendingIntent (its own receiver, action and request code), so arming or
|
||||
* cancelling it can never touch an alarm's or a timer's registration.
|
||||
*/
|
||||
@Singleton
|
||||
class AndroidBedtimeScheduler @Inject constructor(
|
||||
@param:ApplicationContext private val context: Context,
|
||||
) : BedtimeScheduler {
|
||||
|
||||
private val manager: AlarmManager = context.getSystemService(AlarmManager::class.java)
|
||||
|
||||
override fun schedule(at: Instant, urgent: Boolean) {
|
||||
val millis = at.toEpochMilliseconds()
|
||||
val pendingIntent = slotPendingIntent()
|
||||
if (urgent) {
|
||||
val exact = runCatching {
|
||||
manager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, millis, pendingIntent)
|
||||
}.isSuccess
|
||||
if (!exact) runCatching { manager.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, millis, pendingIntent) }
|
||||
return
|
||||
}
|
||||
val exact = runCatching { manager.setExact(AlarmManager.RTC_WAKEUP, millis, pendingIntent) }.isSuccess
|
||||
// Guarded too: bedtime losing its slot is survivable, a crash in the
|
||||
// receiver's process is not.
|
||||
// A bounded window rather than `set`, whose slack grows with the
|
||||
// distance to the trigger and could end an 8-hour night hours late.
|
||||
if (!exact) {
|
||||
runCatching { manager.setWindow(AlarmManager.RTC_WAKEUP, millis, FALLBACK_WINDOW_MILLIS, pendingIntent) }
|
||||
}
|
||||
}
|
||||
|
||||
override fun cancel() = manager.cancel(slotPendingIntent())
|
||||
|
||||
private companion object {
|
||||
/** The platform's own minimum window from Android 12 on. */
|
||||
const val FALLBACK_WINDOW_MILLIS = 10 * 60_000L
|
||||
}
|
||||
|
||||
private fun slotPendingIntent(): PendingIntent = PendingIntent.getBroadcast(
|
||||
context,
|
||||
BedtimeIntents.REQUEST_SLOT,
|
||||
Intent(context, BedtimeReceiver::class.java).setAction(BedtimeIntents.ACTION_SLOT),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package de.jeanlucmakiola.clockula.bedtime.android
|
||||
|
||||
import android.content.Context
|
||||
import android.text.format.DateFormat
|
||||
import java.time.LocalTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.format.FormatStyle
|
||||
|
||||
/**
|
||||
* The same two steps `ui/common/AlarmTimeFormatter.kt` takes — the device's
|
||||
* 12/24-hour choice, then the locale's short form — for the two bedtime
|
||||
* strings the system shows outside the app: the mode's trigger line and the
|
||||
* reminder notification.
|
||||
*/
|
||||
internal fun formatBedtimeTime(context: Context, time: LocalTime): String {
|
||||
val formatter = if (DateFormat.is24HourFormat(context)) {
|
||||
DateTimeFormatter.ofPattern("HH:mm")
|
||||
} else {
|
||||
DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)
|
||||
}
|
||||
return formatter.format(time)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package de.jeanlucmakiola.clockula.bedtime.di
|
||||
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import de.jeanlucmakiola.clockula.bedtime.BedtimeMode
|
||||
import de.jeanlucmakiola.clockula.bedtime.BedtimeNotifier
|
||||
import de.jeanlucmakiola.clockula.bedtime.BedtimeScheduler
|
||||
import de.jeanlucmakiola.clockula.bedtime.android.AndroidBedtimeMode
|
||||
import de.jeanlucmakiola.clockula.bedtime.android.AndroidBedtimeNotifier
|
||||
import de.jeanlucmakiola.clockula.bedtime.android.AndroidBedtimeScheduler
|
||||
import javax.inject.Singleton
|
||||
|
||||
/** The three seams `BedtimeEngine` sees, which keep it JVM-testable. */
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
abstract class BedtimeModule {
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindBedtimeScheduler(impl: AndroidBedtimeScheduler): BedtimeScheduler
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindBedtimeMode(impl: AndroidBedtimeMode): BedtimeMode
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindBedtimeNotifier(impl: AndroidBedtimeNotifier): BedtimeNotifier
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package de.jeanlucmakiola.clockula.bedtime.receiver
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import de.jeanlucmakiola.clockula.alarm.receiver.HiltBroadcastReceiver
|
||||
import de.jeanlucmakiola.clockula.bedtime.BedtimeEngine
|
||||
import de.jeanlucmakiola.clockula.bedtime.BedtimeIntents
|
||||
import de.jeanlucmakiola.floret.di.ApplicationScope
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* The bedtime slot arriving. Do Not Disturb access changing is not heard
|
||||
* here: the platform delivers `ACTION_NOTIFICATION_POLICY_ACCESS_GRANTED_CHANGED`
|
||||
* to runtime-registered receivers only, so the bedtime screen re-reads access
|
||||
* on every `ON_RESUME` instead, and any later pass picks a grant up.
|
||||
*/
|
||||
@AndroidEntryPoint
|
||||
class BedtimeReceiver : HiltBroadcastReceiver() {
|
||||
|
||||
@Inject
|
||||
lateinit var engine: BedtimeEngine
|
||||
|
||||
@Inject
|
||||
@ApplicationScope
|
||||
lateinit var scope: CoroutineScope
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
super.onReceive(context, intent)
|
||||
|
||||
val action = intent.action ?: return
|
||||
val pendingResult = goAsync()
|
||||
scope.launch {
|
||||
try {
|
||||
// Guarded: a platform refusal here must not crash the process,
|
||||
// which is the process an alarm may be about to ring in.
|
||||
if (action == BedtimeIntents.ACTION_SLOT) runCatching { engine.onSlotFired() }
|
||||
} finally {
|
||||
pendingResult.finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,9 @@ import de.jeanlucmakiola.clockula.domain.backup.BackupContent
|
||||
import de.jeanlucmakiola.clockula.domain.backup.BackupSettings
|
||||
import de.jeanlucmakiola.clockula.domain.backup.BackupTimer
|
||||
import de.jeanlucmakiola.clockula.domain.backup.BackupWorldClock
|
||||
import de.jeanlucmakiola.clockula.domain.bedtime.BedtimeEffects
|
||||
import de.jeanlucmakiola.clockula.domain.bedtime.BedtimeLimits
|
||||
import de.jeanlucmakiola.clockula.domain.bedtime.BedtimeSettings
|
||||
import de.jeanlucmakiola.clockula.domain.timer.TimerPresets
|
||||
import de.jeanlucmakiola.floret.prefs.ThemeMode
|
||||
import de.jeanlucmakiola.floret.prefs.toEnum
|
||||
@@ -128,8 +131,44 @@ object BackupCodec {
|
||||
dismissChallenge = defaults.dismissChallenge.name,
|
||||
homeZoneId = defaults.homeZoneId,
|
||||
timerPresets = timerPresets.map { it.inWholeMilliseconds },
|
||||
bedtime = bedtime?.toDto(),
|
||||
)
|
||||
|
||||
private fun BedtimeSettings.toDto() = BedtimeDto(
|
||||
enabled = enabled,
|
||||
bedtimeMinutes = bedtime.minutesOfDay,
|
||||
wakeMinutes = wakeTime.minutesOfDay,
|
||||
days = days.mask,
|
||||
reminderMinutes = reminderMinutes,
|
||||
grayscale = effects.grayscale,
|
||||
dimWallpaper = effects.dimWallpaper,
|
||||
darkTheme = effects.darkTheme,
|
||||
hideAlwaysOnDisplay = effects.hideAlwaysOnDisplay,
|
||||
)
|
||||
|
||||
/**
|
||||
* Forgiving, like the other settings: an out-of-range time or reminder is
|
||||
* clamped rather than failing the import.
|
||||
*/
|
||||
private fun BedtimeDto.toBedtime() = BedtimeSettings(
|
||||
enabled = enabled,
|
||||
bedtime = minutesToTime(bedtimeMinutes),
|
||||
wakeTime = minutesToTime(wakeMinutes),
|
||||
days = BedtimeLimits.nights(days),
|
||||
reminderMinutes = reminderMinutes.coerceIn(BedtimeLimits.REMINDER_MINUTES),
|
||||
effects = BedtimeEffects(
|
||||
grayscale = grayscale,
|
||||
dimWallpaper = dimWallpaper,
|
||||
darkTheme = darkTheme,
|
||||
hideAlwaysOnDisplay = hideAlwaysOnDisplay,
|
||||
),
|
||||
)
|
||||
|
||||
private fun minutesToTime(minutes: Int): TimeOfDay {
|
||||
val clamped = minutes.coerceIn(0, 24 * 60 - 1)
|
||||
return TimeOfDay(clamped / 60, clamped % 60)
|
||||
}
|
||||
|
||||
// --- decode + validate ---
|
||||
|
||||
private fun BackupDocument.toContent(): BackupResult {
|
||||
@@ -235,6 +274,7 @@ object BackupCodec {
|
||||
homeZoneId = settingsDto.homeZoneId?.let(Zones::normalise),
|
||||
),
|
||||
timerPresets = settingsDto.timerPresets?.map { it.milliseconds } ?: TimerPresets.BUILT_IN,
|
||||
bedtime = settingsDto.bedtime?.toBedtime(),
|
||||
)
|
||||
|
||||
return BackupResult.Success(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -77,4 +78,26 @@ data class SettingsDto(
|
||||
* built-in ones.
|
||||
*/
|
||||
@SerialName("timer_presets") val timerPresets: List<Long>? = null,
|
||||
/** Absent from files written before issue #1; restoring one keeps the current bedtime settings. */
|
||||
val bedtime: BedtimeDto? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* The bedtime schedule (issue #1). Times are minutes since midnight, like the
|
||||
* `BedtimePrefs` keys; `days` is the same 7-bit mask an alarm's `repeat_days` is.
|
||||
*/
|
||||
@Serializable
|
||||
data class BedtimeDto(
|
||||
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()
|
||||
@@ -0,0 +1,77 @@
|
||||
package de.jeanlucmakiola.clockula.data.prefs
|
||||
|
||||
import de.jeanlucmakiola.clockula.domain.RepeatDays
|
||||
import de.jeanlucmakiola.clockula.domain.TimeOfDay
|
||||
import de.jeanlucmakiola.clockula.domain.bedtime.BedtimeLimits
|
||||
import de.jeanlucmakiola.clockula.domain.bedtime.BedtimeSettings
|
||||
import de.jeanlucmakiola.floret.prefs.Pref
|
||||
import de.jeanlucmakiola.floret.prefs.booleanPref
|
||||
import de.jeanlucmakiola.floret.prefs.intPref
|
||||
|
||||
/**
|
||||
* The bedtime schedule's on-disk keys (issue #1). Configuration only: the
|
||||
* id of the system mode Clockula created is not stored at all, because the
|
||||
* platform already lists an app's own rules (`AndroidBedtimeMode`), and a
|
||||
* stored id would go stale after a restore onto another device.
|
||||
*/
|
||||
object BedtimePrefs {
|
||||
|
||||
private val DEFAULT = BedtimeSettings()
|
||||
|
||||
val enabled: Pref<Boolean> = booleanPref("bedtime_enabled", DEFAULT.enabled)
|
||||
|
||||
val bedtime: Pref<TimeOfDay> = intPref("bedtime_start_minutes", DEFAULT.bedtime.minutesOfDay).asTimeOfDay()
|
||||
|
||||
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 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 = BedtimeLimits::nights,
|
||||
encode = { it.mask },
|
||||
)
|
||||
|
||||
val reminderMinutes: Pref<Int> = intPref("bedtime_reminder_minutes", DEFAULT.reminderMinutes)
|
||||
.map(
|
||||
decode = { it.coerceIn(BedtimeLimits.REMINDER_MINUTES) },
|
||||
encode = { it.coerceIn(BedtimeLimits.REMINDER_MINUTES) },
|
||||
)
|
||||
|
||||
val grayscale: Pref<Boolean> = booleanPref("bedtime_effect_grayscale", DEFAULT.effects.grayscale)
|
||||
|
||||
val dimWallpaper: Pref<Boolean> = booleanPref("bedtime_effect_dim_wallpaper", DEFAULT.effects.dimWallpaper)
|
||||
|
||||
val darkTheme: Pref<Boolean> = booleanPref("bedtime_effect_dark_theme", DEFAULT.effects.darkTheme)
|
||||
|
||||
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)
|
||||
|
||||
/** A rule rewrite the system refused, still owed; device state like the above. */
|
||||
val ruleStale: Pref<Boolean> = booleanPref("bedtime_rule_stale", false)
|
||||
|
||||
/** A state change the system refused, still owed; device state like the above. */
|
||||
val stateOwed: Pref<Boolean> = booleanPref("bedtime_state_owed", false)
|
||||
|
||||
/** Minutes since midnight on disk, clamped into a day both ways. */
|
||||
private fun Pref<Int>.asTimeOfDay(): Pref<TimeOfDay> = map(
|
||||
decode = { minutes ->
|
||||
val clamped = minutes.coerceIn(0, MINUTES_PER_DAY - 1)
|
||||
TimeOfDay(clamped / 60, clamped % 60)
|
||||
},
|
||||
encode = { it.minutesOfDay },
|
||||
)
|
||||
|
||||
private const val MINUTES_PER_DAY = 24 * 60
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
package de.jeanlucmakiola.clockula.data.prefs
|
||||
|
||||
import androidx.datastore.preferences.core.MutablePreferences
|
||||
import de.jeanlucmakiola.clockula.domain.ClockDefaults
|
||||
import de.jeanlucmakiola.clockula.domain.DismissChallenge
|
||||
import de.jeanlucmakiola.clockula.domain.backup.BackupSettings
|
||||
import de.jeanlucmakiola.clockula.domain.bedtime.BedtimeEffects
|
||||
import de.jeanlucmakiola.clockula.domain.bedtime.BedtimeSettings
|
||||
import de.jeanlucmakiola.clockula.domain.timer.TimerPresets
|
||||
import de.jeanlucmakiola.floret.prefs.Appearance
|
||||
import de.jeanlucmakiola.floret.prefs.AppearancePrefs
|
||||
@@ -93,6 +96,8 @@ class SettingsPrefs @Inject constructor(private val store: PrefStore) {
|
||||
prefs[ClockPrefs.dismissChallenge] = backup.defaults.dismissChallenge
|
||||
prefs[ClockPrefs.homeZoneId] = backup.defaults.homeZoneId
|
||||
prefs[TimerPrefs.presets] = backup.timerPresets
|
||||
// Absent from a file written before bedtime existed: keep it.
|
||||
backup.bedtime?.let { prefs.putBedtime(it) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,6 +138,71 @@ class SettingsPrefs @Inject constructor(private val store: PrefStore) {
|
||||
store.update(TimerPrefs.presets) { TimerPresets.minus(it, value) }
|
||||
}
|
||||
|
||||
// --- bedtime (issue #1) ---
|
||||
|
||||
/** Distinct-until-changed, composed from the individual keys like [defaults]. */
|
||||
val bedtime: Flow<BedtimeSettings> = combine(
|
||||
combine(
|
||||
store.flow(BedtimePrefs.enabled),
|
||||
store.flow(BedtimePrefs.bedtime),
|
||||
store.flow(BedtimePrefs.wakeTime),
|
||||
store.flow(BedtimePrefs.days),
|
||||
store.flow(BedtimePrefs.reminderMinutes),
|
||||
) { enabled, bedtime, wakeTime, days, reminderMinutes ->
|
||||
BedtimeSettings(
|
||||
enabled = enabled,
|
||||
bedtime = bedtime,
|
||||
wakeTime = wakeTime,
|
||||
days = days,
|
||||
reminderMinutes = reminderMinutes,
|
||||
)
|
||||
},
|
||||
store.flow(BedtimePrefs.grayscale),
|
||||
store.flow(BedtimePrefs.dimWallpaper),
|
||||
store.flow(BedtimePrefs.darkTheme),
|
||||
store.flow(BedtimePrefs.hideAlwaysOnDisplay),
|
||||
) { partial, grayscale, dimWallpaper, darkTheme, hideAlwaysOnDisplay ->
|
||||
partial.copy(
|
||||
effects = BedtimeEffects(
|
||||
grayscale = grayscale,
|
||||
dimWallpaper = dimWallpaper,
|
||||
darkTheme = darkTheme,
|
||||
hideAlwaysOnDisplay = hideAlwaysOnDisplay,
|
||||
),
|
||||
)
|
||||
}.distinctUntilChanged()
|
||||
|
||||
suspend fun currentBedtime(): BedtimeSettings = bedtime.first()
|
||||
|
||||
/** Every bedtime key in one transaction. `BedtimeEngine` is the only writer. */
|
||||
suspend fun setBedtime(settings: BedtimeSettings) {
|
||||
store.edit { prefs -> prefs.putBedtime(settings) }
|
||||
}
|
||||
|
||||
suspend fun bedtimeAppliedActive(): Boolean = store.get(BedtimePrefs.appliedActive)
|
||||
|
||||
suspend fun setBedtimeAppliedActive(active: Boolean) = store.set(BedtimePrefs.appliedActive, active)
|
||||
|
||||
suspend fun bedtimeRuleStale(): Boolean = store.get(BedtimePrefs.ruleStale)
|
||||
|
||||
suspend fun setBedtimeRuleStale(stale: Boolean) = store.set(BedtimePrefs.ruleStale, stale)
|
||||
|
||||
suspend fun bedtimeStateOwed(): Boolean = store.get(BedtimePrefs.stateOwed)
|
||||
|
||||
suspend fun setBedtimeStateOwed(owed: Boolean) = store.set(BedtimePrefs.stateOwed, owed)
|
||||
|
||||
private fun MutablePreferences.putBedtime(settings: BedtimeSettings) {
|
||||
this[BedtimePrefs.enabled] = settings.enabled
|
||||
this[BedtimePrefs.bedtime] = settings.bedtime
|
||||
this[BedtimePrefs.wakeTime] = settings.wakeTime
|
||||
this[BedtimePrefs.days] = settings.days
|
||||
this[BedtimePrefs.reminderMinutes] = settings.reminderMinutes
|
||||
this[BedtimePrefs.grayscale] = settings.effects.grayscale
|
||||
this[BedtimePrefs.dimWallpaper] = settings.effects.dimWallpaper
|
||||
this[BedtimePrefs.darkTheme] = settings.effects.darkTheme
|
||||
this[BedtimePrefs.hideAlwaysOnDisplay] = settings.effects.hideAlwaysOnDisplay
|
||||
}
|
||||
|
||||
// --- first-launch bookkeeping (M4) ---
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,6 +4,7 @@ import de.jeanlucmakiola.clockula.domain.ClockDefaults
|
||||
import de.jeanlucmakiola.clockula.domain.DismissChallenge
|
||||
import de.jeanlucmakiola.clockula.domain.RepeatDays
|
||||
import de.jeanlucmakiola.clockula.domain.TimeOfDay
|
||||
import de.jeanlucmakiola.clockula.domain.bedtime.BedtimeSettings
|
||||
import de.jeanlucmakiola.floret.prefs.ThemeMode
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Instant
|
||||
@@ -56,4 +57,6 @@ data class BackupSettings(
|
||||
val dynamicColor: Boolean,
|
||||
val defaults: ClockDefaults,
|
||||
val timerPresets: List<Duration>,
|
||||
/** Null for a file written before bedtime existed: restoring it keeps the current schedule. */
|
||||
val bedtime: BedtimeSettings? = null,
|
||||
)
|
||||
@@ -0,0 +1,101 @@
|
||||
package de.jeanlucmakiola.clockula.domain.bedtime
|
||||
|
||||
import de.jeanlucmakiola.clockula.domain.alarm.AlarmOccurrences
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneId
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
import kotlin.time.Instant
|
||||
import kotlin.time.toJavaInstant
|
||||
|
||||
/** One night of bedtime: on from [start] (inclusive) to [end] (exclusive). */
|
||||
data class BedtimeWindow(val start: Instant, val end: Instant) {
|
||||
operator fun contains(instant: Instant): Boolean = instant >= start && instant < end
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a bedtime schedule stands at a given instant. Like the alarm path,
|
||||
* every window is re-resolved from local times and the zone, never by adding
|
||||
* a fixed length to the last one, so a DST night is as long as the clock
|
||||
* says it is.
|
||||
*/
|
||||
object BedtimeSchedule {
|
||||
|
||||
/**
|
||||
* The window for the night of [date], if [date] is one of the schedule's
|
||||
* nights. A night is named by its evening, as people name it: a bedtime
|
||||
* in the small hours (before 06:00; 00:30, say) is *after* that evening,
|
||||
* so "Thursday night" at 00:30 starts on Friday's calendar date. A later
|
||||
* one (a day sleeper's 08:00) is on the day itself. It ends at the first wake time after it starts, so a wake time
|
||||
* 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 || settings.bedtime == settings.wakeTime) return null
|
||||
// Next morning or same day is decided on the local times, before DST
|
||||
// resolution: a bedtime in a spring-forward gap shifts later and must
|
||||
// not push a same-night wake time to the following day.
|
||||
val startDate = if (settings.bedtime.minutesOfDay < SMALL_HOURS_END) date.plusDays(1) else date
|
||||
val endDate =
|
||||
if (settings.wakeTime.minutesOfDay > settings.bedtime.minutesOfDay) startDate else startDate.plusDays(1)
|
||||
val start = AlarmOccurrences.at(startDate, settings.bedtime, zone)
|
||||
val end = AlarmOccurrences.at(endDate, settings.wakeTime, zone)
|
||||
return if (end > start) BedtimeWindow(start, end) else null
|
||||
}
|
||||
|
||||
/** The window [now] falls in, or null when it is daytime or the schedule is off. */
|
||||
fun activeWindow(settings: BedtimeSettings, now: Instant, zone: ZoneId): BedtimeWindow? {
|
||||
if (!settings.enabled) return null
|
||||
val today = today(now, zone)
|
||||
// A window is at most a day long and starts on its night's date or
|
||||
// the day after, so one that contains now belongs to one of the last
|
||||
// three nights.
|
||||
return listOf(today, today.minusDays(1), today.minusDays(2))
|
||||
.firstNotNullOfOrNull { date -> windowOn(settings, date, zone)?.takeIf { now in it } }
|
||||
}
|
||||
|
||||
/** The first window that starts strictly after [now], within the coming week. */
|
||||
fun nextWindow(settings: BedtimeSettings, now: Instant, zone: ZoneId): BedtimeWindow? {
|
||||
if (!settings.enabled) return null
|
||||
val today = today(now, zone)
|
||||
return (-1L..7L)
|
||||
.firstNotNullOfOrNull { offset ->
|
||||
windowOn(settings, today.plusDays(offset), zone)?.takeIf { it.start > now }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The upcoming window whose reminder is due at [now]: [now] lies between
|
||||
* its reminder time and its start. Null when reminders are off, or when
|
||||
* [now] is already inside a window (the reminder has nothing left to say).
|
||||
*/
|
||||
fun dueReminder(settings: BedtimeSettings, now: Instant, zone: ZoneId): BedtimeWindow? {
|
||||
if (!settings.enabled || settings.reminderMinutes <= 0) return null
|
||||
if (activeWindow(settings, now, zone) != null) return null
|
||||
val next = nextWindow(settings, now, zone) ?: return null
|
||||
return next.takeIf { now >= reminderAt(settings, next) }
|
||||
}
|
||||
|
||||
/**
|
||||
* When the schedule next needs attention: the end of the current window,
|
||||
* the next window's reminder or the next window's start, whichever comes
|
||||
* first. Null when there is nothing to schedule.
|
||||
*/
|
||||
fun nextEvent(settings: BedtimeSettings, now: Instant, zone: ZoneId): Instant? {
|
||||
if (!settings.enabled) return null
|
||||
val active = activeWindow(settings, now, zone)
|
||||
val next = nextWindow(settings, now, zone)
|
||||
val reminder = next
|
||||
?.takeIf { settings.reminderMinutes > 0 }
|
||||
?.let { reminderAt(settings, it) }
|
||||
?.takeIf { it > now }
|
||||
return listOfNotNull(active?.end, reminder, next?.start).minOrNull()
|
||||
}
|
||||
|
||||
private fun reminderAt(settings: BedtimeSettings, window: BedtimeWindow): Instant =
|
||||
window.start - settings.reminderMinutes.minutes
|
||||
|
||||
private const val SMALL_HOURS_END = 6 * 60
|
||||
|
||||
private fun today(now: Instant, zone: ZoneId): LocalDate = now.toJavaInstant().atZone(zone).toLocalDate()
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package de.jeanlucmakiola.clockula.domain.bedtime
|
||||
|
||||
import de.jeanlucmakiola.clockula.domain.RepeatDays
|
||||
import de.jeanlucmakiola.clockula.domain.TimeOfDay
|
||||
|
||||
/**
|
||||
* The bedtime schedule (issue #1): an opt-in Do Not Disturb mode Clockula
|
||||
* turns on at [bedtime] and off at [wakeTime]. [days] are the nights the
|
||||
* mode *starts* on, so a Friday-only schedule runs from Friday evening into
|
||||
* Saturday morning.
|
||||
*/
|
||||
data class BedtimeSettings(
|
||||
val enabled: Boolean = false,
|
||||
val bedtime: TimeOfDay = TimeOfDay(22, 30),
|
||||
val wakeTime: TimeOfDay = TimeOfDay(7, 0),
|
||||
val days: RepeatDays = RepeatDays.EVERY_DAY,
|
||||
/** How long before [bedtime] to post a reminder; 0 is off. */
|
||||
val reminderMinutes: Int = 0,
|
||||
val effects: BedtimeEffects = BedtimeEffects(),
|
||||
)
|
||||
|
||||
/**
|
||||
* The screen effects a mode can carry on Android 15 and later
|
||||
* (`ZenDeviceEffects`). Earlier versions keep the setting and ignore it.
|
||||
*/
|
||||
data class BedtimeEffects(
|
||||
val grayscale: Boolean = true,
|
||||
val dimWallpaper: Boolean = true,
|
||||
val darkTheme: Boolean = true,
|
||||
val hideAlwaysOnDisplay: Boolean = false,
|
||||
)
|
||||
|
||||
object BedtimeLimits {
|
||||
/** The reminder lead times the picker offers, in minutes; 0 is off. */
|
||||
val REMINDER_CHOICES: List<Int> = listOf(0, 15, 30, 45, 60)
|
||||
|
||||
/** Anything read from disk or a backup is clamped into this. */
|
||||
val REMINDER_MINUTES: IntRange = 0..120
|
||||
|
||||
/**
|
||||
* The nights from a stored mask. The screen never lets the last night go,
|
||||
* so an empty mask (corrupt, or written around it) reads as every night,
|
||||
* on disk and in a backup alike.
|
||||
*/
|
||||
fun nights(mask: Int): RepeatDays = RepeatDays.fromMask(mask).takeIf { it.isRepeating } ?: RepeatDays.EVERY_DAY
|
||||
}
|
||||
@@ -155,9 +155,9 @@ object SelfCheck {
|
||||
/**
|
||||
* Only total silence (`INTERRUPTION_FILTER_NONE`) is treated as blocking
|
||||
* alarms: Priority and Alarms-only modes leave the system's own alarm
|
||||
* exemption in effect. Clockula never requests `ACCESS_NOTIFICATION_POLICY`
|
||||
* and never changes DND itself — it only reports what the user already has
|
||||
* (`docs/PLAN.md` §4).
|
||||
* exemption in effect. The check never changes DND itself — it only
|
||||
* reports what the user already has (`docs/PLAN.md` §4). The opt-in
|
||||
* bedtime mode does change it, with a rule that always allows alarms.
|
||||
*/
|
||||
private fun dnd(state: DeviceState): CheckResult {
|
||||
val blocked = state.interruptionFilter == INTERRUPTION_FILTER_NONE
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
package de.jeanlucmakiola.clockula.ui.bedtime
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.DoNotDisturbOn
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.clockula.R
|
||||
import de.jeanlucmakiola.clockula.domain.bedtime.BedtimeLimits
|
||||
import de.jeanlucmakiola.clockula.ui.alarms.AlarmTimePickerDialog
|
||||
import de.jeanlucmakiola.clockula.ui.alarms.RepeatDaySelector
|
||||
import de.jeanlucmakiola.clockula.ui.common.rememberAlarmTimeFormatter
|
||||
import de.jeanlucmakiola.clockula.ui.settings.SettingsHint
|
||||
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.GroupedSectionHeader
|
||||
import de.jeanlucmakiola.floret.components.OptionPicker
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
|
||||
/** Which of the screen's dialogs is open. */
|
||||
private enum class BedtimePicker { BEDTIME, WAKE_TIME, REMINDER }
|
||||
|
||||
/**
|
||||
* Bedtime (issue #1): an opt-in Do Not Disturb mode that Clockula turns on at
|
||||
* bedtime and off at the wake-up time, with an optional reminder before it
|
||||
* starts and, on Android 15 and later, screen effects. Reached from Settings
|
||||
* rather than a fifth tab, from the reminder, and from the mode's own page in
|
||||
* the system settings.
|
||||
*/
|
||||
@Composable
|
||||
fun BedtimeScreen(
|
||||
onBack: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: BedtimeViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val context = LocalContext.current
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
val formatTime = rememberAlarmTimeFormatter()
|
||||
var picker by rememberSaveable { mutableStateOf<BedtimePicker?>(null) }
|
||||
|
||||
// Access is granted in the system's settings, so it is re-read whenever
|
||||
// the user comes back from there.
|
||||
DisposableEffect(lifecycleOwner) {
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_RESUME) viewModel.refresh()
|
||||
}
|
||||
lifecycleOwner.lifecycle.addObserver(observer)
|
||||
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
|
||||
}
|
||||
|
||||
val loaded = state as? BedtimeUiState.Loaded
|
||||
|
||||
CollapsingScaffold(
|
||||
title = stringResource(R.string.bedtime_title),
|
||||
onBack = onBack,
|
||||
modifier = modifier,
|
||||
) {
|
||||
if (loaded == null) return@CollapsingScaffold
|
||||
val settings = loaded.settings
|
||||
|
||||
SettingsHint(stringResource(R.string.bedtime_hint))
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.bedtime_enabled),
|
||||
summary = when {
|
||||
!settings.enabled -> stringResource(R.string.bedtime_enabled_off)
|
||||
// No window at all (BedtimeSchedule): say so rather than "On".
|
||||
settings.bedtime == settings.wakeTime -> stringResource(R.string.bedtime_enabled_same_time)
|
||||
else -> stringResource(
|
||||
R.string.bedtime_enabled_on,
|
||||
formatTime(settings.bedtime),
|
||||
formatTime(settings.wakeTime),
|
||||
)
|
||||
},
|
||||
position = Position.Alone,
|
||||
trailing = {
|
||||
Switch(checked = settings.enabled, onCheckedChange = viewModel::onEnabledChanged)
|
||||
},
|
||||
onClick = { viewModel.onEnabledChanged(!settings.enabled) },
|
||||
)
|
||||
|
||||
// The onboarding step: access is a special app access the user grants
|
||||
// by hand, so the row says what it is for and opens the right page.
|
||||
if (!loaded.hasAccess) {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.bedtime_access_title),
|
||||
summary = stringResource(R.string.bedtime_access_summary),
|
||||
position = Position.Alone,
|
||||
leading = {
|
||||
Icon(
|
||||
Icons.Rounded.DoNotDisturbOn,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.tertiary,
|
||||
)
|
||||
},
|
||||
trailing = {
|
||||
TextButton(onClick = { openAccessSettings(context) }) {
|
||||
Text(stringResource(R.string.bedtime_access_allow))
|
||||
}
|
||||
},
|
||||
onClick = { openAccessSettings(context) },
|
||||
)
|
||||
}
|
||||
|
||||
GroupedSectionHeader(stringResource(R.string.bedtime_section_schedule))
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.bedtime_start),
|
||||
summary = formatTime(settings.bedtime),
|
||||
position = Position.Top,
|
||||
onClick = { picker = BedtimePicker.BEDTIME },
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.bedtime_wake),
|
||||
summary = formatTime(settings.wakeTime),
|
||||
position = Position.Middle,
|
||||
onClick = { picker = BedtimePicker.WAKE_TIME },
|
||||
)
|
||||
RepeatDaySelector(
|
||||
repeatDays = settings.days,
|
||||
onDayToggled = viewModel::onDayToggled,
|
||||
position = Position.Bottom,
|
||||
)
|
||||
SettingsHint(stringResource(R.string.bedtime_days_hint))
|
||||
|
||||
GroupedSectionHeader(stringResource(R.string.bedtime_section_reminder))
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.bedtime_reminder),
|
||||
summary = reminderText(settings.reminderMinutes),
|
||||
position = Position.Alone,
|
||||
onClick = { picker = BedtimePicker.REMINDER },
|
||||
)
|
||||
|
||||
GroupedSectionHeader(stringResource(R.string.bedtime_section_effects))
|
||||
if (loaded.supportsEffects) {
|
||||
EffectRow(
|
||||
title = stringResource(R.string.bedtime_effect_grayscale),
|
||||
checked = settings.effects.grayscale,
|
||||
position = Position.Top,
|
||||
onChange = viewModel::onGrayscaleChanged,
|
||||
)
|
||||
EffectRow(
|
||||
title = stringResource(R.string.bedtime_effect_dim_wallpaper),
|
||||
checked = settings.effects.dimWallpaper,
|
||||
position = Position.Middle,
|
||||
onChange = viewModel::onDimWallpaperChanged,
|
||||
)
|
||||
EffectRow(
|
||||
title = stringResource(R.string.bedtime_effect_dark_theme),
|
||||
checked = settings.effects.darkTheme,
|
||||
position = Position.Middle,
|
||||
onChange = viewModel::onDarkThemeChanged,
|
||||
)
|
||||
EffectRow(
|
||||
title = stringResource(R.string.bedtime_effect_hide_aod),
|
||||
checked = settings.effects.hideAlwaysOnDisplay,
|
||||
position = Position.Bottom,
|
||||
onChange = viewModel::onHideAlwaysOnDisplayChanged,
|
||||
)
|
||||
val ruleId = loaded.ruleId
|
||||
if (ruleId != null) {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.bedtime_open_mode),
|
||||
summary = stringResource(R.string.bedtime_open_mode_summary),
|
||||
position = Position.Alone,
|
||||
onClick = { openModeSettings(context, ruleId) },
|
||||
)
|
||||
}
|
||||
} else {
|
||||
SettingsHint(stringResource(R.string.bedtime_effects_unavailable))
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
SettingsHint(stringResource(R.string.bedtime_alarms_note))
|
||||
Spacer(Modifier.height(8.dp))
|
||||
SettingsHint(stringResource(R.string.bedtime_wellbeing_note))
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
|
||||
val settings = loaded?.settings ?: return
|
||||
when (picker) {
|
||||
null -> Unit
|
||||
BedtimePicker.BEDTIME -> AlarmTimePickerDialog(
|
||||
initial = settings.bedtime,
|
||||
onConfirm = { hour, minute ->
|
||||
viewModel.onBedtimeChanged(hour, minute)
|
||||
picker = null
|
||||
},
|
||||
onDismiss = { picker = null },
|
||||
)
|
||||
BedtimePicker.WAKE_TIME -> AlarmTimePickerDialog(
|
||||
initial = settings.wakeTime,
|
||||
onConfirm = { hour, minute ->
|
||||
viewModel.onWakeTimeChanged(hour, minute)
|
||||
picker = null
|
||||
},
|
||||
onDismiss = { picker = null },
|
||||
)
|
||||
BedtimePicker.REMINDER -> OptionPicker(
|
||||
title = stringResource(R.string.bedtime_reminder),
|
||||
// A restored value the picker does not offer stays choosable.
|
||||
options = (BedtimeLimits.REMINDER_CHOICES + settings.reminderMinutes).distinct().sorted(),
|
||||
selected = settings.reminderMinutes,
|
||||
label = { reminderText(it) },
|
||||
onSelect = {
|
||||
viewModel.onReminderChanged(it)
|
||||
picker = null
|
||||
},
|
||||
onDismiss = { picker = null },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EffectRow(title: String, checked: Boolean, position: Position, onChange: (Boolean) -> Unit) {
|
||||
GroupedRow(
|
||||
title = title,
|
||||
position = position,
|
||||
trailing = { Switch(checked = checked, onCheckedChange = onChange) },
|
||||
onClick = { onChange(!checked) },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun reminderText(minutes: Int): String = if (minutes <= 0) {
|
||||
stringResource(R.string.bedtime_reminder_off)
|
||||
} else {
|
||||
pluralStringResource(R.plurals.bedtime_reminder_before, minutes, minutes)
|
||||
}
|
||||
|
||||
/** Guarded like the self-check's fixes: a settings page a device cannot open must not crash the row. */
|
||||
private fun openAccessSettings(context: Context) {
|
||||
runCatching { context.startActivity(Intent(Settings.ACTION_NOTIFICATION_POLICY_ACCESS_SETTINGS)) }
|
||||
}
|
||||
|
||||
/** Android 15's page for one mode; the link is only offered there. */
|
||||
private fun openModeSettings(context: Context, ruleId: String) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.VANILLA_ICE_CREAM) return
|
||||
runCatching {
|
||||
context.startActivity(
|
||||
Intent(Settings.ACTION_AUTOMATIC_ZEN_RULE_SETTINGS).putExtra(Settings.EXTRA_AUTOMATIC_ZEN_RULE_ID, ruleId),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package de.jeanlucmakiola.clockula.ui.bedtime
|
||||
|
||||
import de.jeanlucmakiola.clockula.domain.bedtime.BedtimeSettings
|
||||
|
||||
/** The bedtime screen's state (issue #1). */
|
||||
internal sealed interface BedtimeUiState {
|
||||
data object Loading : BedtimeUiState
|
||||
|
||||
data class Loaded(
|
||||
val settings: BedtimeSettings,
|
||||
/** Whether the user granted Do Not Disturb access; without it only the reminder works. */
|
||||
val hasAccess: Boolean,
|
||||
/** Android 15 and later: the mode can carry screen effects. */
|
||||
val supportsEffects: Boolean,
|
||||
/** The system rule's id while it exists, for the "open in Settings" link. */
|
||||
val ruleId: String?,
|
||||
) : BedtimeUiState
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package de.jeanlucmakiola.clockula.ui.bedtime
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
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.CoroutineStart
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import java.time.DayOfWeek
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* The bedtime screen. Every control writes at once through [BedtimeEngine],
|
||||
* which stores the change and applies it to the slot and the Do Not Disturb
|
||||
* rule — no Save button, as everywhere else in Settings (M5 D22).
|
||||
*
|
||||
* Do Not Disturb access is granted outside the app, so [refresh] re-reads it
|
||||
* on every `ON_RESUME`, and a change found there is applied straight away:
|
||||
* coming back from granting access creates the mode without waiting for the
|
||||
* system's broadcast.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class BedtimeViewModel @Inject internal constructor(
|
||||
private val engine: BedtimeEngine,
|
||||
@param:ApplicationScope private val appScope: CoroutineScope,
|
||||
) : ViewModel() {
|
||||
|
||||
private val access = MutableStateFlow(engine.hasAccess())
|
||||
private val ruleId = MutableStateFlow(engine.ruleId())
|
||||
|
||||
internal val state: StateFlow<BedtimeUiState> = combine(engine.bedtime, access, ruleId) { settings, access, ruleId ->
|
||||
BedtimeUiState.Loaded(
|
||||
settings = settings,
|
||||
hasAccess = access,
|
||||
supportsEffects = engine.supportsEffects,
|
||||
ruleId = ruleId,
|
||||
)
|
||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(STOP_TIMEOUT_MILLIS), BedtimeUiState.Loading)
|
||||
|
||||
fun refresh() {
|
||||
val granted = engine.hasAccess()
|
||||
val changed = granted != access.value
|
||||
access.value = granted
|
||||
appScope.launch {
|
||||
// Also when access was granted while this screen was closed and
|
||||
// the system's broadcast did not reach the app: no rule yet.
|
||||
// Read from the store, not from [state]: on a fresh screen that
|
||||
// is still Loading at the first resume.
|
||||
val enabled = engine.bedtime.first().enabled
|
||||
if (changed || (granted && enabled && engine.ruleId() == null)) {
|
||||
runCatching { engine.resync(refreshRule = true) }
|
||||
}
|
||||
ruleId.value = engine.ruleId()
|
||||
}
|
||||
}
|
||||
|
||||
fun onEnabledChanged(enabled: Boolean) = update { it.copy(enabled = enabled) }
|
||||
|
||||
fun onBedtimeChanged(hour: Int, minute: Int) = update { it.copy(bedtime = TimeOfDay(hour, minute)) }
|
||||
|
||||
fun onWakeTimeChanged(hour: Int, minute: Int) = update { it.copy(wakeTime = TimeOfDay(hour, minute)) }
|
||||
|
||||
/** The last night cannot be switched off: a schedule with no nights would never start. */
|
||||
fun onDayToggled(day: DayOfWeek) = update { current ->
|
||||
val days = if (day in current.days) current.days - day else current.days + day
|
||||
if (days.isRepeating) current.copy(days = days) else current
|
||||
}
|
||||
|
||||
fun onReminderChanged(minutes: Int) = update { it.copy(reminderMinutes = minutes) }
|
||||
|
||||
fun onGrayscaleChanged(on: Boolean) = updateEffects { it.copy(grayscale = on) }
|
||||
|
||||
fun onDimWallpaperChanged(on: Boolean) = updateEffects { it.copy(dimWallpaper = on) }
|
||||
|
||||
fun onDarkThemeChanged(on: Boolean) = updateEffects { it.copy(darkTheme = on) }
|
||||
|
||||
fun onHideAlwaysOnDisplayChanged(on: Boolean) = updateEffects { it.copy(hideAlwaysOnDisplay = on) }
|
||||
|
||||
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) {
|
||||
// Undispatched, so each change queues on the engine's (fair) lock in
|
||||
// the order the user made it: two quick taps cannot land reversed.
|
||||
appScope.launch(start = CoroutineStart.UNDISPATCHED) {
|
||||
runCatching { engine.update(transform) }
|
||||
ruleId.value = engine.ruleId()
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val STOP_TIMEOUT_MILLIS = 5_000L
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
package de.jeanlucmakiola.clockula.ui.settings
|
||||
|
||||
/** Settings hub and its two sub-screens. No arguments: each is a singleton page (M10). */
|
||||
/** Settings hub and its three sub-screens. No arguments: each is a singleton page (M10). */
|
||||
internal object SettingsRoutes {
|
||||
const val MAIN: String = "settings"
|
||||
const val SELF_CHECK: String = "settings/self_check"
|
||||
const val BACKUP: String = "settings/backup"
|
||||
const val BEDTIME: String = "settings/bedtime"
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Alarm
|
||||
import androidx.compose.material.icons.filled.Backup
|
||||
import androidx.compose.material.icons.filled.Bedtime
|
||||
import androidx.compose.material.icons.filled.BugReport
|
||||
import androidx.compose.material.icons.filled.Favorite
|
||||
import androidx.compose.material.icons.filled.Gavel
|
||||
@@ -83,6 +84,7 @@ fun SettingsScreen(
|
||||
onBack: () -> Unit,
|
||||
onOpenSelfCheck: () -> Unit,
|
||||
onOpenBackup: () -> Unit,
|
||||
onOpenBedtime: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: SettingsViewModel = hiltViewModel(),
|
||||
) {
|
||||
@@ -127,6 +129,7 @@ fun SettingsScreen(
|
||||
onOpenSection = { section = it },
|
||||
onOpenSelfCheck = onOpenSelfCheck,
|
||||
onOpenBackup = onOpenBackup,
|
||||
onOpenBedtime = onOpenBedtime,
|
||||
)
|
||||
|
||||
if (loaded != null) {
|
||||
@@ -183,8 +186,8 @@ private fun SlideInSection(visible: Boolean, content: @Composable () -> Unit) {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Four named groups of category rows, after Calendula's hub: look & behaviour,
|
||||
* data, app, and a foot of reference links. Source, licence and privacy sit in that
|
||||
* Five named groups of category rows, after Calendula's hub: look & behaviour,
|
||||
* sleep, data, app, and a foot of reference links. Source, licence and privacy sit in that
|
||||
* last group rather than as buttons inside the About card — they are reference
|
||||
* material, and the card is the app's identity.
|
||||
*/
|
||||
@@ -195,6 +198,7 @@ private fun SettingsHub(
|
||||
onOpenSection: (SettingsSection) -> Unit,
|
||||
onOpenSelfCheck: () -> Unit,
|
||||
onOpenBackup: () -> Unit,
|
||||
onOpenBedtime: () -> Unit,
|
||||
) {
|
||||
CollapsingScaffold(title = stringResource(R.string.settings_title), onBack = onBack) {
|
||||
// Card and support row are one grouped block, so the call to action
|
||||
@@ -235,6 +239,18 @@ private fun SettingsHub(
|
||||
onClick = { onOpenSection(SettingsSection.Clock) },
|
||||
)
|
||||
|
||||
// Opt-in and its own destination (issue #1): a Do Not Disturb mode,
|
||||
// not a default every alarm inherits, so not one of the rows above.
|
||||
Spacer(Modifier.height(8.dp))
|
||||
GroupedSectionHeader(stringResource(R.string.settings_group_sleep))
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.bedtime_title),
|
||||
summary = stringResource(R.string.settings_bedtime_summary),
|
||||
position = Position.Alone,
|
||||
leading = { CategoryIcon(Icons.Default.Bedtime, ChipAccent.Tertiary) },
|
||||
onClick = onOpenBedtime,
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
GroupedSectionHeader(stringResource(R.string.settings_group_data))
|
||||
GroupedRow(
|
||||
|
||||
@@ -42,6 +42,7 @@ import androidx.navigation.compose.rememberNavController
|
||||
import de.jeanlucmakiola.clockula.ui.alarms.AlarmEditorScreen
|
||||
import de.jeanlucmakiola.clockula.ui.alarms.AlarmRoutes
|
||||
import de.jeanlucmakiola.clockula.ui.alarms.AlarmsScreen
|
||||
import de.jeanlucmakiola.clockula.ui.bedtime.BedtimeScreen
|
||||
import de.jeanlucmakiola.clockula.ui.settings.BackupScreen
|
||||
import de.jeanlucmakiola.clockula.ui.settings.SelfCheckScreen
|
||||
import de.jeanlucmakiola.clockula.ui.settings.SettingsRoutes
|
||||
@@ -158,6 +159,12 @@ fun ClockulaShell(
|
||||
select(ShellNavigation.onTabRequested(route, ClockulaDestination.ALARMS))
|
||||
dismissCandidates = request.alarmIds.toLongArray()
|
||||
}
|
||||
ShellRequest.OpenBedtime -> {
|
||||
// Over whichever tab is showing, after popping anything nested,
|
||||
// so back from it returns to the tabs.
|
||||
select(ShellNavigation.onTabRequested(route, selected))
|
||||
navController.navigate(SettingsRoutes.BEDTIME)
|
||||
}
|
||||
}
|
||||
onRequestConsumed()
|
||||
}
|
||||
@@ -255,6 +262,7 @@ fun ClockulaShell(
|
||||
onBack = { navController.popBackStack() },
|
||||
onOpenSelfCheck = { navController.navigate(SettingsRoutes.SELF_CHECK) },
|
||||
onOpenBackup = { navController.navigate(SettingsRoutes.BACKUP) },
|
||||
onOpenBedtime = { navController.navigate(SettingsRoutes.BEDTIME) },
|
||||
)
|
||||
}
|
||||
composable(SettingsRoutes.SELF_CHECK) {
|
||||
@@ -263,6 +271,9 @@ fun ClockulaShell(
|
||||
composable(SettingsRoutes.BACKUP) {
|
||||
BackupScreen(onBack = { navController.popBackStack() })
|
||||
}
|
||||
composable(SettingsRoutes.BEDTIME) {
|
||||
BedtimeScreen(onBack = { navController.popBackStack() })
|
||||
}
|
||||
}
|
||||
}
|
||||
// In the pill's own band above the navigation component, not in the
|
||||
|
||||
@@ -2,6 +2,7 @@ package de.jeanlucmakiola.clockula.ui.shell
|
||||
|
||||
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteType
|
||||
import de.jeanlucmakiola.clockula.alarm.AlarmIntents
|
||||
import de.jeanlucmakiola.clockula.bedtime.BedtimeIntents
|
||||
import de.jeanlucmakiola.clockula.interop.InteropIntents
|
||||
import de.jeanlucmakiola.clockula.stopwatch.StopwatchIntents
|
||||
import de.jeanlucmakiola.clockula.timer.TimerIntents
|
||||
@@ -31,6 +32,9 @@ sealed interface ShellRequest {
|
||||
data class OpenAlarmEditor(val alarmId: Long) : ShellRequest
|
||||
data object ComposeTimer : ShellRequest
|
||||
data class ChooseAlarmToDismiss(val alarmIds: List<Long>) : ShellRequest
|
||||
|
||||
/** The bedtime screen, from its reminder or from the mode's page in Settings (issue #1). */
|
||||
data object OpenBedtime : ShellRequest
|
||||
}
|
||||
|
||||
sealed interface ShellBackAction {
|
||||
@@ -156,6 +160,7 @@ object ShellNavigation {
|
||||
ShellRequest.OpenTab(ClockulaDestination.ALARMS)
|
||||
}
|
||||
InteropIntents.ACTION_COMPOSE_TIMER -> ShellRequest.ComposeTimer
|
||||
BedtimeIntents.ACTION_SHOW_BEDTIME -> ShellRequest.OpenBedtime
|
||||
InteropIntents.ACTION_CHOOSE_ALARM_TO_DISMISS ->
|
||||
if (alarmIds.isEmpty()) {
|
||||
ShellRequest.OpenTab(ClockulaDestination.ALARMS)
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- The bedtime mode's icon in Settings › Modes and the reminder's status-bar
|
||||
icon: a crescent moon, tinted by the system. -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="#FFFFFFFF">
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M12.34,2.02C6.59,1.82 2,6.42 2,12c0,5.52 4.48,10 10,10c3.71,0 6.93,-2.02 8.66,-5.02C13.15,16.73 8.57,8.55 12.34,2.02z" />
|
||||
</vector>
|
||||
@@ -439,4 +439,47 @@
|
||||
<string name="widget_quick_timer_custom">Custom…</string>
|
||||
<string name="widget_quick_timer_custom_title">Custom duration</string>
|
||||
<string name="widget_quick_timer_range_hint">Between 1 second and 59:59</string>
|
||||
|
||||
<!-- Issue #1: bedtime -->
|
||||
<string name="settings_group_sleep">Sleep</string>
|
||||
<string name="settings_bedtime_summary">A Do Not Disturb mode for the night</string>
|
||||
<string name="bedtime_title">Bedtime</string>
|
||||
<string name="bedtime_hint">Clockula turns on a Do Not Disturb mode of its own at bedtime and turns it off when you wake up. Alarms always ring.</string>
|
||||
<string name="bedtime_enabled">Bedtime mode</string>
|
||||
<string name="bedtime_enabled_on">On, %1$s to %2$s</string>
|
||||
<string name="bedtime_enabled_off">Off</string>
|
||||
<string name="bedtime_enabled_same_time">Bedtime and wake-up are the same time, so it never starts</string>
|
||||
<string name="bedtime_access_title">Allow Do Not Disturb access</string>
|
||||
<string name="bedtime_access_summary">Clockula needs it to turn its bedtime mode on and off. Find Clockula in the list and allow it. Without it, only the reminder works.</string>
|
||||
<string name="bedtime_access_allow">Allow</string>
|
||||
<string name="bedtime_section_schedule">Schedule</string>
|
||||
<string name="bedtime_start">Bedtime</string>
|
||||
<string name="bedtime_wake">Wake up</string>
|
||||
<string name="bedtime_days_hint">The nights bedtime is for, named by their evening: a bedtime between midnight and 6:00 on Thursday night falls early on Friday. A wake-up time before the bedtime is the next morning.</string>
|
||||
<string name="bedtime_section_reminder">Reminder</string>
|
||||
<string name="bedtime_reminder">Remind me before bedtime</string>
|
||||
<string name="bedtime_reminder_off">Off</string>
|
||||
<plurals name="bedtime_reminder_before">
|
||||
<item quantity="one">%1$d minute before</item>
|
||||
<item quantity="other">%1$d minutes before</item>
|
||||
</plurals>
|
||||
<string name="bedtime_section_effects">Screen</string>
|
||||
<string name="bedtime_effect_grayscale">Grayscale</string>
|
||||
<string name="bedtime_effect_dim_wallpaper">Dim the wallpaper</string>
|
||||
<string name="bedtime_effect_dark_theme">Dark theme</string>
|
||||
<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 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>
|
||||
<string name="bedtime_mode_trigger">%1$s to %2$s</string>
|
||||
<string name="channel_bedtime_name">Bedtime reminders</string>
|
||||
<string name="channel_bedtime_description">A reminder shortly before bedtime starts.</string>
|
||||
<plurals name="bedtime_reminder_title">
|
||||
<item quantity="one">Bedtime in %1$d minute</item>
|
||||
<item quantity="other">Bedtime in %1$d minutes</item>
|
||||
</plurals>
|
||||
<string name="bedtime_reminder_text">Bedtime mode starts at %1$s.</string>
|
||||
</resources>
|
||||
@@ -140,6 +140,7 @@ class ArchitectureRulesTest {
|
||||
path.endsWith("de/jeanlucmakiola/clockula/interop/AlarmClockHandler.kt") ||
|
||||
path.endsWith("de/jeanlucmakiola/clockula/timer/TimerEngine.kt") ||
|
||||
path.endsWith("de/jeanlucmakiola/clockula/stopwatch/StopwatchEngine.kt") ||
|
||||
path.endsWith("de/jeanlucmakiola/clockula/bedtime/BedtimeEngine.kt") ||
|
||||
path.endsWith("de/jeanlucmakiola/clockula/alarm/AlarmResolver.kt") ||
|
||||
path.contains("de/jeanlucmakiola/clockula/system/")
|
||||
}
|
||||
|
||||
@@ -231,6 +231,10 @@ class ManifestRulesTest {
|
||||
"android.permission.FOREGROUND_SERVICE_SPECIAL_USE",
|
||||
"android.permission.WAKE_LOCK",
|
||||
"android.permission.VIBRATE",
|
||||
// Issue #1's opt-in bedtime mode. Install-time and harmless on its
|
||||
// own: it only lists Clockula under Do Not Disturb access, which
|
||||
// the user still has to grant by hand.
|
||||
"android.permission.ACCESS_NOTIFICATION_POLICY",
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -70,6 +70,57 @@ class BackupEngineTest {
|
||||
assertThat(target.settings.currentDefaults().homeZoneId).isEqualTo("America/New_York")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the bedtime schedule round-trips and is applied on import`(@TempDir tempDir: Path) = runTest {
|
||||
val source = backupHarness(tempDir.resolve("bedtimeSource").also { it.toFile().mkdirs() })
|
||||
source.bedtimeEngine.update {
|
||||
it.copy(enabled = true, bedtime = TimeOfDay(23, 0), wakeTime = TimeOfDay(7, 0), reminderMinutes = 15)
|
||||
}
|
||||
|
||||
val exported = source.backupEngine.export()
|
||||
|
||||
val target = backupHarness(tempDir.resolve("bedtimeTarget").also { it.toFile().mkdirs() })
|
||||
target.backupEngine.import(exported)
|
||||
|
||||
val restored = target.settings.currentBedtime()
|
||||
assertThat(restored.enabled).isTrue()
|
||||
assertThat(restored.bedtime).isEqualTo(TimeOfDay(23, 0))
|
||||
assertThat(restored.reminderMinutes).isEqualTo(15)
|
||||
// The device's own rule and slot follow the restored settings: 05:00
|
||||
// is inside last night's window, so the mode is on until 07:00.
|
||||
assertThat(target.bedtimeMode.rule).isEqualTo(restored)
|
||||
assertThat(target.bedtimeMode.on).isTrue()
|
||||
assertThat(target.bedtimeScheduler.armedAt).isEqualTo(instantAt(BERLIN, "2026-06-10T07:00"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a backup from before bedtime existed keeps the current bedtime`(@TempDir tempDir: Path) = runTest {
|
||||
val harness = backupHarness(tempDir.resolve("bedtimeKept").also { it.toFile().mkdirs() })
|
||||
harness.bedtimeEngine.update { it.copy(enabled = true, bedtime = TimeOfDay(23, 0), wakeTime = TimeOfDay(7, 0)) }
|
||||
val before = harness.settings.currentBedtime()
|
||||
val ruleBefore = harness.bedtimeMode.rule
|
||||
|
||||
harness.backupEngine.import(harness.backupEngine.export().let { it.copy(settings = it.settings.copy(bedtime = null)) })
|
||||
|
||||
assertThat(harness.settings.currentBedtime()).isEqualTo(before)
|
||||
assertThat(harness.bedtimeMode.rule).isEqualTo(ruleBefore)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `restoring a reminder-only change leaves a hand-switched mode alone`(@TempDir tempDir: Path) = runTest {
|
||||
val harness = backupHarness(tempDir.resolve("bedtimeManual").also { it.toFile().mkdirs() })
|
||||
harness.bedtimeEngine.update { it.copy(enabled = true, bedtime = TimeOfDay(23, 0), wakeTime = TimeOfDay(7, 0)) }
|
||||
// 05:00 is inside last night's window; the user switched it off by hand.
|
||||
harness.bedtimeMode.on = false
|
||||
val exported = harness.backupEngine.export()
|
||||
|
||||
harness.backupEngine.import(
|
||||
exported.copy(settings = exported.settings.copy(bedtime = exported.settings.bedtime!!.copy(reminderMinutes = 30))),
|
||||
)
|
||||
|
||||
assertThat(harness.bedtimeMode.on).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `import never carries ring state`(@TempDir tempDir: Path) = runTest {
|
||||
val source = backupHarness(tempDir.resolve("source2").also { it.toFile().mkdirs() })
|
||||
|
||||
@@ -0,0 +1,371 @@
|
||||
package de.jeanlucmakiola.clockula.bedtime
|
||||
|
||||
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.clockula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.clockula.domain.TimeOfDay
|
||||
import de.jeanlucmakiola.clockula.domain.bedtime.BedtimeSettings
|
||||
import de.jeanlucmakiola.clockula.testing.BERLIN
|
||||
import de.jeanlucmakiola.clockula.testing.FakeBedtimeMode
|
||||
import de.jeanlucmakiola.clockula.testing.FakeBedtimeNotifier
|
||||
import de.jeanlucmakiola.clockula.testing.FakeBedtimeScheduler
|
||||
import de.jeanlucmakiola.clockula.testing.FakeWallClock
|
||||
import de.jeanlucmakiola.clockula.testing.FakeZoneProvider
|
||||
import de.jeanlucmakiola.clockula.testing.instantAt
|
||||
import de.jeanlucmakiola.clockula.testing.offsetInstant
|
||||
import de.jeanlucmakiola.floret.prefs.PrefStore
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.io.TempDir
|
||||
import java.nio.file.Path
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* [BedtimeEngine] over a real DataStore and fake seams: the slot is always
|
||||
* armed for the next event, the rule follows the switch and the access, and a
|
||||
* reminder fire never touches the mode.
|
||||
*/
|
||||
class BedtimeEngineTest {
|
||||
|
||||
private class Harness(scope: TestScope, tempDir: Path, now: Instant, access: Boolean) {
|
||||
val wallClock = FakeWallClock(now)
|
||||
val scheduler = FakeBedtimeScheduler()
|
||||
val mode = FakeBedtimeMode(access = access)
|
||||
val notifier = FakeBedtimeNotifier()
|
||||
val settings = SettingsPrefs(
|
||||
PrefStore(
|
||||
PreferenceDataStoreFactory.create(
|
||||
scope = CoroutineScope(UnconfinedTestDispatcher(scope.testScheduler) + Job()),
|
||||
produceFile = { tempDir.resolve("bedtime_test.preferences_pb").toFile() },
|
||||
),
|
||||
),
|
||||
)
|
||||
val engine = BedtimeEngine(settings, scheduler, mode, notifier, wallClock, FakeZoneProvider(BERLIN))
|
||||
}
|
||||
|
||||
private fun TestScope.harness(
|
||||
tempDir: Path,
|
||||
now: Instant = instantAt(BERLIN, "2026-06-10T12:00"),
|
||||
access: Boolean = true,
|
||||
) = Harness(this, tempDir, now, access)
|
||||
|
||||
private val nightly: (BedtimeSettings) -> BedtimeSettings = {
|
||||
it.copy(enabled = true, bedtime = TimeOfDay(23, 0), wakeTime = TimeOfDay(7, 0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `turning bedtime on creates the rule and arms the slot for bedtime`(@TempDir tempDir: Path) = runTest {
|
||||
val h = harness(tempDir)
|
||||
|
||||
h.engine.update(nightly)
|
||||
|
||||
assertThat(h.mode.rule?.enabled).isTrue()
|
||||
assertThat(h.mode.on).isFalse()
|
||||
assertThat(h.scheduler.armedAt).isEqualTo(offsetInstant("2026-06-10T23:00+02:00"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `turning it on during the night starts the mode at once`(@TempDir tempDir: Path) = runTest {
|
||||
val h = harness(tempDir, now = instantAt(BERLIN, "2026-06-10T23:30"))
|
||||
|
||||
h.engine.update(nightly)
|
||||
|
||||
assertThat(h.mode.on).isTrue()
|
||||
assertThat(h.scheduler.armedAt).isEqualTo(offsetInstant("2026-06-11T07:00+02:00"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the bedtime and wake-up fires switch the mode`(@TempDir tempDir: Path) = runTest {
|
||||
val h = harness(tempDir)
|
||||
h.engine.update(nightly)
|
||||
|
||||
h.wallClock.instant = instantAt(BERLIN, "2026-06-10T23:00")
|
||||
h.engine.onSlotFired()
|
||||
assertThat(h.mode.on).isTrue()
|
||||
assertThat(h.scheduler.armedAt).isEqualTo(offsetInstant("2026-06-11T07:00+02:00"))
|
||||
|
||||
h.wallClock.instant = instantAt(BERLIN, "2026-06-11T07:00")
|
||||
h.engine.onSlotFired()
|
||||
assertThat(h.mode.on).isFalse()
|
||||
assertThat(h.scheduler.armedAt).isEqualTo(offsetInstant("2026-06-11T23:00+02:00"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a reminder fire posts the reminder and leaves the mode alone`(@TempDir tempDir: Path) = runTest {
|
||||
val h = harness(tempDir)
|
||||
h.engine.update { nightly(it).copy(reminderMinutes = 30) }
|
||||
assertThat(h.scheduler.armedAt).isEqualTo(offsetInstant("2026-06-10T22:30+02:00"))
|
||||
// Switched on by hand from the system's Modes page.
|
||||
h.mode.on = true
|
||||
|
||||
h.wallClock.instant = instantAt(BERLIN, "2026-06-10T22:30")
|
||||
h.engine.onSlotFired()
|
||||
|
||||
assertThat(h.notifier.reminders).containsExactly(offsetInstant("2026-06-10T23:00+02:00") to 30)
|
||||
assertThat(h.mode.on).isTrue()
|
||||
assertThat(h.scheduler.armedAt).isEqualTo(offsetInstant("2026-06-10T23:00+02:00"))
|
||||
|
||||
h.wallClock.instant = instantAt(BERLIN, "2026-06-10T23:00")
|
||||
h.engine.onSlotFired()
|
||||
assertThat(h.notifier.showing).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `turning bedtime off removes the rule and disarms the slot`(@TempDir tempDir: Path) = runTest {
|
||||
val h = harness(tempDir, now = instantAt(BERLIN, "2026-06-10T23:30"))
|
||||
h.engine.update(nightly)
|
||||
assertThat(h.mode.on).isTrue()
|
||||
|
||||
h.engine.update { it.copy(enabled = false) }
|
||||
|
||||
assertThat(h.mode.rule).isNull()
|
||||
assertThat(h.mode.on).isFalse()
|
||||
assertThat(h.scheduler.armedAt).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `without access the reminder still runs and no rule is made`(@TempDir tempDir: Path) = runTest {
|
||||
val h = harness(tempDir, access = false)
|
||||
|
||||
h.engine.update { nightly(it).copy(reminderMinutes = 15) }
|
||||
|
||||
assertThat(h.mode.rule).isNull()
|
||||
assertThat(h.scheduler.armedAt).isEqualTo(offsetInstant("2026-06-10T22:45+02:00"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `granting access later creates the rule in the current state`(@TempDir tempDir: Path) = runTest {
|
||||
val h = harness(tempDir, now = instantAt(BERLIN, "2026-06-11T01:00"), access = false)
|
||||
h.engine.update(nightly)
|
||||
|
||||
h.mode.access = true
|
||||
h.engine.resync(refreshRule = true)
|
||||
|
||||
assertThat(h.mode.rule).isNotNull()
|
||||
assertThat(h.mode.on).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an edit rewrites the rule, a plain resync does not`(@TempDir tempDir: Path) = runTest {
|
||||
val h = harness(tempDir)
|
||||
h.engine.update(nightly)
|
||||
|
||||
h.engine.resync()
|
||||
assertThat(h.mode.refreshes).isEqualTo(0)
|
||||
|
||||
h.engine.update { it.copy(effects = it.effects.copy(grayscale = false)) }
|
||||
assertThat(h.mode.refreshes).isEqualTo(1)
|
||||
assertThat(h.mode.rule?.effects?.grayscale).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
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.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 `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 `an effect edit at night re-asserts the mode`(@TempDir tempDir: Path) = runTest {
|
||||
val h = harness(tempDir, now = instantAt(BERLIN, "2026-06-10T23:30"))
|
||||
h.engine.update(nightly)
|
||||
// As if rewriting the rule reset its state.
|
||||
h.mode.on = false
|
||||
|
||||
h.engine.update { it.copy(effects = it.effects.copy(grayscale = false)) }
|
||||
|
||||
assertThat(h.mode.on).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a removal refused in the daytime is retried by the next pass`(@TempDir tempDir: Path) = runTest {
|
||||
val h = harness(tempDir)
|
||||
h.engine.update(nightly)
|
||||
h.mode.refuseRemoval = true
|
||||
h.engine.update { it.copy(enabled = false) }
|
||||
assertThat(h.mode.rule).isNotNull()
|
||||
|
||||
h.mode.refuseRemoval = false
|
||||
h.engine.resync()
|
||||
|
||||
assertThat(h.mode.rule).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a refused removal at night keeps a retry slot`(@TempDir tempDir: Path) = runTest {
|
||||
val h = harness(tempDir, now = instantAt(BERLIN, "2026-06-10T23:30"))
|
||||
h.engine.update(nightly)
|
||||
h.mode.refuseRemoval = true
|
||||
|
||||
h.engine.update { it.copy(enabled = false) }
|
||||
|
||||
assertThat(h.scheduler.armedAt).isEqualTo(instantAt(BERLIN, "2026-06-10T23:45"))
|
||||
h.mode.refuseRemoval = false
|
||||
h.wallClock.instant = instantAt(BERLIN, "2026-06-10T23:45")
|
||||
h.engine.onSlotFired()
|
||||
assertThat(h.mode.rule).isNull()
|
||||
assertThat(h.scheduler.armedAt).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a refused state change is retried by the next pass`(@TempDir tempDir: Path) = runTest {
|
||||
val h = harness(tempDir)
|
||||
h.engine.update(nightly)
|
||||
h.mode.refuseState = true
|
||||
h.wallClock.instant = instantAt(BERLIN, "2026-06-10T23:00")
|
||||
h.engine.onSlotFired()
|
||||
assertThat(h.mode.on).isFalse()
|
||||
|
||||
h.mode.refuseState = false
|
||||
h.engine.resync()
|
||||
|
||||
assertThat(h.mode.on).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a refused rule rewrite is retried by the next pass`(@TempDir tempDir: Path) = runTest {
|
||||
val h = harness(tempDir)
|
||||
h.engine.update(nightly)
|
||||
h.mode.refuseUpdate = true
|
||||
h.engine.update { it.copy(effects = it.effects.copy(grayscale = false)) }
|
||||
assertThat(h.mode.rule?.effects?.grayscale).isTrue()
|
||||
|
||||
h.mode.refuseUpdate = false
|
||||
h.engine.resync()
|
||||
|
||||
assertThat(h.mode.rule?.effects?.grayscale).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a refused state change arms a retry and does not hide the next boundary`(@TempDir tempDir: Path) =
|
||||
runTest {
|
||||
val h = harness(tempDir)
|
||||
h.engine.update(nightly)
|
||||
h.mode.refuseState = true
|
||||
|
||||
h.wallClock.instant = instantAt(BERLIN, "2026-06-10T23:00")
|
||||
h.engine.onSlotFired()
|
||||
|
||||
assertThat(h.mode.on).isFalse()
|
||||
assertThat(h.scheduler.armedAt).isEqualTo(instantAt(BERLIN, "2026-06-10T23:15"))
|
||||
|
||||
h.mode.refuseState = false
|
||||
h.wallClock.instant = instantAt(BERLIN, "2026-06-10T23:15")
|
||||
h.engine.onSlotFired()
|
||||
assertThat(h.mode.on).isTrue()
|
||||
assertThat(h.scheduler.armedAt).isEqualTo(offsetInstant("2026-06-11T07:00+02:00"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `only the wake-up that ends the mode is urgent`(@TempDir tempDir: Path) = runTest {
|
||||
val h = harness(tempDir)
|
||||
h.engine.update(nightly)
|
||||
assertThat(h.scheduler.urgent).isFalse()
|
||||
|
||||
h.wallClock.instant = instantAt(BERLIN, "2026-06-10T23:00")
|
||||
h.engine.onSlotFired()
|
||||
|
||||
assertThat(h.scheduler.armedAt).isEqualTo(offsetInstant("2026-06-11T07:00+02:00"))
|
||||
assertThat(h.scheduler.urgent).isTrue()
|
||||
}
|
||||
|
||||
@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"))
|
||||
h.engine.update(nightly)
|
||||
|
||||
// A reboot through the wake-up time.
|
||||
h.wallClock.instant = instantAt(BERLIN, "2026-06-11T09:00")
|
||||
h.engine.resync()
|
||||
|
||||
assertThat(h.mode.on).isFalse()
|
||||
assertThat(h.scheduler.armedAt).isEqualTo(offsetInstant("2026-06-11T23:00+02:00"))
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,8 @@ import de.jeanlucmakiola.clockula.domain.backup.BackupContent
|
||||
import de.jeanlucmakiola.clockula.domain.backup.BackupSettings
|
||||
import de.jeanlucmakiola.clockula.domain.backup.BackupTimer
|
||||
import de.jeanlucmakiola.clockula.domain.backup.BackupWorldClock
|
||||
import de.jeanlucmakiola.clockula.domain.bedtime.BedtimeEffects
|
||||
import de.jeanlucmakiola.clockula.domain.bedtime.BedtimeSettings
|
||||
import de.jeanlucmakiola.clockula.domain.timer.TimerPresets
|
||||
import de.jeanlucmakiola.floret.prefs.ThemeMode
|
||||
import org.junit.jupiter.api.Test
|
||||
@@ -62,6 +64,19 @@ class BackupCodecTest {
|
||||
homeZoneId = "America/New_York",
|
||||
),
|
||||
timerPresets = listOf(1.minutes, 2.minutes, 10.minutes),
|
||||
bedtime = BedtimeSettings(
|
||||
enabled = true,
|
||||
bedtime = TimeOfDay(23, 15),
|
||||
wakeTime = TimeOfDay(6, 45),
|
||||
days = RepeatDays.WEEKDAYS,
|
||||
reminderMinutes = 30,
|
||||
effects = BedtimeEffects(
|
||||
grayscale = false,
|
||||
dimWallpaper = true,
|
||||
darkTheme = false,
|
||||
hideAlwaysOnDisplay = true,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -263,6 +278,27 @@ class BackupCodecTest {
|
||||
assertThat(settings.timerPresets).isEqualTo(TimerPresets.BUILT_IN)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a file from before bedtime existed carries no bedtime, so restoring it keeps the current one`() {
|
||||
val settings = (BackupCodec.decode(minimalDocument()) as BackupResult.Success).content.settings
|
||||
assertThat(settings.bedtime).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `out-of-range bedtime values are clamped rather than rejected`() {
|
||||
val json = minimalDocument().replace(
|
||||
"\"settings\": {}",
|
||||
"\"settings\": {\"bedtime\": {\"enabled\": true, \"bedtime_minutes\": 99999, " +
|
||||
"\"wake_minutes\": -5, \"days\": 255, \"reminder_minutes\": 999}}",
|
||||
)
|
||||
val bedtime = (BackupCodec.decode(json) as BackupResult.Success).content.settings.bedtime!!
|
||||
assertThat(bedtime.enabled).isTrue()
|
||||
assertThat(bedtime.bedtime).isEqualTo(TimeOfDay(23, 59))
|
||||
assertThat(bedtime.wakeTime).isEqualTo(TimeOfDay(0, 0))
|
||||
assertThat(bedtime.days).isEqualTo(RepeatDays.EVERY_DAY)
|
||||
assertThat(bedtime.reminderMinutes).isEqualTo(120)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an empty preset list is honoured as all removed`() {
|
||||
val json = minimalDocument().replace("\"settings\": {}", "\"settings\": {\"timer_presets\": []}")
|
||||
|
||||
@@ -3,11 +3,17 @@ package de.jeanlucmakiola.clockula.data.prefs
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.intPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.clockula.domain.ClockDefaults
|
||||
import de.jeanlucmakiola.clockula.domain.RepeatDays
|
||||
import de.jeanlucmakiola.clockula.domain.TimeOfDay
|
||||
import de.jeanlucmakiola.clockula.domain.backup.BackupSettings
|
||||
import de.jeanlucmakiola.clockula.domain.bedtime.BedtimeEffects
|
||||
import de.jeanlucmakiola.clockula.domain.bedtime.BedtimeSettings
|
||||
import de.jeanlucmakiola.floret.prefs.Appearance
|
||||
import de.jeanlucmakiola.floret.prefs.PrefStore
|
||||
import de.jeanlucmakiola.floret.prefs.ThemeMode
|
||||
@@ -143,6 +149,48 @@ class SettingsPrefsTest {
|
||||
}
|
||||
}
|
||||
|
||||
// --- issue #1: bedtime ---
|
||||
|
||||
@Test
|
||||
fun `a fresh install has bedtime off`(@TempDir tempDir: Path) = runTest {
|
||||
val prefs = SettingsPrefs(PrefStore(newDataStore(tempDir)))
|
||||
|
||||
assertThat(prefs.currentBedtime()).isEqualTo(BedtimeSettings())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the bedtime schedule survives process death`(@TempDir tempDir: Path) = runTest {
|
||||
val stored = BedtimeSettings(
|
||||
enabled = true,
|
||||
bedtime = TimeOfDay(0, 30),
|
||||
wakeTime = TimeOfDay(8, 15),
|
||||
days = RepeatDays.WEEKENDS,
|
||||
reminderMinutes = 45,
|
||||
effects = BedtimeEffects(grayscale = false, dimWallpaper = false, darkTheme = true, hideAlwaysOnDisplay = true),
|
||||
)
|
||||
val firstProcess = newScope()
|
||||
SettingsPrefs(PrefStore(newDataStore(tempDir, firstProcess))).setBedtime(stored)
|
||||
firstProcess.coroutineContext.job.cancelAndJoin()
|
||||
|
||||
val reopened = SettingsPrefs(PrefStore(newDataStore(tempDir, newScope())))
|
||||
|
||||
assertThat(reopened.currentBedtime()).isEqualTo(stored)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a corrupt bedtime value reads back clamped`(@TempDir tempDir: Path) = runTest {
|
||||
val dataStore = newDataStore(tempDir)
|
||||
dataStore.edit {
|
||||
it[intPreferencesKey("bedtime_start_minutes")] = 5_000
|
||||
it[intPreferencesKey("bedtime_reminder_minutes")] = -3
|
||||
}
|
||||
|
||||
val bedtime = SettingsPrefs(PrefStore(dataStore)).currentBedtime()
|
||||
|
||||
assertThat(bedtime.bedtime).isEqualTo(TimeOfDay(23, 59))
|
||||
assertThat(bedtime.reminderMinutes).isEqualTo(0)
|
||||
}
|
||||
|
||||
// --- M4: the one-shot notification permission request ---
|
||||
|
||||
@Test
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
package de.jeanlucmakiola.clockula.domain.bedtime
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.clockula.domain.RepeatDays
|
||||
import de.jeanlucmakiola.clockula.domain.TimeOfDay
|
||||
import de.jeanlucmakiola.clockula.testing.BERLIN
|
||||
import de.jeanlucmakiola.clockula.testing.instantAt
|
||||
import de.jeanlucmakiola.clockula.testing.offsetInstant
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.time.DayOfWeek
|
||||
import kotlin.time.Duration.Companion.hours
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
|
||||
/** 2026-06-10 is a Wednesday. */
|
||||
class BedtimeScheduleTest {
|
||||
|
||||
private val everyNight = BedtimeSettings(
|
||||
enabled = true,
|
||||
bedtime = TimeOfDay(23, 0),
|
||||
wakeTime = TimeOfDay(7, 0),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `a wake time before the bedtime is the next morning`() {
|
||||
val window = BedtimeSchedule.activeWindow(everyNight, instantAt(BERLIN, "2026-06-11T02:00"), BERLIN)
|
||||
|
||||
assertThat(window).isEqualTo(
|
||||
BedtimeWindow(offsetInstant("2026-06-10T23:00+02:00"), offsetInstant("2026-06-11T07:00+02:00")),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a wake time after the bedtime is the same day`() {
|
||||
val earlyNap = everyNight.copy(bedtime = TimeOfDay(1, 0), wakeTime = TimeOfDay(6, 0))
|
||||
|
||||
val window = BedtimeSchedule.activeWindow(earlyNap, instantAt(BERLIN, "2026-06-10T03:00"), BERLIN)
|
||||
|
||||
assertThat(window?.start).isEqualTo(offsetInstant("2026-06-10T01:00+02:00"))
|
||||
assertThat(window?.end).isEqualTo(offsetInstant("2026-06-10T06:00+02:00"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `daytime is outside every window`() {
|
||||
assertThat(BedtimeSchedule.activeWindow(everyNight, instantAt(BERLIN, "2026-06-10T12:00"), BERLIN)).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the wake time itself is already outside the window`() {
|
||||
assertThat(BedtimeSchedule.activeWindow(everyNight, instantAt(BERLIN, "2026-06-11T07:00"), BERLIN)).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a disabled schedule is never active and schedules nothing`() {
|
||||
val off = everyNight.copy(enabled = false)
|
||||
val night = instantAt(BERLIN, "2026-06-11T02:00")
|
||||
|
||||
assertThat(BedtimeSchedule.activeWindow(off, night, BERLIN)).isNull()
|
||||
assertThat(BedtimeSchedule.nextEvent(off, night, BERLIN)).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the days are the nights a window starts on`() {
|
||||
val fridays = everyNight.copy(days = RepeatDays.of(DayOfWeek.FRIDAY))
|
||||
|
||||
// Saturday 02:00 belongs to Friday night.
|
||||
assertThat(BedtimeSchedule.activeWindow(fridays, instantAt(BERLIN, "2026-06-13T02:00"), BERLIN)).isNotNull()
|
||||
// Thursday 02:00 would belong to Wednesday night, which is not one.
|
||||
assertThat(BedtimeSchedule.activeWindow(fridays, instantAt(BERLIN, "2026-06-11T02:00"), BERLIN)).isNull()
|
||||
assertThat(BedtimeSchedule.nextWindow(fridays, instantAt(BERLIN, "2026-06-10T12:00"), BERLIN)?.start)
|
||||
.isEqualTo(offsetInstant("2026-06-12T23:00+02:00"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `in the daytime the next event is bedtime`() {
|
||||
assertThat(BedtimeSchedule.nextEvent(everyNight, instantAt(BERLIN, "2026-06-10T12:00"), BERLIN))
|
||||
.isEqualTo(offsetInstant("2026-06-10T23:00+02:00"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `at night the next event is the wake time`() {
|
||||
assertThat(BedtimeSchedule.nextEvent(everyNight, instantAt(BERLIN, "2026-06-11T02:00"), BERLIN))
|
||||
.isEqualTo(offsetInstant("2026-06-11T07:00+02:00"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a reminder comes before bedtime, then bedtime itself`() {
|
||||
val reminded = everyNight.copy(reminderMinutes = 30)
|
||||
|
||||
assertThat(BedtimeSchedule.nextEvent(reminded, instantAt(BERLIN, "2026-06-10T12:00"), BERLIN))
|
||||
.isEqualTo(offsetInstant("2026-06-10T22:30+02:00"))
|
||||
assertThat(BedtimeSchedule.nextEvent(reminded, instantAt(BERLIN, "2026-06-10T22:30"), BERLIN))
|
||||
.isEqualTo(offsetInstant("2026-06-10T23:00+02:00"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a reminder is due between its time and bedtime only`() {
|
||||
val reminded = everyNight.copy(reminderMinutes = 30)
|
||||
|
||||
assertThat(BedtimeSchedule.dueReminder(reminded, instantAt(BERLIN, "2026-06-10T22:29"), BERLIN)).isNull()
|
||||
assertThat(BedtimeSchedule.dueReminder(reminded, instantAt(BERLIN, "2026-06-10T22:30"), BERLIN)?.start)
|
||||
.isEqualTo(offsetInstant("2026-06-10T23:00+02:00"))
|
||||
assertThat(BedtimeSchedule.dueReminder(reminded, instantAt(BERLIN, "2026-06-10T23:05"), BERLIN)).isNull()
|
||||
assertThat(BedtimeSchedule.dueReminder(everyNight, instantAt(BERLIN, "2026-06-10T22:45"), BERLIN)).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an equal bedtime and wake time is no window, never one that never ends`() {
|
||||
val same = everyNight.copy(wakeTime = TimeOfDay(23, 0))
|
||||
|
||||
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
|
||||
fun `a fall-back night is an hour longer, by the wall clock`() {
|
||||
// Berlin falls back on 2026-10-25 at 03:00.
|
||||
val window = BedtimeSchedule.windowOn(everyNight, java.time.LocalDate.of(2026, 10, 24), BERLIN)!!
|
||||
|
||||
assertThat(window.end - window.start).isEqualTo(9.hours)
|
||||
assertThat(window.end).isEqualTo(offsetInstant("2026-10-25T07:00+01:00"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a bedtime in a spring-forward gap keeps its same-night wake time`() {
|
||||
// Berlin springs forward on 2026-03-29: 02:30 does not exist. That is
|
||||
// the small hours of Saturday 28's night.
|
||||
val nightShift = everyNight.copy(bedtime = TimeOfDay(2, 30), wakeTime = TimeOfDay(3, 15))
|
||||
|
||||
assertThat(BedtimeSchedule.windowOn(nightShift, java.time.LocalDate.of(2026, 3, 28), BERLIN)).isNull()
|
||||
val ordinary = BedtimeSchedule.windowOn(nightShift, java.time.LocalDate.of(2026, 3, 29), BERLIN)!!
|
||||
assertThat(ordinary.end - ordinary.start).isEqualTo(45.minutes)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a bedtime after midnight belongs to the evening before`() {
|
||||
// Sunday to Thursday nights, 00:30 to 07:00. 2026-06-12 is a Friday.
|
||||
val schoolNights = everyNight.copy(
|
||||
bedtime = TimeOfDay(0, 30),
|
||||
days = RepeatDays.of(
|
||||
DayOfWeek.SUNDAY,
|
||||
DayOfWeek.MONDAY,
|
||||
DayOfWeek.TUESDAY,
|
||||
DayOfWeek.WEDNESDAY,
|
||||
DayOfWeek.THURSDAY,
|
||||
),
|
||||
)
|
||||
|
||||
// Thursday night: Friday 00:30.
|
||||
assertThat(BedtimeSchedule.activeWindow(schoolNights, instantAt(BERLIN, "2026-06-12T01:00"), BERLIN))
|
||||
.isNotNull()
|
||||
// Friday night: Saturday 00:30, not one.
|
||||
assertThat(BedtimeSchedule.activeWindow(schoolNights, instantAt(BERLIN, "2026-06-13T01:00"), BERLIN))
|
||||
.isNull()
|
||||
// From Friday afternoon, the next is Sunday night: Monday 00:30.
|
||||
assertThat(BedtimeSchedule.nextWindow(schoolNights, instantAt(BERLIN, "2026-06-12T15:00"), BERLIN)?.start)
|
||||
.isEqualTo(offsetInstant("2026-06-15T00:30+02:00"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a day sleeper's morning bedtime stays on the day picked`() {
|
||||
val daySleep = everyNight.copy(
|
||||
bedtime = TimeOfDay(8, 0),
|
||||
wakeTime = TimeOfDay(16, 0),
|
||||
days = RepeatDays.of(DayOfWeek.MONDAY),
|
||||
)
|
||||
|
||||
// 2026-06-15 is a Monday.
|
||||
assertThat(BedtimeSchedule.activeWindow(daySleep, instantAt(BERLIN, "2026-06-15T09:00"), BERLIN))
|
||||
.isNotNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no nights means nothing to schedule`() {
|
||||
val none = everyNight.copy(days = RepeatDays.NONE)
|
||||
|
||||
assertThat(BedtimeSchedule.nextEvent(none, instantAt(BERLIN, "2026-06-10T12:00"), BERLIN)).isNull()
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package de.jeanlucmakiola.clockula.testing
|
||||
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
||||
import de.jeanlucmakiola.clockula.alarm.AlarmEngine
|
||||
import de.jeanlucmakiola.clockula.backup.BackupEngine
|
||||
import de.jeanlucmakiola.clockula.bedtime.BedtimeEngine
|
||||
import de.jeanlucmakiola.clockula.data.alarms.AlarmMapper
|
||||
import de.jeanlucmakiola.clockula.data.alarms.AlarmRepositoryImpl
|
||||
import de.jeanlucmakiola.clockula.data.alarms.AlarmStateRepositoryImpl
|
||||
@@ -99,11 +100,24 @@ class BackupHarness(
|
||||
wall = wallClock,
|
||||
)
|
||||
|
||||
val bedtimeScheduler: FakeBedtimeScheduler = FakeBedtimeScheduler()
|
||||
val bedtimeMode: FakeBedtimeMode = FakeBedtimeMode()
|
||||
|
||||
val bedtimeEngine: BedtimeEngine = BedtimeEngine(
|
||||
settings = settings,
|
||||
scheduler = bedtimeScheduler,
|
||||
mode = bedtimeMode,
|
||||
notifier = FakeBedtimeNotifier(),
|
||||
wallClock = wallClock,
|
||||
zones = zones,
|
||||
)
|
||||
|
||||
val backupEngine: BackupEngine = BackupEngine(
|
||||
repository = backupRepository,
|
||||
settings = settings,
|
||||
alarmEngine = alarmEngine,
|
||||
timerEngine = timerEngine,
|
||||
bedtimeEngine = bedtimeEngine,
|
||||
wallClock = wallClock,
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
package de.jeanlucmakiola.clockula.testing
|
||||
|
||||
import de.jeanlucmakiola.clockula.bedtime.BedtimeMode
|
||||
import de.jeanlucmakiola.clockula.bedtime.BedtimeNotifier
|
||||
import de.jeanlucmakiola.clockula.bedtime.BedtimeScheduler
|
||||
import de.jeanlucmakiola.clockula.bedtime.InstallOutcome
|
||||
import de.jeanlucmakiola.clockula.domain.bedtime.BedtimeSettings
|
||||
import kotlin.time.Instant
|
||||
|
||||
/** The bedtime slot: the instant it is armed for, or null when it is not. */
|
||||
class FakeBedtimeScheduler : BedtimeScheduler {
|
||||
var armedAt: Instant? = null
|
||||
private set
|
||||
var urgent: Boolean = false
|
||||
private set
|
||||
|
||||
override fun schedule(at: Instant, urgent: Boolean) {
|
||||
armedAt = at
|
||||
this.urgent = urgent
|
||||
}
|
||||
|
||||
override fun cancel() {
|
||||
armedAt = null
|
||||
urgent = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The Do Not Disturb rule as the platform would hold it: at most one, with its
|
||||
* last-written settings and whether its condition is true. Without [access],
|
||||
* every write is a no-op, as `AndroidBedtimeMode`'s guarded calls are.
|
||||
*/
|
||||
class FakeBedtimeMode(
|
||||
var access: Boolean = true,
|
||||
override val supportsEffects: Boolean = true,
|
||||
) : BedtimeMode {
|
||||
/** The settings the rule was last written from; null when there is no rule. */
|
||||
var rule: BedtimeSettings? = null
|
||||
var on: Boolean = false
|
||||
var refreshes: Int = 0
|
||||
private set
|
||||
|
||||
override fun hasAccess(): Boolean = access
|
||||
|
||||
/** The system refusing a rewrite or a state change. */
|
||||
var refuseUpdate: Boolean = false
|
||||
var refuseState: Boolean = false
|
||||
|
||||
override fun install(settings: BedtimeSettings, refresh: Boolean): InstallOutcome {
|
||||
if (!access) return InstallOutcome.FAILED
|
||||
if (rule == null) {
|
||||
rule = settings
|
||||
// A new rule starts off, as the platform's does.
|
||||
on = false
|
||||
return InstallOutcome.CREATED
|
||||
}
|
||||
if (!refresh) return InstallOutcome.UNCHANGED
|
||||
if (refuseUpdate) return InstallOutcome.FAILED
|
||||
rule = settings
|
||||
refreshes++
|
||||
return InstallOutcome.UPDATED
|
||||
}
|
||||
|
||||
override fun setActive(active: Boolean): Boolean {
|
||||
if (!access || rule == null || refuseState) return false
|
||||
on = active
|
||||
return true
|
||||
}
|
||||
|
||||
/** The system refusing `removeAutomaticZenRule`, as an OEM build might. */
|
||||
var refuseRemoval: Boolean = false
|
||||
|
||||
override fun uninstall(): Boolean {
|
||||
if (!access || refuseRemoval) return false
|
||||
rule = null
|
||||
on = false
|
||||
return true
|
||||
}
|
||||
|
||||
override fun ruleId(): String? = if (access && rule != null) "rule" else null
|
||||
}
|
||||
|
||||
class FakeBedtimeNotifier : BedtimeNotifier {
|
||||
/** Each reminder posted, as (window start, minutes before). */
|
||||
val reminders: MutableList<Pair<Instant, Int>> = mutableListOf()
|
||||
var showing: Boolean = false
|
||||
private set
|
||||
|
||||
override fun showReminder(start: Instant, minutesBefore: Int) {
|
||||
reminders += start to minutesBefore
|
||||
showing = true
|
||||
}
|
||||
|
||||
override fun cancelReminder() {
|
||||
showing = false
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package de.jeanlucmakiola.clockula.ui.shell
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.clockula.alarm.AlarmIntents
|
||||
import de.jeanlucmakiola.clockula.bedtime.BedtimeIntents
|
||||
import de.jeanlucmakiola.clockula.domain.interop.AlarmClockContract
|
||||
import de.jeanlucmakiola.clockula.interop.InteropIntents
|
||||
import de.jeanlucmakiola.clockula.stopwatch.StopwatchIntents
|
||||
@@ -122,4 +123,16 @@ class ShellNavigationInteropTest {
|
||||
assertThat(InteropIntents.ACTIONS.intersect(others.toSet())).isEmpty()
|
||||
assertThat(InteropIntents.ACTIONS).containsNoDuplicates()
|
||||
}
|
||||
|
||||
@Test
|
||||
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)
|
||||
// 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