Merge branch 'feat/1-bedtime-mode' into release/v1.0.0
# Conflicts: # docs/ARCHITECTURE.md
This commit is contained in:
@@ -58,6 +58,14 @@ The first release of Clockula: alarms, timers, a stopwatch and a world clock.
|
||||
state: permissions, battery optimisation, Do Not Disturb, alarm volume, and
|
||||
whether the system has Clockula's next alarm. It links straight to the
|
||||
setting that fixes each problem.
|
||||
- **Bedtime**, under Settings: Clockula can turn on a Do Not Disturb mode of
|
||||
its own at bedtime and off at your wake-up time, on the nights you choose,
|
||||
with an optional reminder before it starts. On Android 15 and later it is a
|
||||
real mode in Settings › Modes and can also turn the screen grayscale, dim the
|
||||
wallpaper, switch to the dark theme and hide the always-on display. It needs
|
||||
Do Not Disturb access, which the screen helps you grant, and it always lets
|
||||
alarms through. It is separate from Digital Wellbeing's own Bedtime mode,
|
||||
which apps can't turn on.
|
||||
- Home-screen widgets: next alarm, timer (with controls), a quick timer, and
|
||||
analog and digital clocks that show your world clocks at larger sizes. Each
|
||||
widget can follow the system theme or stay light or dark, and choose what a
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+171
-1
@@ -98,6 +98,7 @@ kit module: the kit's roadmap keeps schedulers app-local.
|
||||
| `domain/format/` | `ClockFormat` (`M:SS` / `H:MM:SS`, countdowns rounded up, elapsed truncated), `NextFire` (`NextFireLabel` + `NextFireFormat`), the alarm row's "in 9h 12m" as data, and `StopwatchFormat`, the hundredths readout, split into a major field and two digits so the fraction can be drawn smaller |
|
||||
| `domain/backup/` | `BackupContent` (M10): the plain Kotlin model between `data/backup`'s JSON codec and the repository/engine; decoupled from the wire DTOs the same way the data layer is decoupled from Room |
|
||||
| `domain/selfcheck/` | `SelfCheck` (M10): the "why might my alarm not ring?" rule table: pure functions over a `DeviceState` snapshot, each producing a `CheckResult` with a status and an optional deep-link `SelfCheckFix` |
|
||||
| `domain/bedtime/` | the bedtime schedule's pure half (issue #1): `BedtimeSettings`/`BedtimeEffects`/`BedtimeLimits` and `BedtimeSchedule` (the window a night resolves to, the active window, the due reminder, the next event). §18 |
|
||||
| `domain/widget/` | the three widget presenters (M10), `NextAlarmWidgetModel`/`TimerWidgetModel`/`ClockWidgetModel`: selection and formatting only, so a widget's content is unit-tested without Glance |
|
||||
| `data/db/` | `ClockulaDatabase` (`@Database` v3, `exportSchema = true`) and `Migrations` |
|
||||
| `data/alarms/` | the alarm and ring-state entities, DAOs, mappers and repositories |
|
||||
@@ -105,7 +106,7 @@ kit module: the kit's roadmap keeps schedulers app-local.
|
||||
| `data/worldclocks/` | `WorldClockEntity`, `WorldClockDao`, `WorldClockMapper`, `WorldClockRepository(+Impl)`, unchanged since M2; M8 is the milestone that finally calls all of it |
|
||||
| `data/zones/` | the ICU seam (M8): `ZoneNames`, `IcuZoneNames` (the only `android.icu` file in the app) and `ZoneDirectory`, the locale-keyed catalog cache and the DST-keyed display-name memo |
|
||||
| `data/stopwatch/` | `LapEntity`, `LapDao` (one `COUNT(*)` added in M7), `LapMapper`, `StopwatchStateStore`, `StopwatchRepository(+Impl)` |
|
||||
| `data/prefs/` | `SettingsPrefs`, `ClockPrefs`, `StopwatchPrefs`, `TimerPrefs`, `UiPrefs` |
|
||||
| `data/prefs/` | `SettingsPrefs`, `ClockPrefs`, `StopwatchPrefs`, `TimerPrefs`, `UiPrefs`, `BedtimePrefs` (issue #1) |
|
||||
| `data/ringtones/` | the two ringtone seams, `RingtoneCatalog` (the bundled and device alarm sounds, titles, playability, a SAF grant) and `RingtonePreviewer`, plus their `System*` implementations, and `MediaLibrary`/`SystemMediaLibrary`, the picker's view of other apps' `MediaBrowserService` libraries |
|
||||
| `data/time/` | `SystemWallClock`, `SystemElapsedRealtimeClock`, `SystemZoneProvider`, `AndroidBootIdProvider`, `RealTicker` |
|
||||
| `data/di/` | `DataModule`, `DatabaseModule`, `RepositoryModule`, `TimeModule`, `RingtoneModule`, `ZoneModule` (M8; one `@Binds`, `ZoneNames → IcuZoneNames`), `BackupModule` (M10) |
|
||||
@@ -127,6 +128,7 @@ kit module: the kit's roadmap keeps schedulers app-local.
|
||||
| `stopwatch/service/` | `StopwatchService` (the `specialUse` foreground service) and `StopwatchNotifications` |
|
||||
| `stopwatch/di/` | `StopwatchModule`: `@Binds` for the one seam |
|
||||
| `interop/` | the `AlarmClock` boundary's Android half (M9): `AlarmClockActivity` (the one exported, permission-guarded, window-less door), `IntentExtrasReader` (`Bundle` → `IntentExtras`), `AlarmClockHandler` (the orchestrator; no `android.*` import, ever), `InteropIntents` (the internal transport to `MainActivity`) and `InteropLaunch` |
|
||||
| `bedtime/` | `BedtimeEngine` (issue #1) and its three seams, `BedtimeScheduler`, `BedtimeMode` and `BedtimeNotifier`, plus `BedtimeIntents`. `bedtime/android/` holds their implementations (the one wall-clock slot, the Do Not Disturb rule, the reminder), `bedtime/receiver/` the `BedtimeReceiver`, `bedtime/di/` the `BedtimeModule`. §18 |
|
||||
| `system/` | `RebootRepair`, the boot-id gate |
|
||||
| `backup/` | `BackupEngine` (M10), the orchestrator (a ringing alarm dismissed and running timers deleted before a replace, `AlarmEngine.reschedule()`/`TimerEngine.resync()` after it), plus `BackupFiles`, the SAF seam, with its Android implementation in `backup/android/` |
|
||||
| `widget/` | the three `GlanceAppWidget`/`GlanceAppWidgetReceiver` pairs (M10) and `WidgetSync`, the event-driven collector (started from `ClockulaApp.onCreate`) that redraws all three on any change to the state they mirror. `widget/di/` holds the `@EntryPoint` Glance needs, since a `GlanceAppWidgetReceiver` is never `@AndroidEntryPoint`-constructed |
|
||||
@@ -139,6 +141,7 @@ kit module: the kit's roadmap keeps schedulers app-local.
|
||||
| `ui/worldclock/` | the real World clock tab (M8): the pure row builders (`WorldClockRows`, `ZonePickerRows`) and their state, `WorldClockSource`, the ViewModel, the tab, the analog face, the row and the zone picker, `WorldClockDefaults` |
|
||||
| `ui/ring/` | `AlarmRingActivity`, its ViewModel, `RingScreen` and the dismiss-challenge controls |
|
||||
| `ui/settings/` | the settings hub (M10): `SettingsScreen`/`SettingsViewModel` (appearance, alarm/timer/clock defaults, reliability, data, help), `SelfCheckScreen`/`SelfCheckViewModel`, `BackupScreen`/`BackupViewModel`, all reachable from a gear icon on every tab, with no owning tab of their own |
|
||||
| `ui/bedtime/` | `BedtimeScreen`/`BedtimeViewModel` (issue #1), a nested destination reached from the settings hub, the bedtime reminder and the mode's page in system Settings, never a tab |
|
||||
| `ui/widget/` | the three widgets' `@Composable` Glance content (M10), the only Compose this milestone adds outside a tab; the `GlanceAppWidget`/`GlanceAppWidgetReceiver` classes themselves live in `widget/`, since they are manifest components, not UI. `ui/widget/config/` holds each widget's config activity; every widget has one, if only for its per-instance colours (`domain/widget/WidgetColors.kt`: System/Light/Dark and, on API 31+, wallpaper colours on/off). Glance content takes those through `WidgetTheme`; the plain-`RemoteViews` faces, readouts and chronometers through `WidgetPalette.kt`, which resolves the `widget_*` palette under a forced day/night (the app's own tones are `widget_app_*`) and sets concrete colours, or day/night pairs, on the views. The analog and digital clocks are re-coloured on API 31+ only, since below that `AnalogClock`'s hands and a shape's background tint can't be set through `RemoteViews` |
|
||||
|
||||
---
|
||||
@@ -599,6 +602,7 @@ Four slices:
|
||||
| Stopwatch run record (M2) | `stopwatch_state`, `stopwatch_started_elapsed_millis`, `stopwatch_accumulated_millis`, `stopwatch_last_lap_cumulative_millis` (M7 is the first caller of the last, for the in-progress lap's split) | `StopwatchPrefs`, behind `StopwatchStateStore` |
|
||||
| System facts (M3) | `last_boot_id` | `SystemPrefs`, behind `BootStateStore`, the persisted half of the reboot gate |
|
||||
| Timers (M6) | `timer_presets`, `timer_ring_sounding_since_millis` | `TimerPrefs`: the presets surfaced as `SettingsPrefs.timerPresets`, the ring session's anchor behind `TimerRingStateStore` |
|
||||
| Bedtime (issue #1) | `bedtime_enabled`, `bedtime_start_minutes`, `bedtime_wake_minutes`, `bedtime_days`, `bedtime_reminder_minutes`, `bedtime_effect_grayscale`, `bedtime_effect_dim_wallpaper`, `bedtime_effect_dark_theme`, `bedtime_effect_hide_aod`, `bedtime_applied_active`, `bedtime_rule_stale`, `bedtime_state_owed` | `BedtimePrefs`, surfaced as `SettingsPrefs.bedtime: Flow<BedtimeSettings>` and written as one record by `BedtimeEngine` only. Times clamp into a day, the reminder into 0–120 minutes, the days through `RepeatDays.fromMask`. The system rule's id is **not** stored (§18) |
|
||||
|
||||
The stopwatch's *run* is a single record, so it lives in DataStore rather than
|
||||
as a one-row table (`PLAN.md` §5); its *laps* are a list, so they live in Room.
|
||||
@@ -668,6 +672,7 @@ Hilt, `SingletonComponent` throughout. Nine app modules plus the kit's:
|
||||
| `ZoneModule` | `@Binds` for the one ICU seam: `ZoneNames → IcuZoneNames` (M8). `ZoneDirectory` needs no binding; it is a `@Singleton` `@Inject` class |
|
||||
| `StopwatchModule` | `@Binds` for the stopwatch engine's one seam: `StopwatchServiceHandle` (the foreground service). It has no scheduler, because nothing the stopwatch does has a deadline |
|
||||
| `TimerModule` | `@Binds` for the timer engine's three seams: `TimerScheduler` (the one elapsed-realtime slot), `TimerServiceHandle` (the foreground service), `AlarmRingStatus` (read-only: "is an alarm ringing") |
|
||||
| `BedtimeModule` | `@Binds` for the bedtime engine's three seams (issue #1): `BedtimeScheduler`, `BedtimeMode`, `BedtimeNotifier` |
|
||||
|
||||
From floret-kit: `core-di`'s `CoroutinesModule` supplies `@IoDispatcher` and the
|
||||
process-lifetime `@ApplicationScope` the receivers launch on, and `core-prefs`
|
||||
@@ -968,6 +973,7 @@ declared because a specific path would otherwise fail, silently, at 07:00.
|
||||
| `FOREGROUND_SERVICE`, `FOREGROUND_SERVICE_SYSTEM_EXEMPTED` | the two ringing services: the alarm's, and (since M6) the timers' |
|
||||
| `WAKE_LOCK` | keep the CPU up for the length of a ring |
|
||||
| `VIBRATE` | an alarm and a timer both vibrate, and vibrate alone when no audio source opens |
|
||||
| `ACCESS_NOTIFICATION_POLICY` | issue #1's opt-in bedtime mode. Install-time, and on its own it does nothing: it lists Clockula under Special app access › Do Not Disturb, which the user grants by hand. With that grant Clockula adds one Do Not Disturb rule of its own, which always lets alarms through (§18) |
|
||||
|
||||
**M6 adds no permission at all.** A timer never takes over the screen (no
|
||||
full-screen intent, no ring activity, no `showWhenLocked`), because its user set
|
||||
@@ -2690,3 +2696,167 @@ app has shipped nine milestones with no toast anywhere, and formatting "Alarm se
|
||||
for 7:00" would need a second, non-composable 12/24-hour formatter, the thing
|
||||
M8 spent a milestone eliminating. The platform's own receipt is better than a
|
||||
toast and costs nothing.
|
||||
|
||||
---
|
||||
|
||||
## 18. Bedtime
|
||||
|
||||
Issue #1: an opt-in Do Not Disturb mode, after Google Clock's Bedtime tab,
|
||||
without a fifth tab. v1's four surfaces stay as they are; bedtime is a nested
|
||||
destination (`settings/bedtime`) reached from a row on the settings hub, from
|
||||
its own reminder notification, and from the mode's page in the system's
|
||||
Settings.
|
||||
|
||||
### Why a mode of Clockula's own
|
||||
|
||||
Digital Wellbeing's Bedtime mode is off limits. `NotificationManagerService`
|
||||
refuses an `AutomaticZenRule` of `TYPE_BEDTIME` from any package but the one in
|
||||
`config_systemWellbeing`, and no third-party app can trigger Wellbeing's mode or
|
||||
read its schedule. What Android does offer an app with Do Not Disturb access
|
||||
(`ACCESS_NOTIFICATION_POLICY`, granted by hand under Special app access) is an
|
||||
automatic zen rule of its own:
|
||||
|
||||
- **Android 15 and later**: a *mode*, built with `AutomaticZenRule.Builder`,
|
||||
typed `TYPE_SCHEDULE_TIME`, shown in Settings › Modes under Clockula's name and
|
||||
the `ic_bedtime` icon, with a trigger line ("23:00 to 07:00") and the public
|
||||
`ZenDeviceEffects` the screen offers: grayscale, dimmed wallpaper, dark theme
|
||||
and hiding the always-on display. The hidden effects (auto-brightness,
|
||||
tap/tilt to wake, doze, radios) are dropped by `ZenModeHelper` when an app
|
||||
sets them, so the screen does not offer them. Manual invocation is allowed,
|
||||
so the user can also switch the mode on from Settings or Quick Settings.
|
||||
- **Android 10 to 14** (minSdk 29): a plain Do Not Disturb schedule rule from
|
||||
the seven-argument constructor that takes a `ZenPolicy`. No type, no effects:
|
||||
the screen says so and hides the effect switches.
|
||||
|
||||
A rule needs an owner `ConditionProviderService` or a configuration activity.
|
||||
Clockula uses `MainActivity` as the configuration activity: it is already
|
||||
exported, so no new component is exposed. Settings opens it with the explicit
|
||||
component, the rule id in `NotificationManager.EXTRA_AUTOMATIC_RULE_ID` /
|
||||
`ConditionProviderService.EXTRA_RULE_ID`, and **no action**, so `MainActivity`
|
||||
turns an action-less intent carrying either extra into the reminder's own
|
||||
`ACTION_SHOW_BEDTIME`, and the shell opens the bedtime screen
|
||||
(`ShellRequest.OpenBedtime`). There is no condition provider service:
|
||||
Clockula reports the rule's state itself with `setAutomaticZenRuleState`.
|
||||
|
||||
The two modes coexist. A Pixel user who also uses Wellbeing's Bedtime sees two
|
||||
modes in Settings › Modes; the screen says so and suggests using one.
|
||||
|
||||
### Alarms are never suppressed
|
||||
|
||||
The rule's `ZenPolicy` sets exactly two things, `allowAlarms(true)` and
|
||||
`allowMedia(true)` (a sleep sound keeps playing), and leaves every other field
|
||||
unset, so calls, messages and the rest follow the device's own Do Not Disturb
|
||||
defaults. Clockula's alarms and timers play with `USAGE_ALARM`, so the
|
||||
exemption applies. The rule is only rewritten when a field it carries changes
|
||||
(the times, the effects, the switch; not the reminder), and a rewrite reuses
|
||||
the rule's existing policy as long as that still allows alarms, so a user's
|
||||
"who may interrupt" edits in Settings survive on Android 10 to 14 as well
|
||||
(Android 15 keeps a user's edits over an app's updates by itself). An edit
|
||||
that blocked alarms is the one thing a rewrite puts back. The self-check
|
||||
(M10's `SelfCheck`) still reports total silence as a problem whatever caused it.
|
||||
|
||||
### The engine
|
||||
|
||||
`BedtimeEngine` is plain Kotlin behind three seams, JVM-tested like the other
|
||||
engines (`ArchitectureRulesTest` holds it to no `android.*` import):
|
||||
|
||||
- `BedtimeScheduler`, **one** AlarmManager slot of its own. Its PendingIntent
|
||||
targets `BedtimeReceiver` with its own action and request code, so arming or
|
||||
cancelling it cannot replace or cancel an alarm's or a timer's registration.
|
||||
It is `RTC_WAKEUP`. Only the wake-up that ends the mode is allow-while-idle
|
||||
(`setExactAndAllowWhileIdle`, falling back to `setAndAllowWhileIdle`), so
|
||||
Do Not Disturb cannot linger hours into the morning behind a doze deferral.
|
||||
That is one such fire a night, well inside the per-app allow-while-idle
|
||||
ration the timer's expiry slot also draws on (alarms themselves use
|
||||
`setAlarmClock`, which is not rationed). Bedtime's start, the reminder and
|
||||
retries are `setExact` (a ten-minute `setWindow` without exact-alarm
|
||||
access), so they never spend that ration; in deep doze they wait for a
|
||||
maintenance window, which only delays a start in a phone nobody is using.
|
||||
It never uses `setAlarmClock`: that slot is the status bar's next *alarm*.
|
||||
- `BedtimeMode`, the rule. Every call is guarded and a no-op without access.
|
||||
- `BedtimeNotifier`, the reminder, on its own `bedtime_reminder` channel.
|
||||
|
||||
Every entry point is an idempotent pass over "where does the schedule stand
|
||||
now", computed by `BedtimeSchedule`: a night's window runs from the bedtime on
|
||||
one of the chosen nights (named by its evening, so a bedtime in the small hours, before 06:00, falls
|
||||
on the next calendar day: "Thursday night" at 00:30 is early Friday, while a
|
||||
day sleeper's 08:00 stays on the day picked) to the first wake time after it (an earlier wake time is
|
||||
the next morning, decided on the local times so a bedtime in a spring-forward
|
||||
gap cannot push a same-night wake time a day later; an equal one is no window
|
||||
at all, since a day-long window on every night would be a Do Not Disturb that
|
||||
never ends, and the screen says so), resolved from local times and the
|
||||
zone like an alarm, so a DST night is as long as the wall clock says. The slot
|
||||
is armed for the earliest of the current window's end, the next window's
|
||||
reminder and the next window's start.
|
||||
|
||||
| Entry point | Called from | What it does |
|
||||
|---|---|---|
|
||||
| `update(transform)` | the bedtime screen, on the application scope so leaving the screen cannot cut a write short | stores the settings, rewrites the rule if a field it carries changed, and when the schedule or the rule changed sets the mode to what it says now (on at 23:30 for a 23:00 bedtime starts it at once; a reminder edit leaves a hand-switched mode alone), arms the slot. The screen's writes start undispatched, so they queue on the engine's fair lock in the order they were made. Off removes the rule, which ends the mode, cancels the slot and clears the reminder |
|
||||
| `onSlotFired()` | `BedtimeReceiver` | follows any boundary crossed, posts a due reminder, arms the next event. A reminder fire alone crosses no boundary, so it leaves a mode switched on by hand alone |
|
||||
| `resync()` | `ClockulaApp.onCreate` (a force stop clears the slot), `SystemEventReceiver` (boot, app update, clock or zone change, exact-alarm permission) | creates the rule if it is missing, follows any boundary crossed, arms the slot |
|
||||
| `resync(refreshRule = true)` | Do Not Disturb access changing (the screen's `ON_RESUME` check) | the same, but also rewrites the rule and re-asserts the mode's state |
|
||||
| `resyncAfterRestore(previous)` | a backup restore that got as far as applying settings | compares what is stored now with what was stored before, field by field as an edit does, so restoring the same schedule, or one that differs only in its reminder, leaves a hand-switched mode alone |
|
||||
|
||||
"Follows any boundary crossed" is the one rule that lets a hand-switched mode
|
||||
coexist with a schedule. The last state Clockula set is persisted
|
||||
(`bedtime_applied_active`, device state, not in the JSON backup), and a pass
|
||||
that is not an edit switches the mode only when the schedule now says
|
||||
something different, or when the rule was just recreated (a new rule starts
|
||||
off) or rewritten (Android 10 to 14 clear the condition on an update). Only
|
||||
what the system accepted is recorded as applied. A refused state change is
|
||||
remembered as owed (`bedtime_state_owed`) and a refused rule rewrite as stale
|
||||
(`bedtime_rule_stale`), and every pass forces them until they land; meanwhile
|
||||
the slot is pulled in to a retry fifteen minutes out, and a refused removal
|
||||
when turning bedtime off keeps the slot armed the same way. The slot is armed
|
||||
in a `finally`, so a throwing step (a DataStore write, a cancel) cannot leave
|
||||
it unarmed. A launch in the afternoon leaves a mode the user turned
|
||||
on alone; a launch after a force stop that slept through the wake-up time
|
||||
turns it off. Each pass reads the clock **once**, so the mode's state and the
|
||||
next slot always agree about which side of a boundary "now" is on. A posted
|
||||
reminder is withdrawn by any pass once no coming bedtime is due within its
|
||||
lead, so moving the schedule away from it does not leave a stale one, and its
|
||||
title counts the minutes actually left, so a late fire does not promise the
|
||||
full lead.
|
||||
|
||||
Every bedtime call on a shared path runs **after** the alarm, timer and
|
||||
stopwatch passes and inside `runCatching` (in `SystemEventReceiver`, in its
|
||||
`finally`, so a failure up there does not cost bedtime its pass either), so
|
||||
nothing bedtime does can delay or break them. `BedtimeReceiver` and the
|
||||
screen guard their calls too.
|
||||
|
||||
### No stored rule id
|
||||
|
||||
`NotificationManager.getAutomaticZenRules()` returns only the calling app's
|
||||
rules, and the rule's condition id (`condition://<package>/bedtime`) marks
|
||||
Clockula's. So nothing about the rule is stored: a reinstall, a cleared app or
|
||||
a restore onto another device finds the rule or creates it, and a stray second
|
||||
one is removed. An update keeps the rule's own enabled switch, so a change on
|
||||
Clockula's screen does not re-enable a rule the user disabled in Settings.
|
||||
|
||||
### Access, granted and revoked
|
||||
|
||||
Do Not Disturb access cannot be requested with a dialog. The screen shows an
|
||||
onboarding row while it is missing, deep-linking to
|
||||
`Settings.ACTION_NOTIFICATION_POLICY_ACCESS_SETTINGS`, and re-reads access on
|
||||
every `ON_RESUME`; a change found there runs `resync(refreshRule = true)` at
|
||||
once (also when access is there but no rule exists yet, for a grant made while
|
||||
the screen was closed), so coming back from granting it creates the mode.
|
||||
The system's `ACTION_NOTIFICATION_POLICY_ACCESS_GRANTED_CHANGED` is not
|
||||
listened for: it reaches runtime-registered receivers only, and the next
|
||||
slot fire or launch picks a grant up anyway. Without access the schedule still runs for the
|
||||
reminder. Revoking access in Settings removes the app's rules with it (that is
|
||||
Settings' own confirmation dialog), and Clockula cannot remove a rule without
|
||||
access, so a rule left by a revoke from outside Settings is the platform's.
|
||||
Turning bedtime off removes the rule cleanly; the "last applied" state is
|
||||
cleared only once the removal succeeded, so a refused removal is retried by the
|
||||
next pass rather than leaving the mode on (an off schedule's cheap early return
|
||||
still checks that no rule of Clockula's is left).
|
||||
|
||||
### Backup
|
||||
|
||||
The settings travel in the backup's `settings.bedtime` object
|
||||
(`docs/BACKUP.md`); a file from before issue #1 carries no `bedtime` and
|
||||
keeps the device's current bedtime settings, and one with no nights at all
|
||||
restores every night. `BackupEngine.import` hands the engine what was stored
|
||||
before (`resyncAfterRestore`), after the replace or after rolling it back.
|
||||
|
||||
|
||||
+22
-1
@@ -136,10 +136,31 @@ device or after a reboot (`docs/PLAN.md` §5). Every imported timer lands
|
||||
"default_timer_ringtone_uri": null,
|
||||
"default_dismiss_challenge": "NONE",
|
||||
"home_zone_id": null,
|
||||
"timer_presets": [60000, 120000, 180000, 300000, 600000, 900000, 1800000, 3600000]
|
||||
"timer_presets": [60000, 120000, 180000, 300000, 600000, 900000, 1800000, 3600000],
|
||||
"bedtime": {
|
||||
"enabled": false,
|
||||
"bedtime_minutes": 1350,
|
||||
"wake_minutes": 420,
|
||||
"days": 127,
|
||||
"reminder_minutes": 0,
|
||||
"grayscale": true,
|
||||
"dim_wallpaper": true,
|
||||
"dark_theme": true,
|
||||
"hide_always_on_display": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`bedtime` is the opt-in bedtime schedule (issue #1). Its times are minutes
|
||||
since local midnight, `days` is the same 7-bit mask as an alarm's
|
||||
`repeat_days` (the nights bedtime is for, named by their evening), and `reminder_minutes` 0 means no
|
||||
reminder. A file without `bedtime` (any file written before it existed)
|
||||
leaves the device's current bedtime settings as they are, the one setting a
|
||||
restore does not replace when its file is silent about it. Like the other settings it
|
||||
is clamped rather than rejected: times into a day, the reminder into 0–120
|
||||
minutes, and a `days` of 0 (which the screen never allows) into every night. The id of the Do Not Disturb rule a device created is not part of it;
|
||||
the importing device creates its own (`docs/ARCHITECTURE.md` §18).
|
||||
|
||||
Field names mirror `ClockPrefs`'/`SettingsPrefs`' own on-disk keys. Older
|
||||
files may also carry `default_timer_duration_millis`, from a default timer
|
||||
length setting that has since been removed; it is ignored on import like any
|
||||
|
||||
+5
-3
@@ -162,9 +162,11 @@ notification whose content intent is the ring activity.
|
||||
A "**Why might my alarm not ring?**" screen that reports actual device state, not
|
||||
generic advice: exact-alarm permission, notification permission, full-screen-intent
|
||||
permission, battery-optimisation exemption, **whether DND currently blocks alarms**
|
||||
(read via `NotificationManager.getCurrentInterruptionFilter()`; Clockula never
|
||||
requests `ACCESS_NOTIFICATION_POLICY` or changes DND itself, it only reports what the
|
||||
user already has), whether the OEM has Clockula under an aggressive app-killer, and
|
||||
(read via `NotificationManager.getCurrentInterruptionFilter()`; the self-check never
|
||||
changes DND itself, it only reports what the user already has. The one place Clockula
|
||||
touches DND is the opt-in bedtime mode (issue #1), which needs the user to grant
|
||||
`ACCESS_NOTIFICATION_POLICY` by hand and always lets alarms through; see
|
||||
`ARCHITECTURE.md` §18), whether the OEM has Clockula under an aggressive app-killer, and
|
||||
the next scheduled fire time as the system sees it (`getNextAlarmClock()`), compared
|
||||
against the next fire Clockula itself computed. Where possible each row deep-links to
|
||||
the exact settings page. This screen is a v1 feature, not a support afterthought.
|
||||
|
||||
+6
-2
@@ -1,7 +1,7 @@
|
||||
---
|
||||
title: Privacy Policy — Clockula
|
||||
description: What Clockula does with your data. It collects nothing, sends nothing, and has no internet permission at all.
|
||||
updated: 2026-10-02
|
||||
updated: 2026-10-22
|
||||
---
|
||||
|
||||
<!--
|
||||
@@ -20,7 +20,7 @@ updated: 2026-10-02
|
||||
render on the published page.
|
||||
-->
|
||||
|
||||
**Last updated:** 2 October 2026
|
||||
**Last updated:** 22 October 2026
|
||||
Applies to the Android app **Clockula** (package `de.jeanlucmakiola.clockula`),
|
||||
all versions and all distribution channels.
|
||||
|
||||
@@ -167,6 +167,10 @@ on. Clockula only opens the address and sends no data of yours with it.
|
||||
stopwatch going while the app is in the background.
|
||||
- `WAKE_LOCK`: keep the phone awake while an alarm or timer rings.
|
||||
- `VIBRATE`: vibrate when an alarm or timer rings.
|
||||
- `ACCESS_NOTIFICATION_POLICY`: only used if you turn on Bedtime and allow
|
||||
Clockula Do Not Disturb access yourself. Clockula then adds one Do Not
|
||||
Disturb mode of its own and turns it on and off on your schedule. It does not
|
||||
read your notifications.
|
||||
- `ACCESS_NETWORK_STATE`: added by the Android library that updates the
|
||||
home-screen widgets (WorkManager). It lets an app see whether a network
|
||||
connection exists. It does not let it use one, and without `INTERNET` no
|
||||
|
||||
+9
-2
@@ -611,8 +611,15 @@ Deferred deliberately, in rough priority order:
|
||||
- **Dock / screensaver mode**: a `DreamService` full-screen clock. Cheapest big
|
||||
win, and the surface people look at most.
|
||||
- **Quick Settings tile**: timer control and next alarm from the shade.
|
||||
- **Bedtime / sleep schedule**: wind-down and wake, with DND handoff. A feature
|
||||
in its own right, realistically v2.
|
||||
- 🚧 **Bedtime / sleep schedule** (issue #1): a first version exists as an
|
||||
opt-in screen under Settings, not a fifth tab. Clockula's own Do Not Disturb
|
||||
mode (a real mode in Settings › Modes on Android 15+, with grayscale, dimmed
|
||||
wallpaper, dark theme and hidden always-on display; a plain DND rule on 10–14),
|
||||
bedtime and wake-up times, nights, an optional reminder, and backup. Digital
|
||||
Wellbeing's Bedtime mode stays out of reach (the platform refuses
|
||||
`TYPE_BEDTIME` to third parties). Still open: a wind-down phase before
|
||||
bedtime, and whether the wake-up should tie into an alarm. See
|
||||
`ARCHITECTURE.md` §18.
|
||||
- **`core-notification` in floret-kit**: revisit once Clockula's real
|
||||
notification surface is known (`PLAN.md` §3).
|
||||
- **Migrate Calendula and Agendula onto `core-prefs` + `core-di`**: tracked in
|
||||
|
||||
Reference in New Issue
Block a user