feat(bedtime): add an opt-in bedtime mode under Settings

Clockula can now turn on a Do Not Disturb mode of its own at bedtime and
off at the wake-up time, on the nights the user picks, with an optional
reminder before it starts. Digital Wellbeing's Bedtime mode can't be
driven by a third-party app, so this is a separate rule: a typed mode
with grayscale, dimmed wallpaper, dark theme and hidden always-on display
on Android 15+, and a plain DND schedule rule on Android 10-14.

The rule's policy always allows alarms (and media). BedtimeEngine owns one
AlarmManager slot of its own, on its own receiver and request code, and
every bedtime call on the boot, time-change and launch paths runs after
the alarm and timer passes, guarded. The rule id is never stored: the
platform lists an app's own rules, so restores and reinstalls find or
recreate it. Turning bedtime off removes the rule. The screen walks the
user through granting DND access and explains the Wellbeing overlap.
Settings are part of the JSON backup; older files restore bedtime as off.

Refs #1
This commit is contained in:
2026-10-22 11:19:38 +02:00
parent 3f431a7f32
commit 69b436cffd
46 changed files with 2132 additions and 16 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
+17
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,17 @@
</intent-filter>
</receiver>
<!-- The bedtime slot arriving, and Do Not Disturb access changing. The
second is a system broadcast addressed to this package, so the
receiver stays unexported (issue #1). -->
<receiver
android:name=".bedtime.receiver.BedtimeReceiver"
android:exported="false">
<intent-filter>
<action android:name="android.app.action.NOTIFICATION_POLICY_ACCESS_GRANTED_CHANGED" />
</intent-filter>
</receiver>
<!-- 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,6 +66,7 @@ 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:
@@ -80,6 +86,10 @@ class ClockulaApp : Application() {
// 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()
// Last and guarded, like every bedtime call on a shared path: a
// force stop clears its AlarmManager slot too, so the slot is
// re-armed here. The mode's own state is left as it is.
runCatching { bedtimeEngine.rearm() }
}
// Never completes on its own — it is the widgets' one refresh trigger
@@ -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
@@ -97,6 +101,10 @@ class SystemEventReceiver : HiltBroadcastReceiver() {
MidnightRefresh.schedule(context)
}
}
// 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.
runCatching { bedtimeEngine.resync() }
} finally {
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,11 @@ 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.
runCatching { bedtimeEngine.resync(refreshRule = true) }
}
}
failure?.let { throw it }
@@ -103,5 +110,6 @@ class BackupEngine @Inject constructor(
dynamicColor = settings.dynamicColor.first(),
defaults = settings.currentDefaults(),
timerPresets = settings.timerPresets.first(),
bedtime = settings.currentBedtime(),
)
}
@@ -0,0 +1,122 @@
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 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 from the new settings, and the mode is switched to
* what the new schedule says now, so turning bedtime on at 23:30 for a
* 23:00 bedtime starts it straight away.
*/
suspend fun update(transform: (BedtimeSettings) -> BedtimeSettings) = lock.withLock {
val updated = transform(settings.currentBedtime())
settings.setBedtime(updated)
apply(updated, refreshRule = true)
}
/**
* Everything that can invalidate the slot or the rule: a reboot, an app
* update, a clock or zone change, a backup restore, and Do Not Disturb
* access being granted or revoked.
*/
suspend fun resync(refreshRule: Boolean = false) = lock.withLock {
apply(settings.currentBedtime(), refreshRule = refreshRule)
}
/**
* App launch: re-arm the slot only. AlarmManager forgets it on a force
* stop, but the mode's state is left alone here — a launch is not a
* bedtime event, and a mode the user switched on or off by hand should
* stay that way until the next one.
*/
suspend fun rearm() = lock.withLock {
schedule(settings.currentBedtime())
}
/**
* The slot arriving. A reminder fire posts the reminder and leaves the
* mode alone; any other fire switches the mode to what the schedule says
* now. Either way the next event is armed.
*/
suspend fun onSlotFired() = lock.withLock {
val current = settings.currentBedtime()
val now = wallClock.now()
val zone = zones.current()
val reminderFor = BedtimeSchedule.dueReminder(current, now, zone)
if (reminderFor != null) {
notifier.showReminder(reminderFor.start, current.reminderMinutes)
schedule(current)
} else {
apply(current, refreshRule = false)
}
}
/** 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()
private fun apply(current: BedtimeSettings, refreshRule: Boolean) {
if (!current.enabled) {
scheduler.cancel()
notifier.cancelReminder()
// Removing the rule ends the mode if it is on. Without access there
// is nothing Clockula may remove; revoking access in Settings
// already removed the rule.
if (mode.hasAccess()) mode.uninstall()
return
}
val now = wallClock.now()
val active = BedtimeSchedule.activeWindow(current, now, zones.current()) != null
if (active) notifier.cancelReminder()
if (mode.hasAccess()) {
mode.install(current, refresh = refreshRule)
mode.setActive(active)
}
schedule(current)
}
private fun schedule(current: BedtimeSettings) {
val next = BedtimeSchedule.nextEvent(current, wallClock.now(), zones.current())
if (next == null) scheduler.cancel() else scheduler.schedule(next)
}
}
@@ -0,0 +1,28 @@
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"
/**
* `NotificationManager.ACTION_AUTOMATIC_ZEN_RULE`: what Settings sends the
* rule's configuration activity (`MainActivity`) when the user taps
* "Clockula" on the mode's page. Spelled out so the shell's pure routing
* table needs no Android import.
*/
const val ACTION_AUTOMATIC_ZEN_RULE: String = "android.app.action.AUTOMATIC_ZEN_RULE"
/**
* 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,51 @@
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 {
fun schedule(at: Instant)
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)
/** Turns the mode on or off. Does nothing when there is no rule. */
fun setActive(active: Boolean)
/** Removes the rule, which also ends it if it is on. */
fun uninstall()
/** The rule's id, for a deep link into the system's settings; null when there is none. */
fun ruleId(): String?
}
/** The reminder notification. */
interface BedtimeNotifier {
fun showReminder(start: Instant, minutesBefore: Int)
fun cancelReminder()
}
@@ -0,0 +1,168 @@
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.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 sends `ACTION_AUTOMATIC_ZEN_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) {
guarded(Unit) {
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))
} else if (refresh) {
// Keeps the user's own on/off switch for the rule in Settings: a
// change on Clockula's screen must not re-enable a rule they disabled.
manager.updateAutomaticZenRule(existing.key, rule(settings, enabled = existing.value.isEnabled))
}
}
}
override fun setActive(active: Boolean) {
guarded(Unit) {
val id = ownRules().keys.firstOrNull() ?: return@guarded
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)
}
}
override fun uninstall() {
guarded(Unit) {
ownRules().keys.forEach(manager::removeAutomaticZenRule)
}
}
override fun ruleId(): String? = guarded(null) { ownRules().keys.firstOrNull() }
private fun ownRules(): Map<String, AutomaticZenRule> =
manager.automaticZenRules.filterValues { it.conditionId == conditionId }
private fun rule(settings: BedtimeSettings, enabled: Boolean): AutomaticZenRule {
val name = context.getString(R.string.bedtime_mode_name)
val configuration = ComponentName(context, MainActivity::class.java)
val policy = 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,48 @@
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.
* Exact while idle where the platform allows it, the same two branches the
* timer's slot uses: a wake-up time that ends the mode an hour late, after a
* doze maintenance window, is the failure this avoids. 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) {
val millis = at.toEpochMilliseconds()
val pendingIntent = slotPendingIntent()
val exact = runCatching {
manager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, millis, pendingIntent)
}.isSuccess
if (!exact) manager.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, millis, pendingIntent)
}
override fun cancel() = manager.cancel(slotPendingIntent())
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,49 @@
package de.jeanlucmakiola.clockula.bedtime.receiver
import android.app.NotificationManager
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, and Do Not Disturb access changing. The second
* is sent by the system to this package only, so an unexported receiver gets
* it: granting access creates the rule at once, and revoking it leaves the
* slot running for the reminder (Settings removes the rule itself when the
* user revokes access there).
*/
@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 {
when (action) {
BedtimeIntents.ACTION_SLOT -> engine.onSlotFired()
NotificationManager.ACTION_NOTIFICATION_POLICY_ACCESS_GRANTED_CHANGED ->
engine.resync(refreshRule = true)
}
} 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 = RepeatDays.fromMask(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() ?: BedtimeSettings(),
)
return BackupResult.Success(
@@ -77,4 +77,23 @@ data class SettingsDto(
* built-in ones.
*/
@SerialName("timer_presets") val timerPresets: List<Long>? = null,
/** Absent from files written before issue #1; restores as "off" with the default schedule. */
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 = false,
@SerialName("bedtime_minutes") val bedtimeMinutes: Int = 22 * 60 + 30,
@SerialName("wake_minutes") val wakeMinutes: Int = 7 * 60,
val days: Int = 0b111_1111,
@SerialName("reminder_minutes") val reminderMinutes: Int = 0,
val grayscale: Boolean = true,
@SerialName("dim_wallpaper") val dimWallpaper: Boolean = true,
@SerialName("dark_theme") val darkTheme: Boolean = true,
@SerialName("hide_always_on_display") val hideAlwaysOnDisplay: Boolean = false,
)
@@ -0,0 +1,56 @@
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. */
val days: Pref<RepeatDays> = intPref("bedtime_days", DEFAULT.days.mask)
.map(decode = RepeatDays::fromMask, 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)
/** 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,7 @@ 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
prefs.putBedtime(backup.bedtime)
}
}
@@ -133,6 +137,59 @@ 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) }
}
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>,
/** Absent from older backups, which restore it as [BedtimeSettings]' defaults (off). */
val bedtime: BedtimeSettings = BedtimeSettings(),
)
@@ -0,0 +1,89 @@
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 that starts on [date], if [date] is one of the schedule's
* nights. It ends at the first wake time after it starts, so a wake time
* at or before the bedtime means the next morning, and an equal one a
* full day.
*/
fun windowOn(settings: BedtimeSettings, date: LocalDate, zone: ZoneId): BedtimeWindow? {
if (date.dayOfWeek !in settings.days) return null
val start = AlarmOccurrences.at(date, settings.bedtime, zone)
val sameDayEnd = AlarmOccurrences.at(date, settings.wakeTime, zone)
val end = if (sameDayEnd > start) sameDayEnd else AlarmOccurrences.at(date.plusDays(1), settings.wakeTime, zone)
return BedtimeWindow(start, end)
}
/** 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, so one that contains now started
// today or the day before.
return listOf(today, today.minusDays(1))
.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 (0L..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 fun today(now: Instant, zone: ZoneId): LocalDate = now.toJavaInstant().atZone(zone).toLocalDate()
}
@@ -0,0 +1,39 @@
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
}
@@ -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,265 @@
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 = if (settings.enabled) {
stringResource(R.string.bedtime_enabled_on, formatTime(settings.bedtime), formatTime(settings.wakeTime))
} else {
stringResource(R.string.bedtime_enabled_off)
},
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),
options = BedtimeLimits.REMINDER_CHOICES,
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,91 @@
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 kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
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,
) : 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
viewModelScope.launch {
if (changed) 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)) }
private fun update(transform: (BedtimeSettings) -> BedtimeSettings) {
viewModelScope.launch {
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,9 @@ object ShellNavigation {
ShellRequest.OpenTab(ClockulaDestination.ALARMS)
}
InteropIntents.ACTION_COMPOSE_TIMER -> ShellRequest.ComposeTimer
BedtimeIntents.ACTION_SHOW_BEDTIME,
BedtimeIntents.ACTION_AUTOMATIC_ZEN_RULE,
-> 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>
+42
View File
@@ -439,4 +439,46 @@
<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_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 starts on. 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.</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,29 @@ 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 `import never carries ring state`(@TempDir tempDir: Path) = runTest {
val source = backupHarness(tempDir.resolve("source2").also { it.toFile().mkdirs() })
@@ -0,0 +1,190 @@
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 `rearming touches the slot and not the mode`(@TempDir tempDir: Path) = runTest {
val h = harness(tempDir)
h.engine.update(nightly)
h.mode.on = true
h.scheduler.cancel()
h.engine.rearm()
assertThat(h.mode.on).isTrue()
assertThat(h.scheduler.armedAt).isEqualTo(offsetInstant("2026-06-10T23:00+02:00"))
}
@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 restores it off`() {
val settings = (BackupCodec.decode(minimalDocument()) as BackupResult.Success).content.settings
assertThat(settings.bedtime).isEqualTo(BedtimeSettings())
}
@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,131 @@
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
/** 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 a whole day, back to back`() {
val always = everyNight.copy(wakeTime = TimeOfDay(23, 0))
val window = BedtimeSchedule.activeWindow(always, instantAt(BERLIN, "2026-06-10T22:00"), BERLIN)
assertThat(window?.start).isEqualTo(offsetInstant("2026-06-09T23:00+02:00"))
assertThat(window?.end).isEqualTo(offsetInstant("2026-06-10T23:00+02:00"))
assertThat(BedtimeSchedule.activeWindow(always, instantAt(BERLIN, "2026-06-10T23:00"), BERLIN)).isNotNull()
}
@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 `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,78 @@
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.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
override fun schedule(at: Instant) {
armedAt = at
}
override fun cancel() {
armedAt = null
}
}
/**
* 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
override fun install(settings: BedtimeSettings, refresh: Boolean) {
if (!access) return
if (rule == null) {
rule = settings
} else if (refresh) {
rule = settings
refreshes++
}
}
override fun setActive(active: Boolean) {
if (!access || rule == null) return
on = active
}
override fun uninstall() {
if (!access) return
rule = null
on = false
}
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,12 @@ 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)
assertThat(ShellNavigation.requestFor(BedtimeIntents.ACTION_AUTOMATIC_ZEN_RULE, null, emptyList()))
.isEqualTo(ShellRequest.OpenBedtime)
}
}
+125 -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 device's 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` | `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
@@ -2686,3 +2692,121 @@ 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, and when Settings sends it
`NotificationManager.ACTION_AUTOMATIC_ZEN_RULE` 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. A user who later edits the mode in Settings and blocks alarms
there has made that choice in the system's own UI; Android 15 keeps a user's
edits over an app's updates, and Clockula does not fight them. 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`, exact while idle when the platform allows it and
`setAndAllowWhileIdle` otherwise, the timer slot's two branches. It never
uses `setAlarmClock`: that slot is the status bar's next *alarm*. Alarms
themselves use `setAlarmClock`, which the while-idle quota does not touch,
and bedtime adds at most three fires a night.
- `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 days to the first wake time after it (an earlier wake time is
the next morning, an equal one a full day), 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 | stores the settings, rewrites the rule from them, sets the mode to what the schedule says now (on at 23:30 for a 23:00 bedtime starts it at once), arms the slot. Off removes the rule, which ends the mode, cancels the slot and clears the reminder |
| `onSlotFired()` | `BedtimeReceiver` | a reminder fire posts the reminder and **leaves the mode alone**; any other fire sets the mode on or off. Then arms the next event |
| `resync(refreshRule)` | `SystemEventReceiver` (boot, app update, clock or zone change, exact-alarm permission), Do Not Disturb access changing, a backup restore | creates the rule if it is missing, sets the mode, arms the slot |
| `rearm()` | `ClockulaApp.onCreate` | arms the slot only. A force stop clears it, but a launch is not a bedtime event, so a mode the user switched by hand stays as it is |
Every bedtime call on a shared path runs **after** the alarm, timer and
stopwatch passes and inside `runCatching`, so nothing bedtime does can delay
or break them.
### 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, so coming back from granting it creates the mode without waiting for the
system's `ACTION_NOTIFICATION_POLICY_ACCESS_GRANTED_CHANGED` (which
`BedtimeReceiver` also answers). 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.
### Backup
The settings travel in the backup's `settings.bedtime` object
(`docs/BACKUP.md`); a file from before issue #1 restores bedtime as off.
`BackupEngine.import` resyncs the engine after the replace, or after rolling
it back.
+21 -1
View File
@@ -136,10 +136,30 @@ 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 starts on), and `reminder_minutes` 0 means no
reminder. A file without `bedtime` (any file written before it existed)
restores bedtime as off with the default schedule. Like the other settings it
is clamped rather than rejected: times into a day, the reminder into 0–120
minutes. 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 added after v1, 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