From 69b436cffde52d96e556f407864eb9c57f40f0e1 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Thu, 22 Oct 2026 11:19:38 +0200 Subject: [PATCH 01/10] 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 --- CHANGELOG.md | 8 + app/src/main/AndroidManifest.xml | 17 ++ .../de/jeanlucmakiola/clockula/ClockulaApp.kt | 10 + .../alarm/receiver/SystemEventReceiver.kt | 10 +- .../clockula/backup/BackupEngine.kt | 8 + .../clockula/bedtime/BedtimeEngine.kt | 122 ++++++++ .../clockula/bedtime/BedtimeIntents.kt | 28 ++ .../clockula/bedtime/BedtimeSeams.kt | 51 ++++ .../bedtime/android/AndroidBedtimeMode.kt | 168 +++++++++++ .../bedtime/android/AndroidBedtimeNotifier.kt | 88 ++++++ .../android/AndroidBedtimeScheduler.kt | 48 ++++ .../bedtime/android/BedtimeTimeFormat.kt | 22 ++ .../clockula/bedtime/di/BedtimeModule.kt | 31 ++ .../bedtime/receiver/BedtimeReceiver.kt | 49 ++++ .../clockula/data/backup/BackupCodec.kt | 40 +++ .../clockula/data/backup/BackupDto.kt | 19 ++ .../clockula/data/prefs/BedtimePrefs.kt | 56 ++++ .../clockula/data/prefs/SettingsPrefs.kt | 57 ++++ .../clockula/domain/backup/BackupContent.kt | 3 + .../domain/bedtime/BedtimeSchedule.kt | 89 ++++++ .../domain/bedtime/BedtimeSettings.kt | 39 +++ .../clockula/domain/selfcheck/SelfCheck.kt | 6 +- .../clockula/ui/bedtime/BedtimeScreen.kt | 265 ++++++++++++++++++ .../clockula/ui/bedtime/BedtimeUiState.kt | 18 ++ .../clockula/ui/bedtime/BedtimeViewModel.kt | 91 ++++++ .../clockula/ui/settings/SettingsRoutes.kt | 3 +- .../clockula/ui/settings/SettingsScreen.kt | 20 +- .../clockula/ui/shell/ClockulaShell.kt | 11 + .../clockula/ui/shell/ShellNavigation.kt | 7 + app/src/main/res/drawable/ic_bedtime.xml | 13 + app/src/main/res/values/strings.xml | 42 +++ .../clockula/ArchitectureRulesTest.kt | 1 + .../clockula/ManifestRulesTest.kt | 4 + .../clockula/backup/BackupEngineTest.kt | 23 ++ .../clockula/bedtime/BedtimeEngineTest.kt | 190 +++++++++++++ .../clockula/data/backup/BackupCodecTest.kt | 36 +++ .../clockula/data/prefs/SettingsPrefsTest.kt | 48 ++++ .../domain/bedtime/BedtimeScheduleTest.kt | 131 +++++++++ .../clockula/testing/BackupHarness.kt | 14 + .../clockula/testing/FakeBedtimeSeams.kt | 78 ++++++ .../ui/shell/ShellNavigationInteropTest.kt | 9 + docs/ARCHITECTURE.md | 126 ++++++++- docs/BACKUP.md | 22 +- docs/PLAN.md | 8 +- docs/PRIVACY.md | 8 +- docs/ROADMAP.md | 11 +- 46 files changed, 2132 insertions(+), 16 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/bedtime/BedtimeEngine.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/bedtime/BedtimeIntents.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/bedtime/BedtimeSeams.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/bedtime/android/AndroidBedtimeMode.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/bedtime/android/AndroidBedtimeNotifier.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/bedtime/android/AndroidBedtimeScheduler.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/bedtime/android/BedtimeTimeFormat.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/bedtime/di/BedtimeModule.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/bedtime/receiver/BedtimeReceiver.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/BedtimePrefs.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/domain/bedtime/BedtimeSchedule.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/domain/bedtime/BedtimeSettings.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/ui/bedtime/BedtimeScreen.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/ui/bedtime/BedtimeUiState.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/ui/bedtime/BedtimeViewModel.kt create mode 100644 app/src/main/res/drawable/ic_bedtime.xml create mode 100644 app/src/test/java/de/jeanlucmakiola/clockula/bedtime/BedtimeEngineTest.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/clockula/domain/bedtime/BedtimeScheduleTest.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeBedtimeSeams.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 76d4d0d..bf7eedf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index cfe76b4..fc436c2 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -50,6 +50,12 @@ + + + + + + + + + = 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) + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/BedtimeIntents.kt b/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/BedtimeIntents.kt new file mode 100644 index 0000000..4eb52a0 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/BedtimeIntents.kt @@ -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 +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/BedtimeSeams.kt b/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/BedtimeSeams.kt new file mode 100644 index 0000000..8192ed5 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/BedtimeSeams.kt @@ -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() +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/android/AndroidBedtimeMode.kt b/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/android/AndroidBedtimeMode.kt new file mode 100644 index 0000000..ce71253 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/android/AndroidBedtimeMode.kt @@ -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 = + 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 guarded(fallback: T, block: () -> T): T = try { + block() + } catch (_: RuntimeException) { + fallback + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/android/AndroidBedtimeNotifier.kt b/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/android/AndroidBedtimeNotifier.kt new file mode 100644 index 0000000..00dbcf4 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/android/AndroidBedtimeNotifier.kt @@ -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) + } + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/android/AndroidBedtimeScheduler.kt b/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/android/AndroidBedtimeScheduler.kt new file mode 100644 index 0000000..11ba260 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/android/AndroidBedtimeScheduler.kt @@ -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, + ) +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/android/BedtimeTimeFormat.kt b/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/android/BedtimeTimeFormat.kt new file mode 100644 index 0000000..4504780 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/android/BedtimeTimeFormat.kt @@ -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) +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/di/BedtimeModule.kt b/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/di/BedtimeModule.kt new file mode 100644 index 0000000..7e0c0d6 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/di/BedtimeModule.kt @@ -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 +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/receiver/BedtimeReceiver.kt b/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/receiver/BedtimeReceiver.kt new file mode 100644 index 0000000..2d6b448 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/receiver/BedtimeReceiver.kt @@ -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() + } + } + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupCodec.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupCodec.kt index c6daf96..3fd7069 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupCodec.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupCodec.kt @@ -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( diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupDto.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupDto.kt index 118e99e..b225f4a 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupDto.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupDto.kt @@ -77,4 +77,23 @@ data class SettingsDto( * built-in ones. */ @SerialName("timer_presets") val timerPresets: List? = 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, ) diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/BedtimePrefs.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/BedtimePrefs.kt new file mode 100644 index 0000000..ab1704c --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/BedtimePrefs.kt @@ -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 = booleanPref("bedtime_enabled", DEFAULT.enabled) + + val bedtime: Pref = intPref("bedtime_start_minutes", DEFAULT.bedtime.minutesOfDay).asTimeOfDay() + + val wakeTime: Pref = intPref("bedtime_wake_minutes", DEFAULT.wakeTime.minutesOfDay).asTimeOfDay() + + /** The 7-bit mask `RepeatDays` uses; a corrupt value cannot widen the set. */ + val days: Pref = intPref("bedtime_days", DEFAULT.days.mask) + .map(decode = RepeatDays::fromMask, encode = { it.mask }) + + val reminderMinutes: Pref = intPref("bedtime_reminder_minutes", DEFAULT.reminderMinutes) + .map( + decode = { it.coerceIn(BedtimeLimits.REMINDER_MINUTES) }, + encode = { it.coerceIn(BedtimeLimits.REMINDER_MINUTES) }, + ) + + val grayscale: Pref = booleanPref("bedtime_effect_grayscale", DEFAULT.effects.grayscale) + + val dimWallpaper: Pref = booleanPref("bedtime_effect_dim_wallpaper", DEFAULT.effects.dimWallpaper) + + val darkTheme: Pref = booleanPref("bedtime_effect_dark_theme", DEFAULT.effects.darkTheme) + + val hideAlwaysOnDisplay: Pref = + booleanPref("bedtime_effect_hide_aod", DEFAULT.effects.hideAlwaysOnDisplay) + + /** Minutes since midnight on disk, clamped into a day both ways. */ + private fun Pref.asTimeOfDay(): Pref = 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 +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefs.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefs.kt index b76785b..b802eca 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefs.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefs.kt @@ -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 = 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) --- /** diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/backup/BackupContent.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/backup/BackupContent.kt index eac440d..e20b821 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/domain/backup/BackupContent.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/backup/BackupContent.kt @@ -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, + /** Absent from older backups, which restore it as [BedtimeSettings]' defaults (off). */ + val bedtime: BedtimeSettings = BedtimeSettings(), ) diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/bedtime/BedtimeSchedule.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/bedtime/BedtimeSchedule.kt new file mode 100644 index 0000000..09e5b67 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/bedtime/BedtimeSchedule.kt @@ -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() +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/bedtime/BedtimeSettings.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/bedtime/BedtimeSettings.kt new file mode 100644 index 0000000..f8b0e82 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/bedtime/BedtimeSettings.kt @@ -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 = listOf(0, 15, 30, 45, 60) + + /** Anything read from disk or a backup is clamped into this. */ + val REMINDER_MINUTES: IntRange = 0..120 +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/selfcheck/SelfCheck.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/selfcheck/SelfCheck.kt index 0450a24..792eb50 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/domain/selfcheck/SelfCheck.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/selfcheck/SelfCheck.kt @@ -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 diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/bedtime/BedtimeScreen.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/bedtime/BedtimeScreen.kt new file mode 100644 index 0000000..9102513 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/bedtime/BedtimeScreen.kt @@ -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(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), + ) + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/bedtime/BedtimeUiState.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/bedtime/BedtimeUiState.kt new file mode 100644 index 0000000..147afe4 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/bedtime/BedtimeUiState.kt @@ -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 +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/bedtime/BedtimeViewModel.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/bedtime/BedtimeViewModel.kt new file mode 100644 index 0000000..d05c397 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/bedtime/BedtimeViewModel.kt @@ -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 = 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 + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SettingsRoutes.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SettingsRoutes.kt index ce364a0..1aad5f4 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SettingsRoutes.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SettingsRoutes.kt @@ -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" } diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SettingsScreen.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SettingsScreen.kt index eaeda0d..8ea54a1 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SettingsScreen.kt @@ -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( diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/shell/ClockulaShell.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/shell/ClockulaShell.kt index 71a4595..713674a 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/shell/ClockulaShell.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/shell/ClockulaShell.kt @@ -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 diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/shell/ShellNavigation.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/shell/ShellNavigation.kt index aac52fc..b0bb5bd 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/shell/ShellNavigation.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/shell/ShellNavigation.kt @@ -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) : 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) diff --git a/app/src/main/res/drawable/ic_bedtime.xml b/app/src/main/res/drawable/ic_bedtime.xml new file mode 100644 index 0000000..091d0fd --- /dev/null +++ b/app/src/main/res/drawable/ic_bedtime.xml @@ -0,0 +1,13 @@ + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ad0a66c..06d57cb 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -439,4 +439,46 @@ Custom… Custom duration Between 1 second and 59:59 + + + Sleep + A Do Not Disturb mode for the night + Bedtime + Clockula turns on a Do Not Disturb mode of its own at bedtime and turns it off when you wake up. Alarms always ring. + Bedtime mode + On, %1$s to %2$s + Off + Allow Do Not Disturb access + 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. + Allow + Schedule + Bedtime + Wake up + The nights bedtime starts on. A wake-up time before the bedtime is the next morning. + Reminder + Remind me before bedtime + Off + + %1$d minute before + %1$d minutes before + + Screen + Grayscale + Dim the wallpaper + Dark theme + Hide the always-on display + Screen effects need Android 15 or later. On this phone, bedtime silences notifications and calls the way Do Not Disturb does. + Open in system settings + Who can reach you, and more effects, under Settings › Modes. Changes you make there are kept. + Bedtime never silences an alarm or a timer: its mode always lets alarms through. + 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. + Clockula bedtime + %1$s to %2$s + Bedtime reminders + A reminder shortly before bedtime starts. + + Bedtime in %1$d minute + Bedtime in %1$d minutes + + Bedtime mode starts at %1$s. diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/ArchitectureRulesTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/ArchitectureRulesTest.kt index d22312d..1ae6a3e 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/ArchitectureRulesTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/ArchitectureRulesTest.kt @@ -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/") } diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/ManifestRulesTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/ManifestRulesTest.kt index fc5bea3..3acd004 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/ManifestRulesTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/ManifestRulesTest.kt @@ -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", ) } } diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/backup/BackupEngineTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/backup/BackupEngineTest.kt index f572b7d..43b7261 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/backup/BackupEngineTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/backup/BackupEngineTest.kt @@ -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() }) diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/bedtime/BedtimeEngineTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/bedtime/BedtimeEngineTest.kt new file mode 100644 index 0000000..d961b20 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/clockula/bedtime/BedtimeEngineTest.kt @@ -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")) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/data/backup/BackupCodecTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/data/backup/BackupCodecTest.kt index db57734..f4bbe52 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/data/backup/BackupCodecTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/data/backup/BackupCodecTest.kt @@ -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\": []}") diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefsTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefsTest.kt index 71ead3e..8037794 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefsTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefsTest.kt @@ -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 diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/domain/bedtime/BedtimeScheduleTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/domain/bedtime/BedtimeScheduleTest.kt new file mode 100644 index 0000000..9b48ad6 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/clockula/domain/bedtime/BedtimeScheduleTest.kt @@ -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() + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/testing/BackupHarness.kt b/app/src/test/java/de/jeanlucmakiola/clockula/testing/BackupHarness.kt index 43e2b66..2a83db0 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/testing/BackupHarness.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/testing/BackupHarness.kt @@ -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, ) diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeBedtimeSeams.kt b/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeBedtimeSeams.kt new file mode 100644 index 0000000..6cef44e --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeBedtimeSeams.kt @@ -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> = 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 + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/ui/shell/ShellNavigationInteropTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/ui/shell/ShellNavigationInteropTest.kt index 09190be..cbe63e3 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/ui/shell/ShellNavigationInteropTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/ui/shell/ShellNavigationInteropTest.kt @@ -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) + } } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 97ef4e8..363dc39 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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` 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:///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. + diff --git a/docs/BACKUP.md b/docs/BACKUP.md index e8536de..e58eb1f 100644 --- a/docs/BACKUP.md +++ b/docs/BACKUP.md @@ -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 diff --git a/docs/PLAN.md b/docs/PLAN.md index 4899d1e..25015e9 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -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. diff --git a/docs/PRIVACY.md b/docs/PRIVACY.md index 17c2562..385d5ec 100644 --- a/docs/PRIVACY.md +++ b/docs/PRIVACY.md @@ -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 --- -**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 diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index bb7731b..8b0f770 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -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 From 119197106ba06333507aa58fa41fd23e193998f6 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Thu, 22 Oct 2026 11:29:33 +0200 Subject: [PATCH 02/10] fix(bedtime): follow boundaries, open from Settings, keep the user's policy Settings opens a rule's configuration activity with no action, only the component and the rule id, so MainActivity now routes on the rule-id extras. Each engine pass reads the clock once, so the mode's state and the next slot can't disagree around a boundary, and non-edit passes switch the mode only when the schedule crossed a boundary since Clockula last set it (persisted), which also catches up a wake-up missed during a force stop while leaving a mode switched on by hand alone. A reminder fire that also ends a window now turns the mode off, and a reminder the schedule moved away from is withdrawn. The rule is only rewritten when a field it carries changes, and a rewrite keeps the existing policy if it still allows alarms, so Android 10-14 no longer loses the user's Do Not Disturb edits. The bedtime receiver, screen and fallback alarm call are guarded, the boot-path resync runs even if an earlier engine failed, and a backup with no nights restores every night. Refs #1 --- .../de/jeanlucmakiola/clockula/ClockulaApp.kt | 6 +- .../jeanlucmakiola/clockula/MainActivity.kt | 4 +- .../alarm/receiver/SystemEventReceiver.kt | 5 +- .../clockula/bedtime/BedtimeEngine.kt | 84 ++++++++++--------- .../clockula/bedtime/BedtimeIntents.kt | 15 ++-- .../bedtime/android/AndroidBedtimeMode.kt | 29 +++++-- .../android/AndroidBedtimeScheduler.kt | 4 +- .../bedtime/receiver/BedtimeReceiver.kt | 12 ++- .../clockula/data/backup/BackupCodec.kt | 3 +- .../clockula/data/backup/BackupDto.kt | 22 +++-- .../clockula/data/prefs/BedtimePrefs.kt | 8 ++ .../clockula/data/prefs/SettingsPrefs.kt | 4 + .../clockula/ui/bedtime/BedtimeViewModel.kt | 4 +- .../clockula/ui/shell/ShellNavigation.kt | 15 ++-- .../clockula/bedtime/BedtimeEngineTest.kt | 44 +++++++++- .../ui/shell/ShellNavigationInteropTest.kt | 5 +- docs/ARCHITECTURE.md | 47 +++++++---- docs/BACKUP.md | 2 +- 18 files changed, 214 insertions(+), 99 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ClockulaApp.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ClockulaApp.kt index 00fde8c..1cb639a 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ClockulaApp.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ClockulaApp.kt @@ -87,9 +87,9 @@ class ClockulaApp : Application() { // 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() } + // force stop clears its AlarmManager slot too, and may have slept + // through a bedtime or wake-up, so both are caught up here. + runCatching { bedtimeEngine.resync() } } // Never completes on its own — it is the widgets' one refresh trigger diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/MainActivity.kt b/app/src/main/java/de/jeanlucmakiola/clockula/MainActivity.kt index 958be6d..a817945 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/MainActivity.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/MainActivity.kt @@ -16,6 +16,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle import dagger.hilt.android.AndroidEntryPoint +import de.jeanlucmakiola.clockula.bedtime.BedtimeIntents import de.jeanlucmakiola.clockula.data.prefs.SettingsPrefs import de.jeanlucmakiola.clockula.interop.InteropIntents import de.jeanlucmakiola.clockula.ui.crash.CrashReportActivity @@ -165,7 +166,8 @@ class MainActivity : ComponentActivity() { .getOrNull() ?.toList() .orEmpty() - return ShellNavigation.requestFor(intent.action, alarmId, alarmIds) + val forZenRule = runCatching { BedtimeIntents.ZEN_RULE_EXTRAS.any(intent::hasExtra) }.getOrDefault(false) + return ShellNavigation.requestFor(intent.action, alarmId, alarmIds, forZenRule) } override fun onResume() { diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/alarm/receiver/SystemEventReceiver.kt b/app/src/main/java/de/jeanlucmakiola/clockula/alarm/receiver/SystemEventReceiver.kt index 09df3ff..05469f6 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/alarm/receiver/SystemEventReceiver.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/alarm/receiver/SystemEventReceiver.kt @@ -101,11 +101,12 @@ class SystemEventReceiver : HiltBroadcastReceiver() { MidnightRefresh.schedule(context) } } + } finally { // Last, and on its own: every one of these events can move or // clear the bedtime slot too, but nothing bedtime does may - // delay or break the alarm and timer passes above. + // delay or break the alarm and timer passes above, and a + // failure up there must not cost bedtime its pass either. runCatching { bedtimeEngine.resync() } - } finally { pendingResult.finish() } } diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/BedtimeEngine.kt b/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/BedtimeEngine.kt index 1fdbcb4..fac51ee 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/BedtimeEngine.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/BedtimeEngine.kt @@ -8,6 +8,7 @@ import de.jeanlucmakiola.clockula.domain.time.ZoneProvider import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import kotlin.time.Instant import javax.inject.Inject import javax.inject.Singleton @@ -41,50 +42,41 @@ class BedtimeEngine @Inject constructor( /** * 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. + * rule is rewritten when a field it carries changed, 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()) + val previous = settings.currentBedtime() + val updated = transform(previous) settings.setBedtime(updated) - apply(updated, refreshRule = true) + apply(updated, refreshRule = previous.ruleFields() != updated.ruleFields(), force = 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. + * Everything that can invalidate the slot or the rule: app launch (a + * force stop clears the slot), a reboot, an app update, a clock or zone + * change, a backup restore, and Do Not Disturb access changing. The mode + * is only switched when the schedule crossed a boundary since Clockula + * last switched it, so a mode the user turned on or off by hand stays + * that way until the next bedtime or wake-up. [refreshRule] also rewrites + * the rule and re-asserts the mode's state. */ suspend fun resync(refreshRule: Boolean = false) = lock.withLock { - apply(settings.currentBedtime(), refreshRule = refreshRule) + apply(settings.currentBedtime(), refreshRule = refreshRule, force = 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. + * The slot arriving: the mode follows any boundary crossed, a due + * reminder is posted, and the next event is armed. A reminder fire alone + * crosses no boundary, so it leaves the mode as it is. */ suspend fun onSlotFired() = lock.withLock { val current = settings.currentBedtime() val now = wallClock.now() - val zone = zones.current() - val reminderFor = BedtimeSchedule.dueReminder(current, now, zone) - if (reminderFor != null) { - notifier.showReminder(reminderFor.start, current.reminderMinutes) - schedule(current) - } else { - apply(current, refreshRule = false) + apply(current, refreshRule = false, force = false, now = now) + BedtimeSchedule.dueReminder(current, now, zones.current())?.let { window -> + notifier.showReminder(window.start, current.reminderMinutes) } } @@ -95,7 +87,17 @@ class BedtimeEngine @Inject constructor( fun ruleId(): String? = mode.ruleId() - private fun apply(current: BedtimeSettings, refreshRule: Boolean) { + /** + * One pass, over one reading of the clock: the mode's state and the next + * slot must agree about which side of a boundary "now" is on. + */ + private suspend fun apply( + current: BedtimeSettings, + refreshRule: Boolean, + force: Boolean, + now: Instant = wallClock.now(), + ) { + val zone = zones.current() if (!current.enabled) { scheduler.cancel() notifier.cancelReminder() @@ -103,20 +105,24 @@ class BedtimeEngine @Inject constructor( // is nothing Clockula may remove; revoking access in Settings // already removed the rule. if (mode.hasAccess()) mode.uninstall() + settings.setBedtimeAppliedActive(false) return } - val now = wallClock.now() - val active = BedtimeSchedule.activeWindow(current, now, zones.current()) != null - if (active) notifier.cancelReminder() + val active = BedtimeSchedule.activeWindow(current, now, zone) != null + // A reminder that no longer points at a coming bedtime (it started, + // or the schedule changed under it) is stale. + if (BedtimeSchedule.dueReminder(current, now, zone) == null) notifier.cancelReminder() if (mode.hasAccess()) { mode.install(current, refresh = refreshRule) - mode.setActive(active) + if (force || active != settings.bedtimeAppliedActive()) { + mode.setActive(active) + settings.setBedtimeAppliedActive(active) + } } - schedule(current) - } - - private fun schedule(current: BedtimeSettings) { - val next = BedtimeSchedule.nextEvent(current, wallClock.now(), zones.current()) + val next = BedtimeSchedule.nextEvent(current, now, zone) if (next == null) scheduler.cancel() else scheduler.schedule(next) } + + /** What the system rule is built from; the reminder is not part of it. */ + private fun BedtimeSettings.ruleFields() = listOf(enabled, bedtime, wakeTime, effects) } diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/BedtimeIntents.kt b/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/BedtimeIntents.kt index 4eb52a0..f9a2758 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/BedtimeIntents.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/BedtimeIntents.kt @@ -11,12 +11,17 @@ object BedtimeIntents { 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. + * The extras Settings puts on the rule's configuration activity + * (`MainActivity`) when the user taps "Clockula" on the mode's page: + * `NotificationManager.EXTRA_AUTOMATIC_RULE_ID` and + * `ConditionProviderService.EXTRA_RULE_ID`. Settings sends no action with + * them, only the explicit component, so their presence is the signal. + * Spelled out so the shell's pure routing table needs no Android import. */ - const val ACTION_AUTOMATIC_ZEN_RULE: String = "android.app.action.AUTOMATIC_ZEN_RULE" + val ZEN_RULE_EXTRAS: List = listOf( + "android.app.extra.AUTOMATIC_RULE_ID", + "android.service.notification.extra.RULE_ID", + ) /** * Apart from every alarm and timer request code, although the explicit diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/android/AndroidBedtimeMode.kt b/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/android/AndroidBedtimeMode.kt index ce71253..0319d06 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/android/AndroidBedtimeMode.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/android/AndroidBedtimeMode.kt @@ -34,7 +34,7 @@ import javax.inject.Singleton * 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 + * Settings opens it for the rule. It is already exported, so no new * component is exposed for this. * * Nothing is stored about the rule. [NotificationManager.getAutomaticZenRules] @@ -70,9 +70,14 @@ class AndroidBedtimeMode @Inject constructor( 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)) + // Keeps what the user set for the rule in Settings: its on/off + // switch (a change here must not re-enable a rule they + // disabled) and who may interrupt, which Android 10 to 14 + // would otherwise overwrite. Android 15 keeps both itself. + manager.updateAutomaticZenRule( + existing.key, + rule(settings, enabled = existing.value.isEnabled, kept = existing.value.zenPolicy), + ) } } } @@ -104,13 +109,19 @@ class AndroidBedtimeMode @Inject constructor( private fun ownRules(): Map = manager.automaticZenRules.filterValues { it.conditionId == conditionId } - private fun rule(settings: BedtimeSettings, enabled: Boolean): AutomaticZenRule { + /** + * [kept] is the existing rule's policy, reused as long as it lets alarms + * through: a user's edit in Settings survives, and an edit that blocked + * alarms is the one thing replaced. + */ + private fun rule(settings: BedtimeSettings, enabled: Boolean, kept: ZenPolicy? = null): AutomaticZenRule { val name = context.getString(R.string.bedtime_mode_name) val configuration = ComponentName(context, MainActivity::class.java) - val policy = ZenPolicy.Builder() - .allowAlarms(true) - .allowMedia(true) - .build() + val policy = kept?.takeIf { it.priorityCategoryAlarms == ZenPolicy.STATE_ALLOW } + ?: ZenPolicy.Builder() + .allowAlarms(true) + .allowMedia(true) + .build() return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) { modeRule(name, configuration, policy, settings, enabled) } else { diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/android/AndroidBedtimeScheduler.kt b/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/android/AndroidBedtimeScheduler.kt index 11ba260..10c7c37 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/android/AndroidBedtimeScheduler.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/android/AndroidBedtimeScheduler.kt @@ -34,7 +34,9 @@ class AndroidBedtimeScheduler @Inject constructor( val exact = runCatching { manager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, millis, pendingIntent) }.isSuccess - if (!exact) manager.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, millis, pendingIntent) + // Guarded too: bedtime losing its slot is survivable, a crash in the + // receiver's process is not. + if (!exact) runCatching { manager.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, millis, pendingIntent) } } override fun cancel() = manager.cancel(slotPendingIntent()) diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/receiver/BedtimeReceiver.kt b/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/receiver/BedtimeReceiver.kt index 2d6b448..1f6ad1f 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/receiver/BedtimeReceiver.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/receiver/BedtimeReceiver.kt @@ -36,10 +36,14 @@ class BedtimeReceiver : HiltBroadcastReceiver() { val pendingResult = goAsync() scope.launch { try { - when (action) { - BedtimeIntents.ACTION_SLOT -> engine.onSlotFired() - NotificationManager.ACTION_NOTIFICATION_POLICY_ACCESS_GRANTED_CHANGED -> - engine.resync(refreshRule = true) + // Guarded: a platform refusal here must not crash the process, + // which is the process an alarm may be about to ring in. + runCatching { + when (action) { + BedtimeIntents.ACTION_SLOT -> engine.onSlotFired() + NotificationManager.ACTION_NOTIFICATION_POLICY_ACCESS_GRANTED_CHANGED -> + engine.resync(refreshRule = true) + } } } finally { pendingResult.finish() diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupCodec.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupCodec.kt index 3fd7069..e82852c 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupCodec.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupCodec.kt @@ -154,7 +154,8 @@ object BackupCodec { enabled = enabled, bedtime = minutesToTime(bedtimeMinutes), wakeTime = minutesToTime(wakeMinutes), - days = RepeatDays.fromMask(days), + // The screen never lets the last night go; a file with none gets all. + days = RepeatDays.fromMask(days).takeIf { it.isRepeating } ?: RepeatDays.EVERY_DAY, reminderMinutes = reminderMinutes.coerceIn(BedtimeLimits.REMINDER_MINUTES), effects = BedtimeEffects( grayscale = grayscale, diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupDto.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupDto.kt index b225f4a..f664ca4 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupDto.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupDto.kt @@ -1,5 +1,6 @@ package de.jeanlucmakiola.clockula.data.backup +import de.jeanlucmakiola.clockula.domain.bedtime.BedtimeSettings import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -87,13 +88,16 @@ data class SettingsDto( */ @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, + val enabled: Boolean = DEFAULT_BEDTIME.enabled, + @SerialName("bedtime_minutes") val bedtimeMinutes: Int = DEFAULT_BEDTIME.bedtime.minutesOfDay, + @SerialName("wake_minutes") val wakeMinutes: Int = DEFAULT_BEDTIME.wakeTime.minutesOfDay, + val days: Int = DEFAULT_BEDTIME.days.mask, + @SerialName("reminder_minutes") val reminderMinutes: Int = DEFAULT_BEDTIME.reminderMinutes, + val grayscale: Boolean = DEFAULT_BEDTIME.effects.grayscale, + @SerialName("dim_wallpaper") val dimWallpaper: Boolean = DEFAULT_BEDTIME.effects.dimWallpaper, + @SerialName("dark_theme") val darkTheme: Boolean = DEFAULT_BEDTIME.effects.darkTheme, + @SerialName("hide_always_on_display") val hideAlwaysOnDisplay: Boolean = DEFAULT_BEDTIME.effects.hideAlwaysOnDisplay, ) + +/** A missing field restores what a fresh install has. */ +private val DEFAULT_BEDTIME = BedtimeSettings() diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/BedtimePrefs.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/BedtimePrefs.kt index ab1704c..68810ef 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/BedtimePrefs.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/BedtimePrefs.kt @@ -43,6 +43,14 @@ object BedtimePrefs { val hideAlwaysOnDisplay: Pref = booleanPref("bedtime_effect_hide_aod", DEFAULT.effects.hideAlwaysOnDisplay) + /** + * Whether Clockula last switched the mode on or off: device state, not + * configuration, so it is not in the JSON backup. It lets a pass switch + * the mode only when the schedule crossed a boundary since, and leave a + * mode the user switched by hand alone otherwise. + */ + val appliedActive: Pref = booleanPref("bedtime_applied_active", false) + /** Minutes since midnight on disk, clamped into a day both ways. */ private fun Pref.asTimeOfDay(): Pref = map( decode = { minutes -> diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefs.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefs.kt index b802eca..a85904e 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefs.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefs.kt @@ -178,6 +178,10 @@ class SettingsPrefs @Inject constructor(private val store: PrefStore) { store.edit { prefs -> prefs.putBedtime(settings) } } + suspend fun bedtimeAppliedActive(): Boolean = store.get(BedtimePrefs.appliedActive) + + suspend fun setBedtimeAppliedActive(active: Boolean) = store.set(BedtimePrefs.appliedActive, active) + private fun MutablePreferences.putBedtime(settings: BedtimeSettings) { this[BedtimePrefs.enabled] = settings.enabled this[BedtimePrefs.bedtime] = settings.bedtime diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/bedtime/BedtimeViewModel.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/bedtime/BedtimeViewModel.kt index d05c397..a50eddc 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/bedtime/BedtimeViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/bedtime/BedtimeViewModel.kt @@ -48,7 +48,7 @@ class BedtimeViewModel @Inject internal constructor( val changed = granted != access.value access.value = granted viewModelScope.launch { - if (changed) engine.resync(refreshRule = true) + if (changed) runCatching { engine.resync(refreshRule = true) } ruleId.value = engine.ruleId() } } @@ -80,7 +80,7 @@ class BedtimeViewModel @Inject internal constructor( private fun update(transform: (BedtimeSettings) -> BedtimeSettings) { viewModelScope.launch { - engine.update(transform) + runCatching { engine.update(transform) } ruleId.value = engine.ruleId() } } diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/shell/ShellNavigation.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/shell/ShellNavigation.kt index b0bb5bd..13cce4b 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/shell/ShellNavigation.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/shell/ShellNavigation.kt @@ -150,9 +150,16 @@ object ShellNavigation { * missing or non-positive alarm id, or an empty id list, falls back to the * Alarms tab (M9 D23). */ - fun requestFor(action: String?, alarmId: Long?, alarmIds: List): ShellRequest? = + fun requestFor( + action: String?, + alarmId: Long?, + alarmIds: List, + forZenRule: Boolean = false, + ): ShellRequest? = when (action) { - null -> null + // Settings opening the bedtime rule's configuration activity: an + // explicit component, a rule id extra, and no action (issue #1). + null -> if (forZenRule) ShellRequest.OpenBedtime else null InteropIntents.ACTION_OPEN_ALARM_EDITOR -> if (alarmId != null && alarmId > 0L) { ShellRequest.OpenAlarmEditor(alarmId) @@ -160,9 +167,7 @@ object ShellNavigation { ShellRequest.OpenTab(ClockulaDestination.ALARMS) } InteropIntents.ACTION_COMPOSE_TIMER -> ShellRequest.ComposeTimer - BedtimeIntents.ACTION_SHOW_BEDTIME, - BedtimeIntents.ACTION_AUTOMATIC_ZEN_RULE, - -> ShellRequest.OpenBedtime + BedtimeIntents.ACTION_SHOW_BEDTIME -> ShellRequest.OpenBedtime InteropIntents.ACTION_CHOOSE_ALARM_TO_DISMISS -> if (alarmIds.isEmpty()) { ShellRequest.OpenTab(ClockulaDestination.ALARMS) diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/bedtime/BedtimeEngineTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/bedtime/BedtimeEngineTest.kt index d961b20..aeba63b 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/bedtime/BedtimeEngineTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/bedtime/BedtimeEngineTest.kt @@ -163,18 +163,58 @@ class BedtimeEngineTest { } @Test - fun `rearming touches the slot and not the mode`(@TempDir tempDir: Path) = runTest { + fun `a resync with no boundary crossed leaves a hand-switched mode alone`(@TempDir tempDir: Path) = runTest { val h = harness(tempDir) h.engine.update(nightly) + // Switched on by hand in the afternoon, then the slot lost to a force stop. h.mode.on = true h.scheduler.cancel() - h.engine.rearm() + h.engine.resync() assertThat(h.mode.on).isTrue() assertThat(h.scheduler.armedAt).isEqualTo(offsetInstant("2026-06-10T23:00+02:00")) } + @Test + fun `a reminder fire that also ends a window turns the mode off`(@TempDir tempDir: Path) = runTest { + // A 23.5-hour window whose end falls inside the next one's reminder lead. + val h = harness(tempDir, now = instantAt(BERLIN, "2026-06-10T08:00")) + h.engine.update { + it.copy(enabled = true, bedtime = TimeOfDay(7, 30), wakeTime = TimeOfDay(7, 0), reminderMinutes = 30) + } + assertThat(h.mode.on).isTrue() + + h.wallClock.instant = instantAt(BERLIN, "2026-06-11T07:00") + h.engine.onSlotFired() + + assertThat(h.mode.on).isFalse() + assertThat(h.notifier.reminders).hasSize(1) + } + + @Test + fun `a reminder the schedule moved away from is withdrawn`(@TempDir tempDir: Path) = runTest { + val h = harness(tempDir) + h.engine.update { nightly(it).copy(reminderMinutes = 30) } + h.wallClock.instant = instantAt(BERLIN, "2026-06-10T22:30") + h.engine.onSlotFired() + assertThat(h.notifier.showing).isTrue() + + h.engine.update { it.copy(bedtime = TimeOfDay(23, 59)) } + + assertThat(h.notifier.showing).isFalse() + } + + @Test + fun `changing only the reminder does not rewrite the rule`(@TempDir tempDir: Path) = runTest { + val h = harness(tempDir) + h.engine.update(nightly) + + h.engine.update { it.copy(reminderMinutes = 45) } + + assertThat(h.mode.refreshes).isEqualTo(0) + } + @Test fun `a missed wake-up fire is caught up by the next resync`(@TempDir tempDir: Path) = runTest { val h = harness(tempDir, now = instantAt(BERLIN, "2026-06-10T23:30")) diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/ui/shell/ShellNavigationInteropTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/ui/shell/ShellNavigationInteropTest.kt index cbe63e3..bc3ce5c 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/ui/shell/ShellNavigationInteropTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/ui/shell/ShellNavigationInteropTest.kt @@ -128,7 +128,10 @@ class ShellNavigationInteropTest { 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())) + // Settings opens the rule's configuration activity with no action, + // only the component and the rule id. + assertThat(ShellNavigation.requestFor(null, null, emptyList(), forZenRule = true)) .isEqualTo(ShellRequest.OpenBedtime) + assertThat(ShellNavigation.requestFor(null, null, emptyList())).isNull() } } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 363dc39..10c3e25 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -602,7 +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` 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) | +| Bedtime (issue #1) | `bedtime_enabled`, `bedtime_start_minutes`, `bedtime_wake_minutes`, `bedtime_days`, `bedtime_reminder_minutes`, `bedtime_effect_grayscale`, `bedtime_effect_dim_wallpaper`, `bedtime_effect_dark_theme`, `bedtime_effect_hide_aod`, `bedtime_applied_active` | `BedtimePrefs`, surfaced as `SettingsPrefs.bedtime: Flow` 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. @@ -2726,9 +2726,11 @@ automatic zen rule of its own: 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: +exported, so no new component is exposed. Settings opens it with the explicit +component, the rule id in `NotificationManager.EXTRA_AUTOMATIC_RULE_ID` / +`ConditionProviderService.EXTRA_RULE_ID`, and **no action**, so the presence of +either extra is what makes the shell open 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 @@ -2740,9 +2742,12 @@ 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 +exemption applies. The rule is only rewritten when a field it carries changes +(the times, the effects, the switch; not the reminder), and a rewrite reuses +the rule's existing policy as long as that still allows alarms, so a user's +"who may interrupt" edits in Settings survive on Android 10 to 14 as well +(Android 15 keeps a user's edits over an app's updates by itself). An edit +that blocked alarms is the one thing a rewrite puts back. The self-check (M10's `SelfCheck`) still reports total silence as a problem whatever caused it. ### The engine @@ -2771,14 +2776,27 @@ 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 | +| `update(transform)` | the bedtime screen | stores the settings, rewrites the rule if a field it carries changed, 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` | follows any boundary crossed, posts a due reminder, arms the next event. A reminder fire alone crosses no boundary, so it leaves a mode switched on by hand alone | +| `resync()` | `ClockulaApp.onCreate` (a force stop clears the slot), `SystemEventReceiver` (boot, app update, clock or zone change, exact-alarm permission) | creates the rule if it is missing, follows any boundary crossed, arms the slot | +| `resync(refreshRule = true)` | Do Not Disturb access changing (the screen's `ON_RESUME` check and the broadcast), a backup restore | the same, but also rewrites the rule and re-asserts the mode's state | + +"Follows any boundary crossed" is the one rule that lets a hand-switched mode +coexist with a schedule. The last state Clockula set is persisted +(`bedtime_applied_active`, device state, not in the JSON backup), and a pass +that is not an edit switches the mode only when the schedule now says +something different. A launch in the afternoon leaves a mode the user turned +on alone; a launch after a force stop that slept through the wake-up time +turns it off. Each pass reads the clock **once**, so the mode's state and the +next slot always agree about which side of a boundary "now" is on. A posted +reminder is withdrawn by any pass once no coming bedtime is due within its +lead, so moving the schedule away from it does not leave a stale one. 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. +stopwatch passes and inside `runCatching` (in `SystemEventReceiver`, in its +`finally`, so a failure up there does not cost bedtime its pass either), so +nothing bedtime does can delay or break them. `BedtimeReceiver` and the +screen guard their calls too. ### No stored rule id @@ -2806,7 +2824,8 @@ 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. +(`docs/BACKUP.md`); a file from before issue #1 restores bedtime as off, and +one with no nights at all restores every night. `BackupEngine.import` resyncs the engine after the replace, or after rolling it back. diff --git a/docs/BACKUP.md b/docs/BACKUP.md index e58eb1f..6a26075 100644 --- a/docs/BACKUP.md +++ b/docs/BACKUP.md @@ -157,7 +157,7 @@ since local midnight, `days` is the same 7-bit mask as an alarm's 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; +minutes, and a `days` of 0 (which the screen never allows) into every night. The id of the Do Not Disturb rule a device created is not part of it; the importing device creates its own (`docs/ARCHITECTURE.md` §18). Field names mirror `ClockPrefs`'/`SettingsPrefs`' own on-disk keys. Older From 2dce78281ac90fb70aa1984bc4fbe65cb79c9711 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Thu, 22 Oct 2026 11:38:56 +0200 Subject: [PATCH 03/10] fix(bedtime): switch a recreated rule, keep manual overrides, finish edits A rule recreated by a pass (deleted in Settings, access re-granted) now gets its state set even when no boundary was crossed, and the applied state is recorded only once the system accepted it. Edits that can't move a boundary (reminder, effects) no longer re-assert the mode over a manual override, and the screen's writes run on the application scope so leaving right after a change can't cut it short. A late reminder counts the minutes actually left, and one slot fire reads the zone once. An equal bedtime and wake time is now no window instead of a day-long one, which on every night meant Do Not Disturb never ended, and an empty days mask in prefs reads as every night, as a backup's does. The rule-id extras are translated to the bedtime action in MainActivity, keeping the routing table keyed on the action alone. Refs #1 --- .../jeanlucmakiola/clockula/MainActivity.kt | 8 +++- .../clockula/bedtime/BedtimeEngine.kt | 42 +++++++++++++------ .../clockula/bedtime/BedtimeSeams.kt | 11 ++--- .../bedtime/android/AndroidBedtimeMode.kt | 18 ++++---- .../clockula/data/prefs/BedtimePrefs.kt | 11 ++++- .../domain/bedtime/BedtimeSchedule.kt | 7 ++-- .../clockula/ui/bedtime/BedtimeViewModel.kt | 12 +++++- .../clockula/ui/shell/ShellNavigation.kt | 11 +---- app/src/main/res/values/strings.xml | 2 +- .../clockula/bedtime/BedtimeEngineTest.kt | 37 ++++++++++++++++ .../domain/bedtime/BedtimeScheduleTest.kt | 11 ++--- .../clockula/testing/FakeBedtimeSeams.kt | 16 ++++--- .../ui/shell/ShellNavigationInteropTest.kt | 11 ++--- docs/ARCHITECTURE.md | 18 +++++--- 14 files changed, 148 insertions(+), 67 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/MainActivity.kt b/app/src/main/java/de/jeanlucmakiola/clockula/MainActivity.kt index a817945..35b6ffa 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/MainActivity.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/MainActivity.kt @@ -166,8 +166,12 @@ class MainActivity : ComponentActivity() { .getOrNull() ?.toList() .orEmpty() - val forZenRule = runCatching { BedtimeIntents.ZEN_RULE_EXTRAS.any(intent::hasExtra) }.getOrDefault(false) - return ShellNavigation.requestFor(intent.action, alarmId, alarmIds, forZenRule) + // Settings opens the bedtime rule's configuration activity (this one) + // with the rule id and no action; that is the bedtime screen (issue #1). + val forZenRule = intent.action == null && + runCatching { BedtimeIntents.ZEN_RULE_EXTRAS.any(intent::hasExtra) }.getOrDefault(false) + val action = if (forZenRule) BedtimeIntents.ACTION_SHOW_BEDTIME else intent.action + return ShellNavigation.requestFor(action, alarmId, alarmIds) } override fun onResume() { diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/BedtimeEngine.kt b/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/BedtimeEngine.kt index fac51ee..22a0d88 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/BedtimeEngine.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/BedtimeEngine.kt @@ -8,6 +8,9 @@ import de.jeanlucmakiola.clockula.domain.time.ZoneProvider import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import java.time.ZoneId +import kotlin.math.ceil +import kotlin.time.Duration.Companion.minutes import kotlin.time.Instant import javax.inject.Inject import javax.inject.Singleton @@ -42,15 +45,21 @@ class BedtimeEngine @Inject constructor( /** * A change made on the bedtime screen: stored, then applied at once. The - * rule is rewritten when a field it carries changed, 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. + * rule is rewritten when a field it carries changed. When the schedule + * itself changed, the mode is switched to what it says now, so turning + * bedtime on at 23:30 for a 23:00 bedtime starts it straight away; an + * edit that cannot move a boundary (the reminder, an effect) leaves a + * mode switched by hand alone. */ suspend fun update(transform: (BedtimeSettings) -> BedtimeSettings) = lock.withLock { val previous = settings.currentBedtime() val updated = transform(previous) settings.setBedtime(updated) - apply(updated, refreshRule = previous.ruleFields() != updated.ruleFields(), force = true) + apply( + updated, + refreshRule = previous.ruleFields() != updated.ruleFields(), + force = previous.scheduleFields() != updated.scheduleFields(), + ) } /** @@ -74,9 +83,12 @@ class BedtimeEngine @Inject constructor( suspend fun onSlotFired() = lock.withLock { val current = settings.currentBedtime() val now = wallClock.now() - apply(current, refreshRule = false, force = false, now = now) - BedtimeSchedule.dueReminder(current, now, zones.current())?.let { window -> - notifier.showReminder(window.start, current.reminderMinutes) + val zone = zones.current() + apply(current, refreshRule = false, force = false, now = now, zone = zone) + BedtimeSchedule.dueReminder(current, now, zone)?.let { window -> + // What is actually left: a late fire must not promise the full lead. + val minutesLeft = ceil((window.start - now) / 1.minutes).toInt().coerceAtLeast(1) + notifier.showReminder(window.start, minutesLeft) } } @@ -96,8 +108,8 @@ class BedtimeEngine @Inject constructor( refreshRule: Boolean, force: Boolean, now: Instant = wallClock.now(), + zone: ZoneId = zones.current(), ) { - val zone = zones.current() if (!current.enabled) { scheduler.cancel() notifier.cancelReminder() @@ -113,10 +125,13 @@ class BedtimeEngine @Inject constructor( // or the schedule changed under it) is stale. if (BedtimeSchedule.dueReminder(current, now, zone) == null) notifier.cancelReminder() if (mode.hasAccess()) { - mode.install(current, refresh = refreshRule) - if (force || active != settings.bedtimeAppliedActive()) { - mode.setActive(active) - settings.setBedtimeAppliedActive(active) + // A rule that was just (re)created starts off, whatever was last + // applied to its predecessor, so it is switched regardless. + val created = mode.install(current, refresh = refreshRule) + if (force || created || active != settings.bedtimeAppliedActive()) { + // Recorded only once the system took it, so a refused call is + // retried by the next pass rather than forgotten. + if (mode.setActive(active)) settings.setBedtimeAppliedActive(active) } } val next = BedtimeSchedule.nextEvent(current, now, zone) @@ -125,4 +140,7 @@ class BedtimeEngine @Inject constructor( /** What the system rule is built from; the reminder is not part of it. */ private fun BedtimeSettings.ruleFields() = listOf(enabled, bedtime, wakeTime, effects) + + /** What decides whether the mode should be on right now. */ + private fun BedtimeSettings.scheduleFields() = listOf(enabled, bedtime, wakeTime, days) } diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/BedtimeSeams.kt b/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/BedtimeSeams.kt index 8192ed5..673aa21 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/BedtimeSeams.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/BedtimeSeams.kt @@ -28,13 +28,14 @@ interface BedtimeMode { 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. + * Creates the rule when it is missing, and returns whether it did. [refresh] + * also rewrites an existing one from [settings]; otherwise an existing + * rule is left exactly as it is. */ - fun install(settings: BedtimeSettings, refresh: Boolean) + fun install(settings: BedtimeSettings, refresh: Boolean): Boolean - /** Turns the mode on or off. Does nothing when there is no rule. */ - fun setActive(active: Boolean) + /** Turns the mode on or off; false when there is no rule or the system refused. */ + fun setActive(active: Boolean): Boolean /** Removes the rule, which also ends it if it is on. */ fun uninstall() diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/android/AndroidBedtimeMode.kt b/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/android/AndroidBedtimeMode.kt index 0319d06..4ce2c30 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/android/AndroidBedtimeMode.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/android/AndroidBedtimeMode.kt @@ -60,8 +60,8 @@ class AndroidBedtimeMode @Inject constructor( override fun hasAccess(): Boolean = guarded(false) { manager.isNotificationPolicyAccessGranted } - override fun install(settings: BedtimeSettings, refresh: Boolean) { - guarded(Unit) { + override fun install(settings: BedtimeSettings, refresh: Boolean): Boolean = + guarded(false) { val ours = ownRules() // Only ever one. A second (two passes racing on another process, // say) is removed rather than left to fight the first. @@ -69,7 +69,9 @@ class AndroidBedtimeMode @Inject constructor( val existing = ours.entries.firstOrNull() if (existing == null) { manager.addAutomaticZenRule(rule(settings, enabled = true)) - } else if (refresh) { + return@guarded true + } + if (refresh) { // Keeps what the user set for the rule in Settings: its on/off // switch (a change here must not re-enable a rule they // disabled) and who may interrupt, which Android 10 to 14 @@ -79,12 +81,12 @@ class AndroidBedtimeMode @Inject constructor( rule(settings, enabled = existing.value.isEnabled, kept = existing.value.zenPolicy), ) } + false } - } - override fun setActive(active: Boolean) { - guarded(Unit) { - val id = ownRules().keys.firstOrNull() ?: return@guarded + override fun setActive(active: Boolean): Boolean = + guarded(false) { + val id = ownRules().keys.firstOrNull() ?: return@guarded false val state = if (active) Condition.STATE_TRUE else Condition.STATE_FALSE val summary = context.getString(R.string.bedtime_mode_name) val condition = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) { @@ -95,8 +97,8 @@ class AndroidBedtimeMode @Inject constructor( Condition(conditionId, summary, state) } manager.setAutomaticZenRuleState(id, condition) + true } - } override fun uninstall() { guarded(Unit) { diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/BedtimePrefs.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/BedtimePrefs.kt index 68810ef..9693dad 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/BedtimePrefs.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/BedtimePrefs.kt @@ -24,9 +24,16 @@ object BedtimePrefs { val wakeTime: Pref = intPref("bedtime_wake_minutes", DEFAULT.wakeTime.minutesOfDay).asTimeOfDay() - /** The 7-bit mask `RepeatDays` uses; a corrupt value cannot widen the set. */ + /** + * The 7-bit mask `RepeatDays` uses; a corrupt value cannot widen the set. + * The screen never lets the last night go, so an empty mask (corrupt, or + * written around it) reads as every night, as a backup's does. + */ val days: Pref = intPref("bedtime_days", DEFAULT.days.mask) - .map(decode = RepeatDays::fromMask, encode = { it.mask }) + .map( + decode = { mask -> RepeatDays.fromMask(mask).takeIf { it.isRepeating } ?: RepeatDays.EVERY_DAY }, + encode = { it.mask }, + ) val reminderMinutes: Pref = intPref("bedtime_reminder_minutes", DEFAULT.reminderMinutes) .map( diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/bedtime/BedtimeSchedule.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/bedtime/BedtimeSchedule.kt index 09e5b67..4517a4f 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/domain/bedtime/BedtimeSchedule.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/bedtime/BedtimeSchedule.kt @@ -23,11 +23,12 @@ 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. + * before the bedtime means the next morning. An equal bedtime and wake + * time is no window at all: read as a whole day, every night selected + * would join into a Do Not Disturb that never ends. */ fun windowOn(settings: BedtimeSettings, date: LocalDate, zone: ZoneId): BedtimeWindow? { - if (date.dayOfWeek !in settings.days) return null + if (date.dayOfWeek !in settings.days || settings.bedtime == settings.wakeTime) 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) diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/bedtime/BedtimeViewModel.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/bedtime/BedtimeViewModel.kt index a50eddc..4c0a011 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/bedtime/BedtimeViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/bedtime/BedtimeViewModel.kt @@ -7,6 +7,8 @@ import de.jeanlucmakiola.clockula.bedtime.BedtimeEngine import de.jeanlucmakiola.clockula.domain.TimeOfDay import de.jeanlucmakiola.clockula.domain.bedtime.BedtimeEffects import de.jeanlucmakiola.clockula.domain.bedtime.BedtimeSettings +import de.jeanlucmakiola.floret.di.ApplicationScope +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -29,6 +31,7 @@ import javax.inject.Inject @HiltViewModel class BedtimeViewModel @Inject internal constructor( private val engine: BedtimeEngine, + @param:ApplicationScope private val appScope: CoroutineScope, ) : ViewModel() { private val access = MutableStateFlow(engine.hasAccess()) @@ -47,7 +50,7 @@ class BedtimeViewModel @Inject internal constructor( val granted = engine.hasAccess() val changed = granted != access.value access.value = granted - viewModelScope.launch { + appScope.launch { if (changed) runCatching { engine.resync(refreshRule = true) } ruleId.value = engine.ruleId() } @@ -78,8 +81,13 @@ class BedtimeViewModel @Inject internal constructor( private fun updateEffects(transform: (BedtimeEffects) -> BedtimeEffects) = update { it.copy(effects = transform(it.effects)) } + /** + * On the application scope, like the editors' writes: leaving the screen + * right after a change must not cancel it between storing the settings + * and arming the slot or creating the rule. + */ private fun update(transform: (BedtimeSettings) -> BedtimeSettings) { - viewModelScope.launch { + appScope.launch { runCatching { engine.update(transform) } ruleId.value = engine.ruleId() } diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/shell/ShellNavigation.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/shell/ShellNavigation.kt index 13cce4b..c3d0209 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/shell/ShellNavigation.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/shell/ShellNavigation.kt @@ -150,16 +150,9 @@ object ShellNavigation { * missing or non-positive alarm id, or an empty id list, falls back to the * Alarms tab (M9 D23). */ - fun requestFor( - action: String?, - alarmId: Long?, - alarmIds: List, - forZenRule: Boolean = false, - ): ShellRequest? = + fun requestFor(action: String?, alarmId: Long?, alarmIds: List): ShellRequest? = when (action) { - // Settings opening the bedtime rule's configuration activity: an - // explicit component, a rule id extra, and no action (issue #1). - null -> if (forZenRule) ShellRequest.OpenBedtime else null + null -> null InteropIntents.ACTION_OPEN_ALARM_EDITOR -> if (alarmId != null && alarmId > 0L) { ShellRequest.OpenAlarmEditor(alarmId) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 06d57cb..ebcb347 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -469,7 +469,7 @@ Hide the always-on display Screen effects need Android 15 or later. On this phone, bedtime silences notifications and calls the way Do Not Disturb does. Open in system settings - Who can reach you, and more effects, under Settings › Modes. Changes you make there are kept. + Who can reach you, and more effects, under Settings › Modes. Changes you make there are kept while bedtime stays on. Bedtime never silences an alarm or a timer: its mode always lets alarms through. 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. Clockula bedtime diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/bedtime/BedtimeEngineTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/bedtime/BedtimeEngineTest.kt index aeba63b..2e9126e 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/bedtime/BedtimeEngineTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/bedtime/BedtimeEngineTest.kt @@ -205,6 +205,43 @@ class BedtimeEngineTest { assertThat(h.notifier.showing).isFalse() } + @Test + fun `a rule recreated during the night is switched on`(@TempDir tempDir: Path) = runTest { + val h = harness(tempDir, now = instantAt(BERLIN, "2026-06-10T23:30")) + h.engine.update(nightly) + // Deleted in the system's settings, then the app is launched. + h.mode.rule = null + h.mode.on = false + + h.engine.resync() + + assertThat(h.mode.rule).isNotNull() + assertThat(h.mode.on).isTrue() + } + + @Test + fun `a late reminder says how long is actually left`(@TempDir tempDir: Path) = runTest { + val h = harness(tempDir) + h.engine.update { nightly(it).copy(reminderMinutes = 60) } + + h.wallClock.instant = instantAt(BERLIN, "2026-06-10T22:41") + h.engine.onSlotFired() + + assertThat(h.notifier.reminders.single().second).isEqualTo(19) + } + + @Test + fun `an edit that cannot move a boundary leaves a hand-switched mode alone`(@TempDir tempDir: Path) = runTest { + val h = harness(tempDir, now = instantAt(BERLIN, "2026-06-10T23:30")) + h.engine.update(nightly) + // Switched off by hand for tonight. + h.mode.on = false + + h.engine.update { it.copy(reminderMinutes = 30) } + + assertThat(h.mode.on).isFalse() + } + @Test fun `changing only the reminder does not rewrite the rule`(@TempDir tempDir: Path) = runTest { val h = harness(tempDir) diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/domain/bedtime/BedtimeScheduleTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/domain/bedtime/BedtimeScheduleTest.kt index 9b48ad6..49fcc58 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/domain/bedtime/BedtimeScheduleTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/domain/bedtime/BedtimeScheduleTest.kt @@ -103,14 +103,11 @@ class BedtimeScheduleTest { } @Test - fun `an equal bedtime and wake time is a whole day, back to back`() { - val always = everyNight.copy(wakeTime = TimeOfDay(23, 0)) + fun `an equal bedtime and wake time is no window, never one that never ends`() { + val same = 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() + assertThat(BedtimeSchedule.activeWindow(same, instantAt(BERLIN, "2026-06-10T23:30"), BERLIN)).isNull() + assertThat(BedtimeSchedule.nextEvent(same, instantAt(BERLIN, "2026-06-10T12:00"), BERLIN)).isNull() } @Test diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeBedtimeSeams.kt b/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeBedtimeSeams.kt index 6cef44e..7114b22 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeBedtimeSeams.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeBedtimeSeams.kt @@ -37,19 +37,25 @@ class FakeBedtimeMode( override fun hasAccess(): Boolean = access - override fun install(settings: BedtimeSettings, refresh: Boolean) { - if (!access) return + override fun install(settings: BedtimeSettings, refresh: Boolean): Boolean { + if (!access) return false if (rule == null) { rule = settings - } else if (refresh) { + // A new rule starts off, as the platform's does. + on = false + return true + } + if (refresh) { rule = settings refreshes++ } + return false } - override fun setActive(active: Boolean) { - if (!access || rule == null) return + override fun setActive(active: Boolean): Boolean { + if (!access || rule == null) return false on = active + return true } override fun uninstall() { diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/ui/shell/ShellNavigationInteropTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/ui/shell/ShellNavigationInteropTest.kt index bc3ce5c..581051c 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/ui/shell/ShellNavigationInteropTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/ui/shell/ShellNavigationInteropTest.kt @@ -128,10 +128,11 @@ class ShellNavigationInteropTest { 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) - // Settings opens the rule's configuration activity with no action, - // only the component and the rule id. - assertThat(ShellNavigation.requestFor(null, null, emptyList(), forZenRule = true)) - .isEqualTo(ShellRequest.OpenBedtime) - assertThat(ShellNavigation.requestFor(null, null, emptyList())).isNull() + // MainActivity turns Settings' action-less, rule-id-carrying intent + // into the same action; the extras themselves are its business. + assertThat(BedtimeIntents.ZEN_RULE_EXTRAS).containsExactly( + "android.app.extra.AUTOMATIC_RULE_ID", + "android.service.notification.extra.RULE_ID", + ) } } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 10c3e25..40e18a1 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -2728,8 +2728,9 @@ A rule needs an owner `ConditionProviderService` or a configuration activity. Clockula uses `MainActivity` as the configuration activity: it is already exported, so no new component is exposed. Settings opens it with the explicit component, the rule id in `NotificationManager.EXTRA_AUTOMATIC_RULE_ID` / -`ConditionProviderService.EXTRA_RULE_ID`, and **no action**, so the presence of -either extra is what makes the shell open the bedtime screen +`ConditionProviderService.EXTRA_RULE_ID`, and **no action**, so `MainActivity` +turns an action-less intent carrying either extra into the reminder's own +`ACTION_SHOW_BEDTIME`, and the shell opens the bedtime screen (`ShellRequest.OpenBedtime`). There is no condition provider service: Clockula reports the rule's state itself with `setAutomaticZenRuleState`. @@ -2769,14 +2770,15 @@ engines (`ArchitectureRulesTest` holds it to no `android.*` import): 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 +the next morning; an equal one is no window at all, since a day-long window on +every night would be a Do Not Disturb that never ends), 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 if a field it carries changed, 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 | +| `update(transform)` | the bedtime screen, on the application scope so leaving the screen cannot cut a write short | stores the settings, rewrites the rule if a field it carries changed, and when the schedule itself changed sets the mode to what it says now (on at 23:30 for a 23:00 bedtime starts it at once; a reminder or effect edit leaves a hand-switched mode alone), arms the slot. Off removes the rule, which ends the mode, cancels the slot and clears the reminder | | `onSlotFired()` | `BedtimeReceiver` | follows any boundary crossed, posts a due reminder, arms the next event. A reminder fire alone crosses no boundary, so it leaves a mode switched on by hand alone | | `resync()` | `ClockulaApp.onCreate` (a force stop clears the slot), `SystemEventReceiver` (boot, app update, clock or zone change, exact-alarm permission) | creates the rule if it is missing, follows any boundary crossed, arms the slot | | `resync(refreshRule = true)` | Do Not Disturb access changing (the screen's `ON_RESUME` check and the broadcast), a backup restore | the same, but also rewrites the rule and re-asserts the mode's state | @@ -2785,12 +2787,16 @@ reminder and the next window's start. coexist with a schedule. The last state Clockula set is persisted (`bedtime_applied_active`, device state, not in the JSON backup), and a pass that is not an edit switches the mode only when the schedule now says -something different. A launch in the afternoon leaves a mode the user turned +something different, or when the rule was just recreated (a new rule starts +off). The state is recorded only once the system accepted it, so a refused +call is retried by the next pass. A launch in the afternoon leaves a mode the user turned on alone; a launch after a force stop that slept through the wake-up time turns it off. Each pass reads the clock **once**, so the mode's state and the next slot always agree about which side of a boundary "now" is on. A posted reminder is withdrawn by any pass once no coming bedtime is due within its -lead, so moving the schedule away from it does not leave a stale one. +lead, so moving the schedule away from it does not leave a stale one, and its +title counts the minutes actually left, so a late fire does not promise the +full lead. Every bedtime call on a shared path runs **after** the alarm, timer and stopwatch passes and inside `runCatching` (in `SystemEventReceiver`, in its From 1a443c066fc5ca810e779f2a8d3bcf4f2f7a9e4c Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Thu, 22 Oct 2026 11:53:39 +0200 Subject: [PATCH 04/10] fix(bedtime): DST-safe windows, ordered edits, quieter passes Whether the wake time is the same night or the next morning is now decided on the local times, so a bedtime in a spring-forward gap no longer turns a 45-minute window into a day-long one. Screen edits start undispatched so they reach the engine's lock in the order they were made, and an edit that rewrites the rule also re-asserts the mode, in case the platform reset its state. The launch-time bedtime pass runs even if an earlier engine pass failed, a restore that never applied settings no longer forces the mode, and a user who never turned bedtime on pays nothing for it at launch or boot. The screen says when equal bedtime and wake-up times mean it never starts. Refs #1 --- .../de/jeanlucmakiola/clockula/ClockulaApp.kt | 42 +++++++++++-------- .../clockula/backup/BackupEngine.kt | 4 +- .../clockula/bedtime/BedtimeEngine.kt | 16 ++++--- .../domain/bedtime/BedtimeSchedule.kt | 9 ++-- .../clockula/ui/bedtime/BedtimeScreen.kt | 13 ++++-- .../clockula/ui/bedtime/BedtimeViewModel.kt | 5 ++- app/src/main/res/values/strings.xml | 1 + .../clockula/bedtime/BedtimeEngineTest.kt | 12 ++++++ .../domain/bedtime/BedtimeScheduleTest.kt | 11 +++++ docs/ARCHITECTURE.md | 10 +++-- docs/PLAN.md | 2 +- 11 files changed, 87 insertions(+), 38 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ClockulaApp.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ClockulaApp.kt index 1cb639a..b816a6e 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ClockulaApp.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ClockulaApp.kt @@ -73,27 +73,35 @@ class ClockulaApp : Application() { // a cold start caused by the fire broadcast must not interleave its // read-modify-write over `alarm_states` with the fire's own. scope.launch { - engine.onBootCompleted() - // After the alarm engine's pass, and idempotent within a boot: the - // boot-id gate makes the second repair a no-op, so neither engine - // depends on the other's ordering (M6 D21). Process death costs the - // timers nothing — the anchors and the AlarmManager slot survive it — - // but the service may not have, so this brings it back. - timerEngine.onBootCompleted() - // And the third, after the other two. Process death costs the - // stopwatch nothing — the run is in DataStore and the laps in Room — - // but the service may not have survived it, so this brings it back; - // and if this is a new boot the gate above has already repaired the - // run, leaving it paused at what it banked (M7 D17). - stopwatchEngine.onBootCompleted() - // Last and guarded, like every bedtime call on a shared path: a - // force stop clears its AlarmManager slot too, and may have slept - // through a bedtime or wake-up, so both are caught up here. - runCatching { bedtimeEngine.resync() } + try { + launchPasses() + } finally { + // Last and guarded, like every bedtime call on a shared path, + // and in a finally so a failure above cannot cost it its pass: + // a force stop clears its AlarmManager slot too, and may have + // slept through a bedtime or wake-up, so both are caught up here. + runCatching { bedtimeEngine.resync() } + } } // Never completes on its own — it is the widgets' one refresh trigger // for as long as the process lives (M10, docs/PLAN.md §9). scope.launch { widgetSync.run() } } + + private suspend fun launchPasses() { + engine.onBootCompleted() + // After the alarm engine's pass, and idempotent within a boot: the + // boot-id gate makes the second repair a no-op, so neither engine + // depends on the other's ordering (M6 D21). Process death costs the + // timers nothing — the anchors and the AlarmManager slot survive it — + // but the service may not have, so this brings it back. + timerEngine.onBootCompleted() + // And the third, after the other two. Process death costs the + // stopwatch nothing — the run is in DataStore and the laps in Room — + // but the service may not have survived it, so this brings it back; + // and if this is a new boot the gate above has already repaired the + // run, leaving it paused at what it banked (M7 D17). + stopwatchEngine.onBootCompleted() + } } diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/backup/BackupEngine.kt b/app/src/main/java/de/jeanlucmakiola/clockula/backup/BackupEngine.kt index 1d167a5..327d812 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/backup/BackupEngine.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/backup/BackupEngine.kt @@ -98,8 +98,8 @@ class BackupEngine @Inject constructor( // 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) } + // not a failed import. Nothing to follow if they never changed. + if (settingsApplied) runCatching { bedtimeEngine.resync(refreshRule = true) } } } failure?.let { throw it } diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/BedtimeEngine.kt b/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/BedtimeEngine.kt index 22a0d88..6acd839 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/BedtimeEngine.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/BedtimeEngine.kt @@ -46,19 +46,20 @@ class BedtimeEngine @Inject constructor( /** * A change made on the bedtime screen: stored, then applied at once. The * rule is rewritten when a field it carries changed. When the schedule - * itself changed, the mode is switched to what it says now, so turning - * bedtime on at 23:30 for a 23:00 bedtime starts it straight away; an - * edit that cannot move a boundary (the reminder, an effect) leaves a - * mode switched by hand alone. + * or the rule changed, the mode is switched to what it says now, so + * turning bedtime on at 23:30 for a 23:00 bedtime starts it straight away + * (and a rewritten rule cannot be left off if the platform reset its + * state); a reminder edit leaves a mode switched by hand alone. */ suspend fun update(transform: (BedtimeSettings) -> BedtimeSettings) = lock.withLock { val previous = settings.currentBedtime() val updated = transform(previous) settings.setBedtime(updated) + val refreshRule = previous.ruleFields() != updated.ruleFields() apply( updated, - refreshRule = previous.ruleFields() != updated.ruleFields(), - force = previous.scheduleFields() != updated.scheduleFields(), + refreshRule = refreshRule, + force = refreshRule || previous.scheduleFields() != updated.scheduleFields(), ) } @@ -111,6 +112,9 @@ class BedtimeEngine @Inject constructor( zone: ZoneId = zones.current(), ) { if (!current.enabled) { + // Already off and cleaned up: nothing to do, which keeps every + // launch and boot of a user who never turned bedtime on cheap. + if (!force && !refreshRule && !settings.bedtimeAppliedActive()) return scheduler.cancel() notifier.cancelReminder() // Removing the rule ends the mode if it is on. Without access there diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/bedtime/BedtimeSchedule.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/bedtime/BedtimeSchedule.kt index 4517a4f..e14adbd 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/domain/bedtime/BedtimeSchedule.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/bedtime/BedtimeSchedule.kt @@ -29,10 +29,13 @@ object BedtimeSchedule { */ fun windowOn(settings: BedtimeSettings, date: LocalDate, zone: ZoneId): BedtimeWindow? { if (date.dayOfWeek !in settings.days || settings.bedtime == settings.wakeTime) return null + // Next morning or same day is decided on the local times, before DST + // resolution: a bedtime in a spring-forward gap shifts later and must + // not push a same-night wake time to the following day. + val endDate = if (settings.wakeTime.minutesOfDay > settings.bedtime.minutesOfDay) date else date.plusDays(1) 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) + val end = AlarmOccurrences.at(endDate, settings.wakeTime, zone) + return if (end > start) BedtimeWindow(start, end) else null } /** The window [now] falls in, or null when it is daytime or the schedule is off. */ diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/bedtime/BedtimeScreen.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/bedtime/BedtimeScreen.kt index 9102513..ca32b6a 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/bedtime/BedtimeScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/bedtime/BedtimeScreen.kt @@ -88,10 +88,15 @@ fun BedtimeScreen( 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) + summary = when { + !settings.enabled -> stringResource(R.string.bedtime_enabled_off) + // No window at all (BedtimeSchedule): say so rather than "On". + settings.bedtime == settings.wakeTime -> stringResource(R.string.bedtime_enabled_same_time) + else -> stringResource( + R.string.bedtime_enabled_on, + formatTime(settings.bedtime), + formatTime(settings.wakeTime), + ) }, position = Position.Alone, trailing = { diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/bedtime/BedtimeViewModel.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/bedtime/BedtimeViewModel.kt index 4c0a011..afd1a83 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/bedtime/BedtimeViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/bedtime/BedtimeViewModel.kt @@ -9,6 +9,7 @@ import de.jeanlucmakiola.clockula.domain.bedtime.BedtimeEffects import de.jeanlucmakiola.clockula.domain.bedtime.BedtimeSettings import de.jeanlucmakiola.floret.di.ApplicationScope import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -87,7 +88,9 @@ class BedtimeViewModel @Inject internal constructor( * and arming the slot or creating the rule. */ private fun update(transform: (BedtimeSettings) -> BedtimeSettings) { - appScope.launch { + // Undispatched, so each change queues on the engine's (fair) lock in + // the order the user made it: two quick taps cannot land reversed. + appScope.launch(start = CoroutineStart.UNDISPATCHED) { runCatching { engine.update(transform) } ruleId.value = engine.ruleId() } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ebcb347..9887ccd 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -448,6 +448,7 @@ Bedtime mode On, %1$s to %2$s Off + Bedtime and wake-up are the same time, so it never starts Allow Do Not Disturb access 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. Allow diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/bedtime/BedtimeEngineTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/bedtime/BedtimeEngineTest.kt index 2e9126e..28ccf88 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/bedtime/BedtimeEngineTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/bedtime/BedtimeEngineTest.kt @@ -242,6 +242,18 @@ class BedtimeEngineTest { assertThat(h.mode.on).isFalse() } + @Test + fun `an effect edit at night re-asserts the mode`(@TempDir tempDir: Path) = runTest { + val h = harness(tempDir, now = instantAt(BERLIN, "2026-06-10T23:30")) + h.engine.update(nightly) + // As if rewriting the rule reset its state. + h.mode.on = false + + h.engine.update { it.copy(effects = it.effects.copy(grayscale = false)) } + + assertThat(h.mode.on).isTrue() + } + @Test fun `changing only the reminder does not rewrite the rule`(@TempDir tempDir: Path) = runTest { val h = harness(tempDir) diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/domain/bedtime/BedtimeScheduleTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/domain/bedtime/BedtimeScheduleTest.kt index 49fcc58..04d53ca 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/domain/bedtime/BedtimeScheduleTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/domain/bedtime/BedtimeScheduleTest.kt @@ -9,6 +9,7 @@ import de.jeanlucmakiola.clockula.testing.offsetInstant import org.junit.jupiter.api.Test import java.time.DayOfWeek import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.minutes /** 2026-06-10 is a Wednesday. */ class BedtimeScheduleTest { @@ -119,6 +120,16 @@ class BedtimeScheduleTest { assertThat(window.end).isEqualTo(offsetInstant("2026-10-25T07:00+01:00")) } + @Test + fun `a bedtime in a spring-forward gap keeps its same-night wake time`() { + // Berlin springs forward on 2026-03-29: 02:30 does not exist. + val nightShift = everyNight.copy(bedtime = TimeOfDay(2, 30), wakeTime = TimeOfDay(3, 15)) + + assertThat(BedtimeSchedule.windowOn(nightShift, java.time.LocalDate.of(2026, 3, 29), BERLIN)).isNull() + val ordinary = BedtimeSchedule.windowOn(nightShift, java.time.LocalDate.of(2026, 3, 30), BERLIN)!! + assertThat(ordinary.end - ordinary.start).isEqualTo(45.minutes) + } + @Test fun `no nights means nothing to schedule`() { val none = everyNight.copy(days = RepeatDays.NONE) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 40e18a1..d566922 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -2770,18 +2770,20 @@ engines (`ArchitectureRulesTest` holds it to no `android.*` import): 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 is no window at all, since a day-long window on -every night would be a Do Not Disturb that never ends), resolved from local times and the +the next morning, decided on the local times so a bedtime in a spring-forward +gap cannot push a same-night wake time a day later; an equal one is no window +at all, since a day-long window on every night would be a Do Not Disturb that +never ends, and the screen says so), resolved from local times and the zone like an alarm, so a DST night is as long as the wall clock says. The slot is armed for the earliest of the current window's end, the next window's reminder and the next window's start. | Entry point | Called from | What it does | |---|---|---| -| `update(transform)` | the bedtime screen, on the application scope so leaving the screen cannot cut a write short | stores the settings, rewrites the rule if a field it carries changed, and when the schedule itself changed sets the mode to what it says now (on at 23:30 for a 23:00 bedtime starts it at once; a reminder or effect edit leaves a hand-switched mode alone), arms the slot. Off removes the rule, which ends the mode, cancels the slot and clears the reminder | +| `update(transform)` | the bedtime screen, on the application scope so leaving the screen cannot cut a write short | stores the settings, rewrites the rule if a field it carries changed, and when the schedule or the rule changed sets the mode to what it says now (on at 23:30 for a 23:00 bedtime starts it at once; a reminder edit leaves a hand-switched mode alone), arms the slot. The screen's writes start undispatched, so they queue on the engine's fair lock in the order they were made. Off removes the rule, which ends the mode, cancels the slot and clears the reminder | | `onSlotFired()` | `BedtimeReceiver` | follows any boundary crossed, posts a due reminder, arms the next event. A reminder fire alone crosses no boundary, so it leaves a mode switched on by hand alone | | `resync()` | `ClockulaApp.onCreate` (a force stop clears the slot), `SystemEventReceiver` (boot, app update, clock or zone change, exact-alarm permission) | creates the rule if it is missing, follows any boundary crossed, arms the slot | -| `resync(refreshRule = true)` | Do Not Disturb access changing (the screen's `ON_RESUME` check and the broadcast), a backup restore | the same, but also rewrites the rule and re-asserts the mode's state | +| `resync(refreshRule = true)` | Do Not Disturb access changing (the screen's `ON_RESUME` check and the broadcast), a backup restore | the same, but also rewrites the rule and re-asserts the mode's state. A restore re-runs it only if it got as far as applying settings | "Follows any boundary crossed" is the one rule that lets a hand-switched mode coexist with a schedule. The last state Clockula set is persisted diff --git a/docs/PLAN.md b/docs/PLAN.md index 25015e9..a8dcc8c 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -164,7 +164,7 @@ generic advice: exact-alarm permission, notification permission, full-screen-int permission, battery-optimisation exemption, **whether DND currently blocks alarms** (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 +touches DND is the opt-in bedtime mode (issue #1), which needs the user to grant `ACCESS_NOTIFICATION_POLICY` by hand and always lets alarms through; see `ARCHITECTURE.md` §18), whether the OEM has Clockula under an aggressive app-killer, and the next scheduled fire time as the system sees it (`getNextAlarmClock()`), compared From b701859156175f39279f2ed8b3686c65bff4fb76 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Thu, 22 Oct 2026 12:02:39 +0200 Subject: [PATCH 05/10] fix(bedtime): name nights by their evening, retry a refused removal A bedtime before noon now belongs to the evening before, so Sunday to Thursday nights at 00:30 run early Monday to early Friday, not Saturday night through Wednesday night. Turning bedtime off clears the "last applied" state only once the rule is really gone, so a refused removal is retried instead of leaving Do Not Disturb on. Reopening the screen after granting access elsewhere creates the rule even if the system's broadcast never arrived. The empty-mask rule lives in one place. Refs #1 --- .../clockula/bedtime/BedtimeEngine.kt | 6 ++-- .../clockula/bedtime/BedtimeSeams.kt | 4 +-- .../bedtime/android/AndroidBedtimeMode.kt | 6 ++-- .../clockula/data/backup/BackupCodec.kt | 3 +- .../clockula/data/prefs/BedtimePrefs.kt | 2 +- .../domain/bedtime/BedtimeSchedule.kt | 23 ++++++++----- .../domain/bedtime/BedtimeSettings.kt | 7 ++++ .../clockula/ui/bedtime/BedtimeViewModel.kt | 6 +++- app/src/main/res/values/strings.xml | 2 +- .../domain/bedtime/BedtimeScheduleTest.kt | 32 +++++++++++++++++-- .../clockula/testing/FakeBedtimeSeams.kt | 5 +-- docs/ARCHITECTURE.md | 11 +++++-- docs/BACKUP.md | 2 +- 13 files changed, 80 insertions(+), 29 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/BedtimeEngine.kt b/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/BedtimeEngine.kt index 6acd839..a20c589 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/BedtimeEngine.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/BedtimeEngine.kt @@ -120,8 +120,10 @@ class BedtimeEngine @Inject constructor( // Removing the rule ends the mode if it is on. Without access there // is nothing Clockula may remove; revoking access in Settings // already removed the rule. - if (mode.hasAccess()) mode.uninstall() - settings.setBedtimeAppliedActive(false) + // Recorded only once the rule is really gone, so a refused removal + // is retried by the next pass instead of leaving the mode on. + val removed = !mode.hasAccess() || mode.uninstall() + if (removed) settings.setBedtimeAppliedActive(false) return } val active = BedtimeSchedule.activeWindow(current, now, zone) != null diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/BedtimeSeams.kt b/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/BedtimeSeams.kt index 673aa21..ed3187b 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/BedtimeSeams.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/BedtimeSeams.kt @@ -37,8 +37,8 @@ interface BedtimeMode { /** Turns the mode on or off; false when there is no rule or the system refused. */ fun setActive(active: Boolean): Boolean - /** Removes the rule, which also ends it if it is on. */ - fun uninstall() + /** Removes the rule, which also ends it if it is on; false when the system refused. */ + fun uninstall(): Boolean /** The rule's id, for a deep link into the system's settings; null when there is none. */ fun ruleId(): String? diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/android/AndroidBedtimeMode.kt b/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/android/AndroidBedtimeMode.kt index 4ce2c30..3d42c39 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/android/AndroidBedtimeMode.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/android/AndroidBedtimeMode.kt @@ -100,11 +100,11 @@ class AndroidBedtimeMode @Inject constructor( true } - override fun uninstall() { - guarded(Unit) { + override fun uninstall(): Boolean = + guarded(false) { ownRules().keys.forEach(manager::removeAutomaticZenRule) + true } - } override fun ruleId(): String? = guarded(null) { ownRules().keys.firstOrNull() } diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupCodec.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupCodec.kt index e82852c..025bc8c 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupCodec.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupCodec.kt @@ -154,8 +154,7 @@ object BackupCodec { enabled = enabled, bedtime = minutesToTime(bedtimeMinutes), wakeTime = minutesToTime(wakeMinutes), - // The screen never lets the last night go; a file with none gets all. - days = RepeatDays.fromMask(days).takeIf { it.isRepeating } ?: RepeatDays.EVERY_DAY, + days = BedtimeLimits.nights(days), reminderMinutes = reminderMinutes.coerceIn(BedtimeLimits.REMINDER_MINUTES), effects = BedtimeEffects( grayscale = grayscale, diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/BedtimePrefs.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/BedtimePrefs.kt index 9693dad..0f88a1f 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/BedtimePrefs.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/BedtimePrefs.kt @@ -31,7 +31,7 @@ object BedtimePrefs { */ val days: Pref = intPref("bedtime_days", DEFAULT.days.mask) .map( - decode = { mask -> RepeatDays.fromMask(mask).takeIf { it.isRepeating } ?: RepeatDays.EVERY_DAY }, + decode = BedtimeLimits::nights, encode = { it.mask }, ) diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/bedtime/BedtimeSchedule.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/bedtime/BedtimeSchedule.kt index e14adbd..3cf1caa 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/domain/bedtime/BedtimeSchedule.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/bedtime/BedtimeSchedule.kt @@ -21,8 +21,10 @@ data class BedtimeWindow(val start: Instant, val end: Instant) { 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 + * The window for the night of [date], if [date] is one of the schedule's + * nights. A night is named by its evening, as people name it: a bedtime + * before noon (00:30, say) is the small hours *after* that evening, so + * "Thursday night" at 00:30 starts on Friday's calendar date. It ends at the first wake time after it starts, so a wake time * before the bedtime means the next morning. An equal bedtime and wake * time is no window at all: read as a whole day, every night selected * would join into a Do Not Disturb that never ends. @@ -32,8 +34,10 @@ object BedtimeSchedule { // Next morning or same day is decided on the local times, before DST // resolution: a bedtime in a spring-forward gap shifts later and must // not push a same-night wake time to the following day. - val endDate = if (settings.wakeTime.minutesOfDay > settings.bedtime.minutesOfDay) date else date.plusDays(1) - val start = AlarmOccurrences.at(date, settings.bedtime, zone) + val startDate = if (settings.bedtime.minutesOfDay < NOON) date.plusDays(1) else date + val endDate = + if (settings.wakeTime.minutesOfDay > settings.bedtime.minutesOfDay) startDate else startDate.plusDays(1) + val start = AlarmOccurrences.at(startDate, settings.bedtime, zone) val end = AlarmOccurrences.at(endDate, settings.wakeTime, zone) return if (end > start) BedtimeWindow(start, end) else null } @@ -42,9 +46,10 @@ object BedtimeSchedule { 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)) + // A window is at most a day long and starts on its night's date or + // the day after, so one that contains now belongs to one of the last + // three nights. + return listOf(today, today.minusDays(1), today.minusDays(2)) .firstNotNullOfOrNull { date -> windowOn(settings, date, zone)?.takeIf { now in it } } } @@ -52,7 +57,7 @@ object BedtimeSchedule { fun nextWindow(settings: BedtimeSettings, now: Instant, zone: ZoneId): BedtimeWindow? { if (!settings.enabled) return null val today = today(now, zone) - return (0L..7L) + return (-1L..7L) .firstNotNullOfOrNull { offset -> windowOn(settings, today.plusDays(offset), zone)?.takeIf { it.start > now } } @@ -89,5 +94,7 @@ object BedtimeSchedule { private fun reminderAt(settings: BedtimeSettings, window: BedtimeWindow): Instant = window.start - settings.reminderMinutes.minutes + private const val NOON = 12 * 60 + private fun today(now: Instant, zone: ZoneId): LocalDate = now.toJavaInstant().atZone(zone).toLocalDate() } diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/bedtime/BedtimeSettings.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/bedtime/BedtimeSettings.kt index f8b0e82..e6f27e3 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/domain/bedtime/BedtimeSettings.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/bedtime/BedtimeSettings.kt @@ -36,4 +36,11 @@ object BedtimeLimits { /** Anything read from disk or a backup is clamped into this. */ val REMINDER_MINUTES: IntRange = 0..120 + + /** + * The nights from a stored mask. The screen never lets the last night go, + * so an empty mask (corrupt, or written around it) reads as every night, + * on disk and in a backup alike. + */ + fun nights(mask: Int): RepeatDays = RepeatDays.fromMask(mask).takeIf { it.isRepeating } ?: RepeatDays.EVERY_DAY } diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/bedtime/BedtimeViewModel.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/bedtime/BedtimeViewModel.kt index afd1a83..6a98974 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/bedtime/BedtimeViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/bedtime/BedtimeViewModel.kt @@ -52,7 +52,11 @@ class BedtimeViewModel @Inject internal constructor( val changed = granted != access.value access.value = granted appScope.launch { - if (changed) runCatching { engine.resync(refreshRule = true) } + // Also when access was granted while this screen was closed and + // the system's broadcast did not reach the app: no rule yet. + if (changed || (granted && engine.ruleId() == null)) { + runCatching { engine.resync(refreshRule = true) } + } ruleId.value = engine.ruleId() } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9887ccd..1cff9f1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -455,7 +455,7 @@ Schedule Bedtime Wake up - The nights bedtime starts on. A wake-up time before the bedtime is the next morning. + The nights bedtime is for, named by their evening: a bedtime after midnight on Thursday night falls early on Friday. A wake-up time before the bedtime is the next morning. Reminder Remind me before bedtime Off diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/domain/bedtime/BedtimeScheduleTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/domain/bedtime/BedtimeScheduleTest.kt index 04d53ca..0846dad 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/domain/bedtime/BedtimeScheduleTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/domain/bedtime/BedtimeScheduleTest.kt @@ -122,14 +122,40 @@ class BedtimeScheduleTest { @Test fun `a bedtime in a spring-forward gap keeps its same-night wake time`() { - // Berlin springs forward on 2026-03-29: 02:30 does not exist. + // Berlin springs forward on 2026-03-29: 02:30 does not exist. That is + // the small hours of Saturday 28's night. val nightShift = everyNight.copy(bedtime = TimeOfDay(2, 30), wakeTime = TimeOfDay(3, 15)) - assertThat(BedtimeSchedule.windowOn(nightShift, java.time.LocalDate.of(2026, 3, 29), BERLIN)).isNull() - val ordinary = BedtimeSchedule.windowOn(nightShift, java.time.LocalDate.of(2026, 3, 30), BERLIN)!! + assertThat(BedtimeSchedule.windowOn(nightShift, java.time.LocalDate.of(2026, 3, 28), BERLIN)).isNull() + val ordinary = BedtimeSchedule.windowOn(nightShift, java.time.LocalDate.of(2026, 3, 29), BERLIN)!! assertThat(ordinary.end - ordinary.start).isEqualTo(45.minutes) } + @Test + fun `a bedtime after midnight belongs to the evening before`() { + // Sunday to Thursday nights, 00:30 to 07:00. 2026-06-12 is a Friday. + val schoolNights = everyNight.copy( + bedtime = TimeOfDay(0, 30), + days = RepeatDays.of( + DayOfWeek.SUNDAY, + DayOfWeek.MONDAY, + DayOfWeek.TUESDAY, + DayOfWeek.WEDNESDAY, + DayOfWeek.THURSDAY, + ), + ) + + // Thursday night: Friday 00:30. + assertThat(BedtimeSchedule.activeWindow(schoolNights, instantAt(BERLIN, "2026-06-12T01:00"), BERLIN)) + .isNotNull() + // Friday night: Saturday 00:30, not one. + assertThat(BedtimeSchedule.activeWindow(schoolNights, instantAt(BERLIN, "2026-06-13T01:00"), BERLIN)) + .isNull() + // From Friday afternoon, the next is Sunday night: Monday 00:30. + assertThat(BedtimeSchedule.nextWindow(schoolNights, instantAt(BERLIN, "2026-06-12T15:00"), BERLIN)?.start) + .isEqualTo(offsetInstant("2026-06-15T00:30+02:00")) + } + @Test fun `no nights means nothing to schedule`() { val none = everyNight.copy(days = RepeatDays.NONE) diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeBedtimeSeams.kt b/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeBedtimeSeams.kt index 7114b22..4858ce9 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeBedtimeSeams.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeBedtimeSeams.kt @@ -58,10 +58,11 @@ class FakeBedtimeMode( return true } - override fun uninstall() { - if (!access) return + override fun uninstall(): Boolean { + if (!access) return false rule = null on = false + return true } override fun ruleId(): String? = if (access && rule != null) "rule" else null diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index d566922..fae1276 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -2769,7 +2769,9 @@ engines (`ArchitectureRulesTest` holds it to no `android.*` import): 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 +one of the chosen nights (named by its evening, so a bedtime before noon such as +00:30 falls on the next calendar day: "Thursday night" at 00:30 is early +Friday) to the first wake time after it (an earlier wake time is the next morning, decided on the local times so a bedtime in a spring-forward gap cannot push a same-night wake time a day later; an equal one is no window at all, since a day-long window on every night would be a Do Not Disturb that @@ -2821,13 +2823,16 @@ 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 +once (also when access is there but no rule exists yet, for a grant made while +the screen was closed), so coming back from granting it creates the mode 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. +Turning bedtime off removes the rule cleanly; the "last applied" state is +cleared only once the removal succeeded, so a refused removal is retried by the +next pass rather than leaving the mode on. ### Backup diff --git a/docs/BACKUP.md b/docs/BACKUP.md index 6a26075..f212994 100644 --- a/docs/BACKUP.md +++ b/docs/BACKUP.md @@ -153,7 +153,7 @@ device or after a reboot (`docs/PLAN.md` §5). Every imported timer lands `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 +`repeat_days` (the nights bedtime is for, named by their evening), and `reminder_minutes` 0 means no reminder. A file without `bedtime` (any file written before it existed) 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 From 8b182c999b7d978a663f2aac92daff9ad01a10f3 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Thu, 22 Oct 2026 12:11:04 +0200 Subject: [PATCH 06/10] fix(bedtime): small hours end at 06:00, retry daytime removals Only a bedtime before 06:00 now counts as the small hours after the chosen evening, so a day sleeper's 08:00 bedtime runs on the days picked. A rule whose removal was refused while turning bedtime off in the daytime is removed by the next pass, and a restored reminder length the picker doesn't offer stays choosable. Refs #1 --- .../clockula/bedtime/BedtimeEngine.kt | 6 +++++- .../clockula/domain/bedtime/BedtimeSchedule.kt | 9 +++++---- .../clockula/ui/bedtime/BedtimeScreen.kt | 3 ++- app/src/main/res/values/strings.xml | 2 +- .../clockula/bedtime/BedtimeEngineTest.kt | 14 ++++++++++++++ .../clockula/domain/bedtime/BedtimeScheduleTest.kt | 13 +++++++++++++ .../clockula/testing/FakeBedtimeSeams.kt | 5 ++++- docs/ARCHITECTURE.md | 9 +++++---- 8 files changed, 49 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/BedtimeEngine.kt b/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/BedtimeEngine.kt index a20c589..496fdd0 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/BedtimeEngine.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/BedtimeEngine.kt @@ -114,7 +114,11 @@ class BedtimeEngine @Inject constructor( if (!current.enabled) { // Already off and cleaned up: nothing to do, which keeps every // launch and boot of a user who never turned bedtime on cheap. - if (!force && !refreshRule && !settings.bedtimeAppliedActive()) return + if (!force && !refreshRule && !settings.bedtimeAppliedActive() && + (!mode.hasAccess() || mode.ruleId() == null) + ) { + return + } scheduler.cancel() notifier.cancelReminder() // Removing the rule ends the mode if it is on. Without access there diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/bedtime/BedtimeSchedule.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/bedtime/BedtimeSchedule.kt index 3cf1caa..fe9c2fa 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/domain/bedtime/BedtimeSchedule.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/bedtime/BedtimeSchedule.kt @@ -23,8 +23,9 @@ object BedtimeSchedule { /** * The window for the night of [date], if [date] is one of the schedule's * nights. A night is named by its evening, as people name it: a bedtime - * before noon (00:30, say) is the small hours *after* that evening, so - * "Thursday night" at 00:30 starts on Friday's calendar date. It ends at the first wake time after it starts, so a wake time + * in the small hours (before 06:00; 00:30, say) is *after* that evening, + * so "Thursday night" at 00:30 starts on Friday's calendar date. A later + * one (a day sleeper's 08:00) is on the day itself. It ends at the first wake time after it starts, so a wake time * before the bedtime means the next morning. An equal bedtime and wake * time is no window at all: read as a whole day, every night selected * would join into a Do Not Disturb that never ends. @@ -34,7 +35,7 @@ object BedtimeSchedule { // Next morning or same day is decided on the local times, before DST // resolution: a bedtime in a spring-forward gap shifts later and must // not push a same-night wake time to the following day. - val startDate = if (settings.bedtime.minutesOfDay < NOON) date.plusDays(1) else date + val startDate = if (settings.bedtime.minutesOfDay < SMALL_HOURS_END) date.plusDays(1) else date val endDate = if (settings.wakeTime.minutesOfDay > settings.bedtime.minutesOfDay) startDate else startDate.plusDays(1) val start = AlarmOccurrences.at(startDate, settings.bedtime, zone) @@ -94,7 +95,7 @@ object BedtimeSchedule { private fun reminderAt(settings: BedtimeSettings, window: BedtimeWindow): Instant = window.start - settings.reminderMinutes.minutes - private const val NOON = 12 * 60 + private const val SMALL_HOURS_END = 6 * 60 private fun today(now: Instant, zone: ZoneId): LocalDate = now.toJavaInstant().atZone(zone).toLocalDate() } diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/bedtime/BedtimeScreen.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/bedtime/BedtimeScreen.kt index ca32b6a..7547eee 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/bedtime/BedtimeScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/bedtime/BedtimeScreen.kt @@ -225,7 +225,8 @@ fun BedtimeScreen( ) BedtimePicker.REMINDER -> OptionPicker( title = stringResource(R.string.bedtime_reminder), - options = BedtimeLimits.REMINDER_CHOICES, + // A restored value the picker does not offer stays choosable. + options = (BedtimeLimits.REMINDER_CHOICES + settings.reminderMinutes).distinct().sorted(), selected = settings.reminderMinutes, label = { reminderText(it) }, onSelect = { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1cff9f1..08e9ead 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -455,7 +455,7 @@ Schedule Bedtime Wake up - The nights bedtime is for, named by their evening: a bedtime after midnight on Thursday night falls early on Friday. A wake-up time before the bedtime is the next morning. + The nights bedtime is for, named by their evening: a bedtime between midnight and 6:00 on Thursday night falls early on Friday. A wake-up time before the bedtime is the next morning. Reminder Remind me before bedtime Off diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/bedtime/BedtimeEngineTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/bedtime/BedtimeEngineTest.kt index 28ccf88..16068d1 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/bedtime/BedtimeEngineTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/bedtime/BedtimeEngineTest.kt @@ -254,6 +254,20 @@ class BedtimeEngineTest { assertThat(h.mode.on).isTrue() } + @Test + fun `a removal refused in the daytime is retried by the next pass`(@TempDir tempDir: Path) = runTest { + val h = harness(tempDir) + h.engine.update(nightly) + h.mode.refuseRemoval = true + h.engine.update { it.copy(enabled = false) } + assertThat(h.mode.rule).isNotNull() + + h.mode.refuseRemoval = false + h.engine.resync() + + assertThat(h.mode.rule).isNull() + } + @Test fun `changing only the reminder does not rewrite the rule`(@TempDir tempDir: Path) = runTest { val h = harness(tempDir) diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/domain/bedtime/BedtimeScheduleTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/domain/bedtime/BedtimeScheduleTest.kt index 0846dad..fe31724 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/domain/bedtime/BedtimeScheduleTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/domain/bedtime/BedtimeScheduleTest.kt @@ -156,6 +156,19 @@ class BedtimeScheduleTest { .isEqualTo(offsetInstant("2026-06-15T00:30+02:00")) } + @Test + fun `a day sleeper's morning bedtime stays on the day picked`() { + val daySleep = everyNight.copy( + bedtime = TimeOfDay(8, 0), + wakeTime = TimeOfDay(16, 0), + days = RepeatDays.of(DayOfWeek.MONDAY), + ) + + // 2026-06-15 is a Monday. + assertThat(BedtimeSchedule.activeWindow(daySleep, instantAt(BERLIN, "2026-06-15T09:00"), BERLIN)) + .isNotNull() + } + @Test fun `no nights means nothing to schedule`() { val none = everyNight.copy(days = RepeatDays.NONE) diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeBedtimeSeams.kt b/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeBedtimeSeams.kt index 4858ce9..d84d529 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeBedtimeSeams.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeBedtimeSeams.kt @@ -58,8 +58,11 @@ class FakeBedtimeMode( return true } + /** The system refusing `removeAutomaticZenRule`, as an OEM build might. */ + var refuseRemoval: Boolean = false + override fun uninstall(): Boolean { - if (!access) return false + if (!access || refuseRemoval) return false rule = null on = false return true diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index fae1276..7f42f3e 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -2769,9 +2769,9 @@ engines (`ArchitectureRulesTest` holds it to no `android.*` import): Every entry point is an idempotent pass over "where does the schedule stand now", computed by `BedtimeSchedule`: a night's window runs from the bedtime on -one of the chosen nights (named by its evening, so a bedtime before noon such as -00:30 falls on the next calendar day: "Thursday night" at 00:30 is early -Friday) to the first wake time after it (an earlier wake time is +one of the chosen nights (named by its evening, so a bedtime in the small hours, before 06:00, falls +on the next calendar day: "Thursday night" at 00:30 is early Friday, while a +day sleeper's 08:00 stays on the day picked) to the first wake time after it (an earlier wake time is the next morning, decided on the local times so a bedtime in a spring-forward gap cannot push a same-night wake time a day later; an equal one is no window at all, since a day-long window on every night would be a Do Not Disturb that @@ -2832,7 +2832,8 @@ Settings' own confirmation dialog), and Clockula cannot remove a rule without access, so a rule left by a revoke from outside Settings is the platform's. Turning bedtime off removes the rule cleanly; the "last applied" state is cleared only once the removal succeeded, so a refused removal is retried by the -next pass rather than leaving the mode on. +next pass rather than leaving the mode on (an off schedule's cheap early return +still checks that no rule of Clockula's is left). ### Backup From 1a5552a22146a40d2507dcb0b42e96d7d7fd6790 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Thu, 22 Oct 2026 12:20:25 +0200 Subject: [PATCH 07/10] fix(bedtime): keep the slot off the allow-while-idle ration Android rations allow-while-idle wake-ups per app in doze, and the timer's expiry slot and the alarm's fallback share that ration, so a 07:00 wake-up fire could hold back a timer due at 07:04. The bedtime slot is now a plain exact alarm that doze may defer, which can move a mode boundary but never a ring. A restore of unchanged bedtime settings no longer forces the mode, and the screen skips its rule check while bedtime is off. Refs #1 --- .../clockula/backup/BackupEngine.kt | 7 +++++- .../android/AndroidBedtimeScheduler.kt | 22 +++++++++++-------- .../clockula/ui/bedtime/BedtimeViewModel.kt | 3 ++- docs/ARCHITECTURE.md | 15 ++++++++----- 4 files changed, 31 insertions(+), 16 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/backup/BackupEngine.kt b/app/src/main/java/de/jeanlucmakiola/clockula/backup/BackupEngine.kt index 327d812..bfeb275 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/backup/BackupEngine.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/backup/BackupEngine.kt @@ -99,7 +99,12 @@ class BackupEngine @Inject constructor( // 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. Nothing to follow if they never changed. - if (settingsApplied) runCatching { bedtimeEngine.resync(refreshRule = true) } + // Forced only when the bedtime settings really changed, so a + // restore of the same schedule leaves a hand-switched mode be. + if (settingsApplied) { + val changed = previousSettings.bedtime != content.settings.bedtime + runCatching { bedtimeEngine.resync(refreshRule = changed) } + } } } failure?.let { throw it } diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/android/AndroidBedtimeScheduler.kt b/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/android/AndroidBedtimeScheduler.kt index 10c7c37..487b0ff 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/android/AndroidBedtimeScheduler.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/android/AndroidBedtimeScheduler.kt @@ -15,11 +15,17 @@ 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. + * + * And never *allow-while-idle*. Android rations allow-while-idle wake-ups + * per app while the device dozes (one per nine minutes on Android 10 and 11, + * and for the inexact fallback without exact-alarm access), and the timer's + * expiry slot and the alarm's fallback draw on the same ration. A 07:00 + * wake-up fire would hold back a timer due at 07:04 or a fallback alarm at + * 07:05. So the slot is exact but doze-deferrable: in deep doze it waits for + * the next maintenance window or for the next alarm clock to wake the device, + * which only moves a mode boundary, never a ring. 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( @@ -31,12 +37,10 @@ class AndroidBedtimeScheduler @Inject constructor( override fun schedule(at: Instant) { val millis = at.toEpochMilliseconds() val pendingIntent = slotPendingIntent() - val exact = runCatching { - manager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, millis, pendingIntent) - }.isSuccess + val exact = runCatching { manager.setExact(AlarmManager.RTC_WAKEUP, millis, pendingIntent) }.isSuccess // Guarded too: bedtime losing its slot is survivable, a crash in the // receiver's process is not. - if (!exact) runCatching { manager.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, millis, pendingIntent) } + if (!exact) runCatching { manager.set(AlarmManager.RTC_WAKEUP, millis, pendingIntent) } } override fun cancel() = manager.cancel(slotPendingIntent()) diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/bedtime/BedtimeViewModel.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/bedtime/BedtimeViewModel.kt index 6a98974..db0c391 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/bedtime/BedtimeViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/bedtime/BedtimeViewModel.kt @@ -54,7 +54,8 @@ class BedtimeViewModel @Inject internal constructor( appScope.launch { // Also when access was granted while this screen was closed and // the system's broadcast did not reach the app: no rule yet. - if (changed || (granted && engine.ruleId() == null)) { + val enabled = (state.value as? BedtimeUiState.Loaded)?.settings?.enabled == true + if (changed || (granted && enabled && engine.ruleId() == null)) { runCatching { engine.resync(refreshRule = true) } } ruleId.value = engine.ruleId() diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 7f42f3e..165a8cb 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -2759,11 +2759,16 @@ 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. + It is `RTC_WAKEUP` and `setExact` (plain `set` without exact-alarm access), + and deliberately **not** allow-while-idle: Android rations allow-while-idle + wake-ups per app in doze (one per nine minutes on Android 10 and 11, and for + the inexact fallback without exact-alarm access), and the timer's expiry slot + and the alarm's fallback draw on that same ration, so a 07:00 wake-up fire + could hold back a timer at 07:04 or a fallback alarm at 07:05. In deep doze + the slot therefore waits for a maintenance window or for the next alarm + clock to wake the device. That can move a mode boundary, never a ring. It + never uses `setAlarmClock` either: that slot is the status bar's next + *alarm*. - `BedtimeMode`, the rule. Every call is guarded and a no-op without access. - `BedtimeNotifier`, the reminder, on its own `bedtime_reminder` channel. From 131a28c1bea0a2f4ab9d0be0759dbfb06a8e503b Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Thu, 22 Oct 2026 12:30:26 +0200 Subject: [PATCH 08/10] fix(bedtime): bounded fallback window, fresh-screen access check Without exact-alarm access the slot falls back to a ten-minute window instead of plain set(), whose slack could end an eight-hour night hours late. A freshly opened bedtime screen reads whether bedtime is on from the store, not from its still-loading state, so access granted while the screen was closed creates the rule. A restore that rolled back no longer forces the mode. Refs #1 --- .../de/jeanlucmakiola/clockula/backup/BackupEngine.kt | 3 ++- .../bedtime/android/AndroidBedtimeScheduler.kt | 11 ++++++++++- .../clockula/ui/bedtime/BedtimeViewModel.kt | 5 ++++- docs/ARCHITECTURE.md | 3 ++- 4 files changed, 18 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/backup/BackupEngine.kt b/app/src/main/java/de/jeanlucmakiola/clockula/backup/BackupEngine.kt index bfeb275..42da075 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/backup/BackupEngine.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/backup/BackupEngine.kt @@ -102,7 +102,8 @@ class BackupEngine @Inject constructor( // Forced only when the bedtime settings really changed, so a // restore of the same schedule leaves a hand-switched mode be. if (settingsApplied) { - val changed = previousSettings.bedtime != content.settings.bedtime + // After a rollback the stored settings are the previous ones. + val changed = rowsReplaced && previousSettings.bedtime != content.settings.bedtime runCatching { bedtimeEngine.resync(refreshRule = changed) } } } diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/android/AndroidBedtimeScheduler.kt b/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/android/AndroidBedtimeScheduler.kt index 487b0ff..9002657 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/android/AndroidBedtimeScheduler.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/android/AndroidBedtimeScheduler.kt @@ -40,11 +40,20 @@ class AndroidBedtimeScheduler @Inject constructor( val exact = runCatching { manager.setExact(AlarmManager.RTC_WAKEUP, millis, pendingIntent) }.isSuccess // Guarded too: bedtime losing its slot is survivable, a crash in the // receiver's process is not. - if (!exact) runCatching { manager.set(AlarmManager.RTC_WAKEUP, millis, pendingIntent) } + // A bounded window rather than `set`, whose slack grows with the + // distance to the trigger and could end an 8-hour night hours late. + if (!exact) { + runCatching { manager.setWindow(AlarmManager.RTC_WAKEUP, millis, FALLBACK_WINDOW_MILLIS, pendingIntent) } + } } override fun cancel() = manager.cancel(slotPendingIntent()) + private companion object { + /** The platform's own minimum window from Android 12 on. */ + const val FALLBACK_WINDOW_MILLIS = 10 * 60_000L + } + private fun slotPendingIntent(): PendingIntent = PendingIntent.getBroadcast( context, BedtimeIntents.REQUEST_SLOT, diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/bedtime/BedtimeViewModel.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/bedtime/BedtimeViewModel.kt index db0c391..01a4e18 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/bedtime/BedtimeViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/bedtime/BedtimeViewModel.kt @@ -14,6 +14,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import java.time.DayOfWeek @@ -54,7 +55,9 @@ class BedtimeViewModel @Inject internal constructor( appScope.launch { // Also when access was granted while this screen was closed and // the system's broadcast did not reach the app: no rule yet. - val enabled = (state.value as? BedtimeUiState.Loaded)?.settings?.enabled == true + // Read from the store, not from [state]: on a fresh screen that + // is still Loading at the first resume. + val enabled = engine.bedtime.first().enabled if (changed || (granted && enabled && engine.ruleId() == null)) { runCatching { engine.resync(refreshRule = true) } } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 165a8cb..19d4346 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -2759,7 +2759,8 @@ 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` and `setExact` (plain `set` without exact-alarm access), + It is `RTC_WAKEUP` and `setExact` (a ten-minute `setWindow` without + exact-alarm access, since plain `set`'s slack grows with the distance), and deliberately **not** allow-while-idle: Android rations allow-while-idle wake-ups per app in doze (one per nine minutes on Android 10 and 11, and for the inexact fallback without exact-alarm access), and the timer's expiry slot From 74c0c278ef20bcca76fb0aa539994215a41bb18f Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Thu, 22 Oct 2026 12:41:15 +0200 Subject: [PATCH 09/10] fix(bedtime): retry refused removals, state changes and rule rewrites A removal the system refuses while turning bedtime off now keeps the slot armed fifteen minutes out as its retry, instead of cancelling it and leaving Do Not Disturb on until some unrelated launch. A refused state change records the opposite state so the next pass retries it, and a rule that was rewritten (which clears its condition on Android 10-14) is switched regardless. A refused rewrite is remembered and retried by every pass until it lands, rather than resting on the one edit that asked. Refs #1 --- .../clockula/bedtime/BedtimeEngine.kt | 37 +++++++++++---- .../clockula/bedtime/BedtimeSeams.kt | 10 +++-- .../bedtime/android/AndroidBedtimeMode.kt | 10 +++-- .../clockula/data/prefs/BedtimePrefs.kt | 3 ++ .../clockula/data/prefs/SettingsPrefs.kt | 4 ++ .../clockula/bedtime/BedtimeEngineTest.kt | 45 +++++++++++++++++++ .../clockula/testing/FakeBedtimeSeams.kt | 23 ++++++---- docs/ARCHITECTURE.md | 10 +++-- 8 files changed, 113 insertions(+), 29 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/BedtimeEngine.kt b/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/BedtimeEngine.kt index 496fdd0..3bc6622 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/BedtimeEngine.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/BedtimeEngine.kt @@ -117,17 +117,22 @@ class BedtimeEngine @Inject constructor( if (!force && !refreshRule && !settings.bedtimeAppliedActive() && (!mode.hasAccess() || mode.ruleId() == null) ) { + scheduler.cancel() return } - 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. - // Recorded only once the rule is really gone, so a refused removal - // is retried by the next pass instead of leaving the mode on. val removed = !mode.hasAccess() || mode.uninstall() - if (removed) settings.setBedtimeAppliedActive(false) + if (removed) { + settings.setBedtimeAppliedActive(false) + scheduler.cancel() + } else { + // Refused: keep the slot as the retry, so the mode cannot stay + // on until some unrelated launch or reboot happens along. + scheduler.schedule(now + RETRY_AFTER) + } return } val active = BedtimeSchedule.activeWindow(current, now, zone) != null @@ -137,17 +142,31 @@ class BedtimeEngine @Inject constructor( if (mode.hasAccess()) { // A rule that was just (re)created starts off, whatever was last // applied to its predecessor, so it is switched regardless. - val created = mode.install(current, refresh = refreshRule) - if (force || created || active != settings.bedtimeAppliedActive()) { - // Recorded only once the system took it, so a refused call is - // retried by the next pass rather than forgotten. - if (mode.setActive(active)) settings.setBedtimeAppliedActive(active) + // A rewrite that failed is remembered and retried by every pass + // until it lands, rather than resting on the one caller that asked. + val stale = refreshRule || settings.bedtimeRuleStale() + val outcome = mode.install(current, refresh = stale) + if (stale) settings.setBedtimeRuleStale(outcome == InstallOutcome.FAILED) + // A rule that was just (re)created or rewritten may start off + // (Android 10 to 14 clear the condition on an update), so it is + // switched regardless of what was last applied. + val reset = outcome == InstallOutcome.CREATED || outcome == InstallOutcome.UPDATED + if (force || reset || active != settings.bedtimeAppliedActive()) { + // A refused call records the opposite, so the next pass sees a + // difference and retries instead of trusting a state that never + // reached the system. + val accepted = mode.setActive(active) + settings.setBedtimeAppliedActive(if (accepted) active else !active) } } val next = BedtimeSchedule.nextEvent(current, now, zone) if (next == null) scheduler.cancel() else scheduler.schedule(next) } + private companion object { + val RETRY_AFTER = 15.minutes + } + /** What the system rule is built from; the reminder is not part of it. */ private fun BedtimeSettings.ruleFields() = listOf(enabled, bedtime, wakeTime, effects) diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/BedtimeSeams.kt b/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/BedtimeSeams.kt index ed3187b..57931a6 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/BedtimeSeams.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/BedtimeSeams.kt @@ -28,11 +28,10 @@ interface BedtimeMode { val supportsEffects: Boolean /** - * Creates the rule when it is missing, and returns whether it did. [refresh] - * also rewrites an existing one from [settings]; otherwise an existing - * rule is left exactly as it is. + * 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): Boolean + fun install(settings: BedtimeSettings, refresh: Boolean): InstallOutcome /** Turns the mode on or off; false when there is no rule or the system refused. */ fun setActive(active: Boolean): Boolean @@ -44,6 +43,9 @@ interface BedtimeMode { fun ruleId(): String? } +/** What [BedtimeMode.install] did. */ +enum class InstallOutcome { UNCHANGED, CREATED, UPDATED, FAILED } + /** The reminder notification. */ interface BedtimeNotifier { fun showReminder(start: Instant, minutesBefore: Int) diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/android/AndroidBedtimeMode.kt b/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/android/AndroidBedtimeMode.kt index 3d42c39..7139919 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/android/AndroidBedtimeMode.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/android/AndroidBedtimeMode.kt @@ -14,6 +14,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext import de.jeanlucmakiola.clockula.MainActivity import de.jeanlucmakiola.clockula.R import de.jeanlucmakiola.clockula.bedtime.BedtimeMode +import de.jeanlucmakiola.clockula.bedtime.InstallOutcome import de.jeanlucmakiola.clockula.domain.TimeOfDay import de.jeanlucmakiola.clockula.domain.bedtime.BedtimeSettings import java.time.LocalTime @@ -60,8 +61,8 @@ class AndroidBedtimeMode @Inject constructor( override fun hasAccess(): Boolean = guarded(false) { manager.isNotificationPolicyAccessGranted } - override fun install(settings: BedtimeSettings, refresh: Boolean): Boolean = - guarded(false) { + override fun install(settings: BedtimeSettings, refresh: Boolean): InstallOutcome = + guarded(InstallOutcome.FAILED) { val ours = ownRules() // Only ever one. A second (two passes racing on another process, // say) is removed rather than left to fight the first. @@ -69,7 +70,7 @@ class AndroidBedtimeMode @Inject constructor( val existing = ours.entries.firstOrNull() if (existing == null) { manager.addAutomaticZenRule(rule(settings, enabled = true)) - return@guarded true + return@guarded InstallOutcome.CREATED } if (refresh) { // Keeps what the user set for the rule in Settings: its on/off @@ -80,8 +81,9 @@ class AndroidBedtimeMode @Inject constructor( existing.key, rule(settings, enabled = existing.value.isEnabled, kept = existing.value.zenPolicy), ) + return@guarded InstallOutcome.UPDATED } - false + InstallOutcome.UNCHANGED } override fun setActive(active: Boolean): Boolean = diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/BedtimePrefs.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/BedtimePrefs.kt index 0f88a1f..74d6f62 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/BedtimePrefs.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/BedtimePrefs.kt @@ -58,6 +58,9 @@ object BedtimePrefs { */ val appliedActive: Pref = booleanPref("bedtime_applied_active", false) + /** A rule rewrite the system refused, still owed; device state like the above. */ + val ruleStale: Pref = booleanPref("bedtime_rule_stale", false) + /** Minutes since midnight on disk, clamped into a day both ways. */ private fun Pref.asTimeOfDay(): Pref = map( decode = { minutes -> diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefs.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefs.kt index a85904e..b653303 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefs.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefs.kt @@ -182,6 +182,10 @@ class SettingsPrefs @Inject constructor(private val store: PrefStore) { suspend fun setBedtimeAppliedActive(active: Boolean) = store.set(BedtimePrefs.appliedActive, active) + suspend fun bedtimeRuleStale(): Boolean = store.get(BedtimePrefs.ruleStale) + + suspend fun setBedtimeRuleStale(stale: Boolean) = store.set(BedtimePrefs.ruleStale, stale) + private fun MutablePreferences.putBedtime(settings: BedtimeSettings) { this[BedtimePrefs.enabled] = settings.enabled this[BedtimePrefs.bedtime] = settings.bedtime diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/bedtime/BedtimeEngineTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/bedtime/BedtimeEngineTest.kt index 16068d1..f350083 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/bedtime/BedtimeEngineTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/bedtime/BedtimeEngineTest.kt @@ -268,6 +268,51 @@ class BedtimeEngineTest { assertThat(h.mode.rule).isNull() } + @Test + fun `a refused removal at night keeps a retry slot`(@TempDir tempDir: Path) = runTest { + val h = harness(tempDir, now = instantAt(BERLIN, "2026-06-10T23:30")) + h.engine.update(nightly) + h.mode.refuseRemoval = true + + h.engine.update { it.copy(enabled = false) } + + assertThat(h.scheduler.armedAt).isEqualTo(instantAt(BERLIN, "2026-06-10T23:45")) + h.mode.refuseRemoval = false + h.wallClock.instant = instantAt(BERLIN, "2026-06-10T23:45") + h.engine.onSlotFired() + assertThat(h.mode.rule).isNull() + assertThat(h.scheduler.armedAt).isNull() + } + + @Test + fun `a refused state change is retried by the next pass`(@TempDir tempDir: Path) = runTest { + val h = harness(tempDir) + h.engine.update(nightly) + h.mode.refuseState = true + h.wallClock.instant = instantAt(BERLIN, "2026-06-10T23:00") + h.engine.onSlotFired() + assertThat(h.mode.on).isFalse() + + h.mode.refuseState = false + h.engine.resync() + + assertThat(h.mode.on).isTrue() + } + + @Test + fun `a refused rule rewrite is retried by the next pass`(@TempDir tempDir: Path) = runTest { + val h = harness(tempDir) + h.engine.update(nightly) + h.mode.refuseUpdate = true + h.engine.update { it.copy(effects = it.effects.copy(grayscale = false)) } + assertThat(h.mode.rule?.effects?.grayscale).isTrue() + + h.mode.refuseUpdate = false + h.engine.resync() + + assertThat(h.mode.rule?.effects?.grayscale).isFalse() + } + @Test fun `changing only the reminder does not rewrite the rule`(@TempDir tempDir: Path) = runTest { val h = harness(tempDir) diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeBedtimeSeams.kt b/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeBedtimeSeams.kt index d84d529..a592679 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeBedtimeSeams.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeBedtimeSeams.kt @@ -3,6 +3,7 @@ package de.jeanlucmakiola.clockula.testing import de.jeanlucmakiola.clockula.bedtime.BedtimeMode import de.jeanlucmakiola.clockula.bedtime.BedtimeNotifier import de.jeanlucmakiola.clockula.bedtime.BedtimeScheduler +import de.jeanlucmakiola.clockula.bedtime.InstallOutcome import de.jeanlucmakiola.clockula.domain.bedtime.BedtimeSettings import kotlin.time.Instant @@ -37,23 +38,27 @@ class FakeBedtimeMode( override fun hasAccess(): Boolean = access - override fun install(settings: BedtimeSettings, refresh: Boolean): Boolean { - if (!access) return false + /** The system refusing a rewrite or a state change. */ + var refuseUpdate: Boolean = false + var refuseState: Boolean = false + + override fun install(settings: BedtimeSettings, refresh: Boolean): InstallOutcome { + if (!access) return InstallOutcome.FAILED if (rule == null) { rule = settings // A new rule starts off, as the platform's does. on = false - return true + return InstallOutcome.CREATED } - if (refresh) { - rule = settings - refreshes++ - } - return false + if (!refresh) return InstallOutcome.UNCHANGED + if (refuseUpdate) return InstallOutcome.FAILED + rule = settings + refreshes++ + return InstallOutcome.UPDATED } override fun setActive(active: Boolean): Boolean { - if (!access || rule == null) return false + if (!access || rule == null || refuseState) return false on = active return true } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 19d4346..7a0c414 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -602,7 +602,7 @@ Four slices: | Stopwatch run record (M2) | `stopwatch_state`, `stopwatch_started_elapsed_millis`, `stopwatch_accumulated_millis`, `stopwatch_last_lap_cumulative_millis` (M7 is the first caller of the last, for the in-progress lap's split) | `StopwatchPrefs`, behind `StopwatchStateStore` | | System facts (M3) | `last_boot_id` | `SystemPrefs`, behind `BootStateStore`, the persisted half of the reboot gate | | Timers (M6) | `timer_presets`, `timer_ring_sounding_since_millis` | `TimerPrefs`: the presets surfaced as `SettingsPrefs.timerPresets`, the ring session's anchor behind `TimerRingStateStore` | -| Bedtime (issue #1) | `bedtime_enabled`, `bedtime_start_minutes`, `bedtime_wake_minutes`, `bedtime_days`, `bedtime_reminder_minutes`, `bedtime_effect_grayscale`, `bedtime_effect_dim_wallpaper`, `bedtime_effect_dark_theme`, `bedtime_effect_hide_aod`, `bedtime_applied_active` | `BedtimePrefs`, surfaced as `SettingsPrefs.bedtime: Flow` 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) | +| Bedtime (issue #1) | `bedtime_enabled`, `bedtime_start_minutes`, `bedtime_wake_minutes`, `bedtime_days`, `bedtime_reminder_minutes`, `bedtime_effect_grayscale`, `bedtime_effect_dim_wallpaper`, `bedtime_effect_dark_theme`, `bedtime_effect_hide_aod`, `bedtime_applied_active`, `bedtime_rule_stale` | `BedtimePrefs`, surfaced as `SettingsPrefs.bedtime: Flow` 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. @@ -2798,8 +2798,12 @@ coexist with a schedule. The last state Clockula set is persisted (`bedtime_applied_active`, device state, not in the JSON backup), and a pass that is not an edit switches the mode only when the schedule now says something different, or when the rule was just recreated (a new rule starts -off). The state is recorded only once the system accepted it, so a refused -call is retried by the next pass. A launch in the afternoon leaves a mode the user turned +off) or rewritten (Android 10 to 14 clear the condition on an update). A +refused state change records the opposite state, so the next pass sees a +difference and retries; a refused rule rewrite is remembered +(`bedtime_rule_stale`) and retried by every pass until it lands; and a refused +removal when turning bedtime off keeps the slot armed fifteen minutes out as +its retry. A launch in the afternoon leaves a mode the user turned on alone; a launch after a force stop that slept through the wake-up time turns it off. Each pass reads the clock **once**, so the mode's state and the next slot always agree about which side of a boundary "now" is on. A posted From 817fe77b17af924a884e79ba7f6f542d04c1c843 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Thu, 22 Oct 2026 12:54:02 +0200 Subject: [PATCH 10/10] fix(bedtime): record only accepted states, urgent wake-up, keep bedtime on old restores A refused state change is now remembered as owed and forced by every pass until it lands, instead of recording the opposite state, which could hide the next real boundary. Any refusal, in the rule rewrite or the state change, pulls the slot in to a retry, and the slot is armed in a finally so a throwing step can't leave it unarmed. The wake-up that ends the mode is armed allow-while-idle (one such fire a night), so Do Not Disturb no longer lingers into the morning behind a doze deferral. The manifest filter for the access-changed broadcast is gone: the platform only delivers it to runtime-registered receivers, and the screen's resume check covers it. A backup written before bedtime existed now keeps the current bedtime settings, and a restore goes through the engine's own field comparison, so a reminder-only difference leaves a hand-switched mode alone. Refs #1 --- app/src/main/AndroidManifest.xml | 11 +-- .../clockula/backup/BackupEngine.kt | 13 ++- .../clockula/bedtime/BedtimeEngine.kt | 93 +++++++++++++------ .../clockula/bedtime/BedtimeSeams.kt | 6 +- .../android/AndroidBedtimeScheduler.kt | 27 +++--- .../bedtime/receiver/BedtimeReceiver.kt | 18 +--- .../clockula/data/backup/BackupCodec.kt | 4 +- .../clockula/data/backup/BackupDto.kt | 2 +- .../clockula/data/prefs/BedtimePrefs.kt | 3 + .../clockula/data/prefs/SettingsPrefs.kt | 7 +- .../clockula/domain/backup/BackupContent.kt | 4 +- .../clockula/backup/BackupEngineTest.kt | 28 ++++++ .../clockula/bedtime/BedtimeEngineTest.kt | 33 +++++++ .../clockula/data/backup/BackupCodecTest.kt | 6 +- .../clockula/testing/FakeBedtimeSeams.kt | 6 +- docs/ARCHITECTURE.md | 55 +++++------ docs/BACKUP.md | 3 +- 17 files changed, 215 insertions(+), 104 deletions(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index fc436c2..db065b6 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -279,16 +279,11 @@ - + - - - - + android:exported="false" /> diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/backup/BackupEngine.kt b/app/src/main/java/de/jeanlucmakiola/clockula/backup/BackupEngine.kt index 42da075..3cdc85a 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/backup/BackupEngine.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/backup/BackupEngine.kt @@ -98,13 +98,12 @@ class BackupEngine @Inject constructor( // 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. Nothing to follow if they never changed. - // Forced only when the bedtime settings really changed, so a - // restore of the same schedule leaves a hand-switched mode be. - if (settingsApplied) { - // After a rollback the stored settings are the previous ones. - val changed = rowsReplaced && previousSettings.bedtime != content.settings.bedtime - runCatching { bedtimeEngine.resync(refreshRule = changed) } + // not a failed import. The engine compares against what was + // stored before (after a rollback, the same again), so only a + // real change to the schedule or the rule re-asserts the mode. + val previousBedtime = previousSettings.bedtime + if (settingsApplied && previousBedtime != null) { + runCatching { bedtimeEngine.resyncAfterRestore(previousBedtime) } } } } diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/BedtimeEngine.kt b/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/BedtimeEngine.kt index 3bc6622..0dea305 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/BedtimeEngine.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/BedtimeEngine.kt @@ -66,7 +66,8 @@ class BedtimeEngine @Inject constructor( /** * Everything that can invalidate the slot or the rule: app launch (a * force stop clears the slot), a reboot, an app update, a clock or zone - * change, a backup restore, and Do Not Disturb access changing. The mode + * change, and Do Not Disturb access changing (a restore has + * [resyncAfterRestore]). The mode * is only switched when the schedule crossed a boundary since Clockula * last switched it, so a mode the user turned on or off by hand stays * that way until the next bedtime or wake-up. [refreshRule] also rewrites @@ -131,36 +132,74 @@ class BedtimeEngine @Inject constructor( } else { // Refused: keep the slot as the retry, so the mode cannot stay // on until some unrelated launch or reboot happens along. - scheduler.schedule(now + RETRY_AFTER) + scheduler.schedule(now + RETRY_AFTER, urgent = false) } return } - val active = BedtimeSchedule.activeWindow(current, now, zone) != null - // A reminder that no longer points at a coming bedtime (it started, - // or the schedule changed under it) is stale. - if (BedtimeSchedule.dueReminder(current, now, zone) == null) notifier.cancelReminder() - if (mode.hasAccess()) { - // A rule that was just (re)created starts off, whatever was last - // applied to its predecessor, so it is switched regardless. - // A rewrite that failed is remembered and retried by every pass - // until it lands, rather than resting on the one caller that asked. - val stale = refreshRule || settings.bedtimeRuleStale() - val outcome = mode.install(current, refresh = stale) - if (stale) settings.setBedtimeRuleStale(outcome == InstallOutcome.FAILED) - // A rule that was just (re)created or rewritten may start off - // (Android 10 to 14 clear the condition on an update), so it is - // switched regardless of what was last applied. - val reset = outcome == InstallOutcome.CREATED || outcome == InstallOutcome.UPDATED - if (force || reset || active != settings.bedtimeAppliedActive()) { - // A refused call records the opposite, so the next pass sees a - // difference and retries instead of trusting a state that never - // reached the system. - val accepted = mode.setActive(active) - settings.setBedtimeAppliedActive(if (accepted) active else !active) - } - } + val window = BedtimeSchedule.activeWindow(current, now, zone) val next = BedtimeSchedule.nextEvent(current, now, zone) - if (next == null) scheduler.cancel() else scheduler.schedule(next) + // Anything below may throw or be refused; the slot is armed whatever + // happens, and pulled in to a retry when the mode is not where it + // should be. + var retry = true + try { + // A reminder that no longer points at a coming bedtime (it started, + // or the schedule changed under it) is stale. + if (BedtimeSchedule.dueReminder(current, now, zone) == null) notifier.cancelReminder() + retry = mode.hasAccess() && !syncMode(current, active = window != null, refreshRule, force) + } finally { + arm(next, now, retry = retry, urgent = window != null && next == window.end) + } + } + + /** + * Brings the rule and its state in line; false when something is still + * owed. Only what the system accepted is recorded as applied. A refused + * rewrite or state change is remembered as owed and forced by every pass + * until it lands, so a refusal neither hides the next real boundary nor + * rests on the one caller that asked. + */ + private suspend fun syncMode(current: BedtimeSettings, active: Boolean, refreshRule: Boolean, force: Boolean): Boolean { + val stale = refreshRule || settings.bedtimeRuleStale() + val outcome = mode.install(current, refresh = stale) + if (stale) settings.setBedtimeRuleStale(outcome == InstallOutcome.FAILED) + // A rule that was just (re)created or rewritten may start off + // (Android 10 to 14 clear the condition on an update), so it is + // switched regardless of what was last applied. + val reset = outcome == InstallOutcome.CREATED || outcome == InstallOutcome.UPDATED + val owed = settings.bedtimeStateOwed() + var stateOk = true + if (force || reset || owed || active != settings.bedtimeAppliedActive()) { + stateOk = mode.setActive(active) + if (stateOk) settings.setBedtimeAppliedActive(active) + if (stateOk != !owed) settings.setBedtimeStateOwed(!stateOk) + } + return outcome != InstallOutcome.FAILED && stateOk + } + + /** + * The next event, or sooner as a retry. Only the wake-up boundary, which + * ends the mode, is [urgent]: one allow-while-idle fire a night, so the + * mode cannot linger into the morning behind a doze deferral. + */ + private fun arm(next: Instant?, now: Instant, retry: Boolean, urgent: Boolean) { + val retryAt = now + RETRY_AFTER + when { + retry && (next == null || retryAt < next) -> scheduler.schedule(retryAt, urgent = false) + next != null -> scheduler.schedule(next, urgent = urgent) + else -> scheduler.cancel() + } + } + + /** + * After a backup restore: the same field comparison an edit makes, against + * what was stored before, so restoring the same schedule (or one that only + * differs in its reminder) leaves a hand-switched mode alone. + */ + suspend fun resyncAfterRestore(previous: BedtimeSettings) = lock.withLock { + val current = settings.currentBedtime() + val refreshRule = previous.ruleFields() != current.ruleFields() + apply(current, refreshRule = refreshRule, force = refreshRule || previous.scheduleFields() != current.scheduleFields()) } private companion object { diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/BedtimeSeams.kt b/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/BedtimeSeams.kt index 57931a6..10bdbee 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/BedtimeSeams.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/BedtimeSeams.kt @@ -9,7 +9,11 @@ import kotlin.time.Instant * registration. */ interface BedtimeScheduler { - fun schedule(at: Instant) + /** + * [urgent] is the one event a night that may draw on the platform's + * allow-while-idle ration: the wake-up that ends the mode. + */ + fun schedule(at: Instant, urgent: Boolean) fun cancel() } diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/android/AndroidBedtimeScheduler.kt b/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/android/AndroidBedtimeScheduler.kt index 9002657..1d87f0d 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/android/AndroidBedtimeScheduler.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/android/AndroidBedtimeScheduler.kt @@ -16,16 +16,14 @@ 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. * - * And never *allow-while-idle*. Android rations allow-while-idle wake-ups - * per app while the device dozes (one per nine minutes on Android 10 and 11, - * and for the inexact fallback without exact-alarm access), and the timer's - * expiry slot and the alarm's fallback draw on the same ration. A 07:00 - * wake-up fire would hold back a timer due at 07:04 or a fallback alarm at - * 07:05. So the slot is exact but doze-deferrable: in deep doze it waits for - * the next maintenance window or for the next alarm clock to wake the device, - * which only moves a mode boundary, never a ring. 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. + * Only the wake-up that ends the mode is allow-while-idle, so Do Not Disturb + * cannot linger hours into the morning behind a doze deferral. That is one + * such fire a night, well within the per-app ration the timer's expiry slot + * also uses (alarms themselves use `setAlarmClock`, which is not rationed). + * Bedtime's start, the reminder and retries are exact but doze-deferrable: + * a late start in a dozing, untouched phone costs nothing. It is its own + * PendingIntent (its own receiver, action and request code), so arming or + * cancelling it can never touch an alarm's or a timer's registration. */ @Singleton class AndroidBedtimeScheduler @Inject constructor( @@ -34,9 +32,16 @@ class AndroidBedtimeScheduler @Inject constructor( private val manager: AlarmManager = context.getSystemService(AlarmManager::class.java) - override fun schedule(at: Instant) { + override fun schedule(at: Instant, urgent: Boolean) { val millis = at.toEpochMilliseconds() val pendingIntent = slotPendingIntent() + if (urgent) { + val exact = runCatching { + manager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, millis, pendingIntent) + }.isSuccess + if (!exact) runCatching { manager.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, millis, pendingIntent) } + return + } val exact = runCatching { manager.setExact(AlarmManager.RTC_WAKEUP, millis, pendingIntent) }.isSuccess // Guarded too: bedtime losing its slot is survivable, a crash in the // receiver's process is not. diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/receiver/BedtimeReceiver.kt b/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/receiver/BedtimeReceiver.kt index 1f6ad1f..9da7ab2 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/receiver/BedtimeReceiver.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/receiver/BedtimeReceiver.kt @@ -1,6 +1,5 @@ package de.jeanlucmakiola.clockula.bedtime.receiver -import android.app.NotificationManager import android.content.Context import android.content.Intent import dagger.hilt.android.AndroidEntryPoint @@ -13,11 +12,10 @@ 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). + * The bedtime slot arriving. Do Not Disturb access changing is not heard + * here: the platform delivers `ACTION_NOTIFICATION_POLICY_ACCESS_GRANTED_CHANGED` + * to runtime-registered receivers only, so the bedtime screen re-reads access + * on every `ON_RESUME` instead, and any later pass picks a grant up. */ @AndroidEntryPoint class BedtimeReceiver : HiltBroadcastReceiver() { @@ -38,13 +36,7 @@ class BedtimeReceiver : HiltBroadcastReceiver() { try { // Guarded: a platform refusal here must not crash the process, // which is the process an alarm may be about to ring in. - runCatching { - when (action) { - BedtimeIntents.ACTION_SLOT -> engine.onSlotFired() - NotificationManager.ACTION_NOTIFICATION_POLICY_ACCESS_GRANTED_CHANGED -> - engine.resync(refreshRule = true) - } - } + if (action == BedtimeIntents.ACTION_SLOT) runCatching { engine.onSlotFired() } } finally { pendingResult.finish() } diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupCodec.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupCodec.kt index 025bc8c..1c2361c 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupCodec.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupCodec.kt @@ -131,7 +131,7 @@ object BackupCodec { dismissChallenge = defaults.dismissChallenge.name, homeZoneId = defaults.homeZoneId, timerPresets = timerPresets.map { it.inWholeMilliseconds }, - bedtime = bedtime.toDto(), + bedtime = bedtime?.toDto(), ) private fun BedtimeSettings.toDto() = BedtimeDto( @@ -274,7 +274,7 @@ object BackupCodec { homeZoneId = settingsDto.homeZoneId?.let(Zones::normalise), ), timerPresets = settingsDto.timerPresets?.map { it.milliseconds } ?: TimerPresets.BUILT_IN, - bedtime = settingsDto.bedtime?.toBedtime() ?: BedtimeSettings(), + bedtime = settingsDto.bedtime?.toBedtime(), ) return BackupResult.Success( diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupDto.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupDto.kt index f664ca4..ff20a7f 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupDto.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupDto.kt @@ -78,7 +78,7 @@ data class SettingsDto( * built-in ones. */ @SerialName("timer_presets") val timerPresets: List? = null, - /** Absent from files written before issue #1; restores as "off" with the default schedule. */ + /** Absent from files written before issue #1; restoring one keeps the current bedtime settings. */ val bedtime: BedtimeDto? = null, ) diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/BedtimePrefs.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/BedtimePrefs.kt index 74d6f62..702ed0c 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/BedtimePrefs.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/BedtimePrefs.kt @@ -61,6 +61,9 @@ object BedtimePrefs { /** A rule rewrite the system refused, still owed; device state like the above. */ val ruleStale: Pref = booleanPref("bedtime_rule_stale", false) + /** A state change the system refused, still owed; device state like the above. */ + val stateOwed: Pref = booleanPref("bedtime_state_owed", false) + /** Minutes since midnight on disk, clamped into a day both ways. */ private fun Pref.asTimeOfDay(): Pref = map( decode = { minutes -> diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefs.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefs.kt index b653303..781d86b 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefs.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefs.kt @@ -96,7 +96,8 @@ class SettingsPrefs @Inject constructor(private val store: PrefStore) { prefs[ClockPrefs.dismissChallenge] = backup.defaults.dismissChallenge prefs[ClockPrefs.homeZoneId] = backup.defaults.homeZoneId prefs[TimerPrefs.presets] = backup.timerPresets - prefs.putBedtime(backup.bedtime) + // Absent from a file written before bedtime existed: keep it. + backup.bedtime?.let { prefs.putBedtime(it) } } } @@ -186,6 +187,10 @@ class SettingsPrefs @Inject constructor(private val store: PrefStore) { suspend fun setBedtimeRuleStale(stale: Boolean) = store.set(BedtimePrefs.ruleStale, stale) + suspend fun bedtimeStateOwed(): Boolean = store.get(BedtimePrefs.stateOwed) + + suspend fun setBedtimeStateOwed(owed: Boolean) = store.set(BedtimePrefs.stateOwed, owed) + private fun MutablePreferences.putBedtime(settings: BedtimeSettings) { this[BedtimePrefs.enabled] = settings.enabled this[BedtimePrefs.bedtime] = settings.bedtime diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/backup/BackupContent.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/backup/BackupContent.kt index e20b821..04779cb 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/domain/backup/BackupContent.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/backup/BackupContent.kt @@ -57,6 +57,6 @@ data class BackupSettings( val dynamicColor: Boolean, val defaults: ClockDefaults, val timerPresets: List, - /** Absent from older backups, which restore it as [BedtimeSettings]' defaults (off). */ - val bedtime: BedtimeSettings = BedtimeSettings(), + /** Null for a file written before bedtime existed: restoring it keeps the current schedule. */ + val bedtime: BedtimeSettings? = null, ) diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/backup/BackupEngineTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/backup/BackupEngineTest.kt index 43b7261..5408624 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/backup/BackupEngineTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/backup/BackupEngineTest.kt @@ -93,6 +93,34 @@ class BackupEngineTest { assertThat(target.bedtimeScheduler.armedAt).isEqualTo(instantAt(BERLIN, "2026-06-10T07:00")) } + @Test + fun `a backup from before bedtime existed keeps the current bedtime`(@TempDir tempDir: Path) = runTest { + val harness = backupHarness(tempDir.resolve("bedtimeKept").also { it.toFile().mkdirs() }) + harness.bedtimeEngine.update { it.copy(enabled = true, bedtime = TimeOfDay(23, 0), wakeTime = TimeOfDay(7, 0)) } + val before = harness.settings.currentBedtime() + val ruleBefore = harness.bedtimeMode.rule + + harness.backupEngine.import(harness.backupEngine.export().let { it.copy(settings = it.settings.copy(bedtime = null)) }) + + assertThat(harness.settings.currentBedtime()).isEqualTo(before) + assertThat(harness.bedtimeMode.rule).isEqualTo(ruleBefore) + } + + @Test + fun `restoring a reminder-only change leaves a hand-switched mode alone`(@TempDir tempDir: Path) = runTest { + val harness = backupHarness(tempDir.resolve("bedtimeManual").also { it.toFile().mkdirs() }) + harness.bedtimeEngine.update { it.copy(enabled = true, bedtime = TimeOfDay(23, 0), wakeTime = TimeOfDay(7, 0)) } + // 05:00 is inside last night's window; the user switched it off by hand. + harness.bedtimeMode.on = false + val exported = harness.backupEngine.export() + + harness.backupEngine.import( + exported.copy(settings = exported.settings.copy(bedtime = exported.settings.bedtime!!.copy(reminderMinutes = 30))), + ) + + assertThat(harness.bedtimeMode.on).isFalse() + } + @Test fun `import never carries ring state`(@TempDir tempDir: Path) = runTest { val source = backupHarness(tempDir.resolve("source2").also { it.toFile().mkdirs() }) diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/bedtime/BedtimeEngineTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/bedtime/BedtimeEngineTest.kt index f350083..767d6d0 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/bedtime/BedtimeEngineTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/bedtime/BedtimeEngineTest.kt @@ -313,6 +313,39 @@ class BedtimeEngineTest { assertThat(h.mode.rule?.effects?.grayscale).isFalse() } + @Test + fun `a refused state change arms a retry and does not hide the next boundary`(@TempDir tempDir: Path) = + runTest { + val h = harness(tempDir) + h.engine.update(nightly) + h.mode.refuseState = true + + h.wallClock.instant = instantAt(BERLIN, "2026-06-10T23:00") + h.engine.onSlotFired() + + assertThat(h.mode.on).isFalse() + assertThat(h.scheduler.armedAt).isEqualTo(instantAt(BERLIN, "2026-06-10T23:15")) + + h.mode.refuseState = false + h.wallClock.instant = instantAt(BERLIN, "2026-06-10T23:15") + h.engine.onSlotFired() + assertThat(h.mode.on).isTrue() + assertThat(h.scheduler.armedAt).isEqualTo(offsetInstant("2026-06-11T07:00+02:00")) + } + + @Test + fun `only the wake-up that ends the mode is urgent`(@TempDir tempDir: Path) = runTest { + val h = harness(tempDir) + h.engine.update(nightly) + assertThat(h.scheduler.urgent).isFalse() + + h.wallClock.instant = instantAt(BERLIN, "2026-06-10T23:00") + h.engine.onSlotFired() + + assertThat(h.scheduler.armedAt).isEqualTo(offsetInstant("2026-06-11T07:00+02:00")) + assertThat(h.scheduler.urgent).isTrue() + } + @Test fun `changing only the reminder does not rewrite the rule`(@TempDir tempDir: Path) = runTest { val h = harness(tempDir) diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/data/backup/BackupCodecTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/data/backup/BackupCodecTest.kt index f4bbe52..5584361 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/data/backup/BackupCodecTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/data/backup/BackupCodecTest.kt @@ -279,9 +279,9 @@ class BackupCodecTest { } @Test - fun `a file from before bedtime existed restores it off`() { + fun `a file from before bedtime existed carries no bedtime, so restoring it keeps the current one`() { val settings = (BackupCodec.decode(minimalDocument()) as BackupResult.Success).content.settings - assertThat(settings.bedtime).isEqualTo(BedtimeSettings()) + assertThat(settings.bedtime).isNull() } @Test @@ -291,7 +291,7 @@ class BackupCodecTest { "\"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 + 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)) diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeBedtimeSeams.kt b/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeBedtimeSeams.kt index a592679..c5bca82 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeBedtimeSeams.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeBedtimeSeams.kt @@ -11,13 +11,17 @@ import kotlin.time.Instant class FakeBedtimeScheduler : BedtimeScheduler { var armedAt: Instant? = null private set + var urgent: Boolean = false + private set - override fun schedule(at: Instant) { + override fun schedule(at: Instant, urgent: Boolean) { armedAt = at + this.urgent = urgent } override fun cancel() { armedAt = null + urgent = false } } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 7a0c414..89f1d5e 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -602,7 +602,7 @@ Four slices: | Stopwatch run record (M2) | `stopwatch_state`, `stopwatch_started_elapsed_millis`, `stopwatch_accumulated_millis`, `stopwatch_last_lap_cumulative_millis` (M7 is the first caller of the last, for the in-progress lap's split) | `StopwatchPrefs`, behind `StopwatchStateStore` | | System facts (M3) | `last_boot_id` | `SystemPrefs`, behind `BootStateStore`, the persisted half of the reboot gate | | Timers (M6) | `timer_presets`, `timer_ring_sounding_since_millis` | `TimerPrefs`: the presets surfaced as `SettingsPrefs.timerPresets`, the ring session's anchor behind `TimerRingStateStore` | -| Bedtime (issue #1) | `bedtime_enabled`, `bedtime_start_minutes`, `bedtime_wake_minutes`, `bedtime_days`, `bedtime_reminder_minutes`, `bedtime_effect_grayscale`, `bedtime_effect_dim_wallpaper`, `bedtime_effect_dark_theme`, `bedtime_effect_hide_aod`, `bedtime_applied_active`, `bedtime_rule_stale` | `BedtimePrefs`, surfaced as `SettingsPrefs.bedtime: Flow` 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) | +| Bedtime (issue #1) | `bedtime_enabled`, `bedtime_start_minutes`, `bedtime_wake_minutes`, `bedtime_days`, `bedtime_reminder_minutes`, `bedtime_effect_grayscale`, `bedtime_effect_dim_wallpaper`, `bedtime_effect_dark_theme`, `bedtime_effect_hide_aod`, `bedtime_applied_active`, `bedtime_rule_stale`, `bedtime_state_owed` | `BedtimePrefs`, surfaced as `SettingsPrefs.bedtime: Flow` 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. @@ -2759,17 +2759,16 @@ 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` and `setExact` (a ten-minute `setWindow` without - exact-alarm access, since plain `set`'s slack grows with the distance), - and deliberately **not** allow-while-idle: Android rations allow-while-idle - wake-ups per app in doze (one per nine minutes on Android 10 and 11, and for - the inexact fallback without exact-alarm access), and the timer's expiry slot - and the alarm's fallback draw on that same ration, so a 07:00 wake-up fire - could hold back a timer at 07:04 or a fallback alarm at 07:05. In deep doze - the slot therefore waits for a maintenance window or for the next alarm - clock to wake the device. That can move a mode boundary, never a ring. It - never uses `setAlarmClock` either: that slot is the status bar's next - *alarm*. + It is `RTC_WAKEUP`. Only the wake-up that ends the mode is allow-while-idle + (`setExactAndAllowWhileIdle`, falling back to `setAndAllowWhileIdle`), so + Do Not Disturb cannot linger hours into the morning behind a doze deferral. + That is one such fire a night, well inside the per-app allow-while-idle + ration the timer's expiry slot also draws on (alarms themselves use + `setAlarmClock`, which is not rationed). Bedtime's start, the reminder and + retries are `setExact` (a ten-minute `setWindow` without exact-alarm + access), so they never spend that ration; in deep doze they wait for a + maintenance window, which only delays a start in a phone nobody is using. + It never uses `setAlarmClock`: that slot is the status bar's next *alarm*. - `BedtimeMode`, the rule. Every call is guarded and a no-op without access. - `BedtimeNotifier`, the reminder, on its own `bedtime_reminder` channel. @@ -2791,19 +2790,22 @@ reminder and the next window's start. | `update(transform)` | the bedtime screen, on the application scope so leaving the screen cannot cut a write short | stores the settings, rewrites the rule if a field it carries changed, and when the schedule or the rule changed sets the mode to what it says now (on at 23:30 for a 23:00 bedtime starts it at once; a reminder edit leaves a hand-switched mode alone), arms the slot. The screen's writes start undispatched, so they queue on the engine's fair lock in the order they were made. Off removes the rule, which ends the mode, cancels the slot and clears the reminder | | `onSlotFired()` | `BedtimeReceiver` | follows any boundary crossed, posts a due reminder, arms the next event. A reminder fire alone crosses no boundary, so it leaves a mode switched on by hand alone | | `resync()` | `ClockulaApp.onCreate` (a force stop clears the slot), `SystemEventReceiver` (boot, app update, clock or zone change, exact-alarm permission) | creates the rule if it is missing, follows any boundary crossed, arms the slot | -| `resync(refreshRule = true)` | Do Not Disturb access changing (the screen's `ON_RESUME` check and the broadcast), a backup restore | the same, but also rewrites the rule and re-asserts the mode's state. A restore re-runs it only if it got as far as applying settings | +| `resync(refreshRule = true)` | Do Not Disturb access changing (the screen's `ON_RESUME` check) | the same, but also rewrites the rule and re-asserts the mode's state | +| `resyncAfterRestore(previous)` | a backup restore that got as far as applying settings | compares what is stored now with what was stored before, field by field as an edit does, so restoring the same schedule, or one that differs only in its reminder, leaves a hand-switched mode alone | "Follows any boundary crossed" is the one rule that lets a hand-switched mode coexist with a schedule. The last state Clockula set is persisted (`bedtime_applied_active`, device state, not in the JSON backup), and a pass that is not an edit switches the mode only when the schedule now says something different, or when the rule was just recreated (a new rule starts -off) or rewritten (Android 10 to 14 clear the condition on an update). A -refused state change records the opposite state, so the next pass sees a -difference and retries; a refused rule rewrite is remembered -(`bedtime_rule_stale`) and retried by every pass until it lands; and a refused -removal when turning bedtime off keeps the slot armed fifteen minutes out as -its retry. A launch in the afternoon leaves a mode the user turned +off) or rewritten (Android 10 to 14 clear the condition on an update). Only +what the system accepted is recorded as applied. A refused state change is +remembered as owed (`bedtime_state_owed`) and a refused rule rewrite as stale +(`bedtime_rule_stale`), and every pass forces them until they land; meanwhile +the slot is pulled in to a retry fifteen minutes out, and a refused removal +when turning bedtime off keeps the slot armed the same way. The slot is armed +in a `finally`, so a throwing step (a DataStore write, a cancel) cannot leave +it unarmed. A launch in the afternoon leaves a mode the user turned on alone; a launch after a force stop that slept through the wake-up time turns it off. Each pass reads the clock **once**, so the mode's state and the next slot always agree about which side of a boundary "now" is on. A posted @@ -2834,9 +2836,10 @@ onboarding row while it is missing, deep-linking to `Settings.ACTION_NOTIFICATION_POLICY_ACCESS_SETTINGS`, and re-reads access on every `ON_RESUME`; a change found there runs `resync(refreshRule = true)` at once (also when access is there but no rule exists yet, for a grant made while -the screen was closed), so coming back from granting it creates the mode without waiting for the -system's `ACTION_NOTIFICATION_POLICY_ACCESS_GRANTED_CHANGED` (which -`BedtimeReceiver` also answers). Without access the schedule still runs for the +the screen was closed), so coming back from granting it creates the mode. +The system's `ACTION_NOTIFICATION_POLICY_ACCESS_GRANTED_CHANGED` is not +listened for: it reaches runtime-registered receivers only, and the next +slot fire or launch picks a grant up anyway. Without access the schedule still runs for the reminder. Revoking access in Settings removes the app's rules with it (that is Settings' own confirmation dialog), and Clockula cannot remove a rule without access, so a rule left by a revoke from outside Settings is the platform's. @@ -2848,8 +2851,8 @@ still checks that no rule of Clockula's is left). ### Backup The settings travel in the backup's `settings.bedtime` object -(`docs/BACKUP.md`); a file from before issue #1 restores bedtime as off, and -one with no nights at all restores every night. -`BackupEngine.import` resyncs the engine after the replace, or after rolling -it back. +(`docs/BACKUP.md`); a file from before issue #1 carries no `bedtime` and +keeps the device's current bedtime settings, and one with no nights at all +restores every night. `BackupEngine.import` hands the engine what was stored +before (`resyncAfterRestore`), after the replace or after rolling it back. diff --git a/docs/BACKUP.md b/docs/BACKUP.md index f212994..9be0f44 100644 --- a/docs/BACKUP.md +++ b/docs/BACKUP.md @@ -155,7 +155,8 @@ device or after a reboot (`docs/PLAN.md` §5). Every imported timer lands since local midnight, `days` is the same 7-bit mask as an alarm's `repeat_days` (the nights bedtime is for, named by their evening), and `reminder_minutes` 0 means no reminder. A file without `bedtime` (any file written before it existed) -restores bedtime as off with the default schedule. Like the other settings it +leaves the device's current bedtime settings as they are, the one setting a +restore does not replace when its file is silent about it. Like the other settings it is clamped rather than rejected: times into a day, the reminder into 0–120 minutes, and a `days` of 0 (which the screen never allows) into every night. The id of the Do Not Disturb rule a device created is not part of it; the importing device creates its own (`docs/ARCHITECTURE.md` §18).