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

# Conflicts:
#	docs/ARCHITECTURE.md
This commit is contained in:
makiolaj committed 2026-10-26 20:47:54 +01:00
commit ea8e159b39
47 files changed
+2703 -30

No files matched your search

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