Merge branch 'feat/1-bedtime-mode' into release/v1.0.0

# Conflicts:
#	docs/ARCHITECTURE.md
This commit is contained in:
2026-10-26 20:47:54 +01:00
47 changed files with 2703 additions and 30 deletions
+8
View File
@@ -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
+12
View File
@@ -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
}
}
@@ -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)
}
}
}
@@ -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)
+13
View File
@@ -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>
+43
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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