docs: the timers, and the counts checked against the gate

ARCHITECTURE gains §14 for timers and the ring package's new shape. The test
counts were written before the review's fixes landed; they now match what the
gate actually runs.
This commit is contained in:
2026-09-12 16:49:05 +02:00
parent fac9250ec7
commit 319d176ae8
2 changed files with 519 additions and 25 deletions
+492 -25
View File
@@ -79,30 +79,39 @@ kit module — the kit's roadmap keeps schedulers app-local.
| Package | Holds |
|---|---|
| `domain/` | `Alarm.kt`, `Timer.kt`, `WorldClock.kt`, `Stopwatch.kt`, `ClockDefaults.kt`, `Ringtone.kt` (the silent sentinel), `RepeatSummary.kt`, `AlarmDefaults.kt` — plain Kotlin, no Android |
| `domain/alarm/` | the alarm engine's pure half: occurrences, the resolver, the ring state, the volume ramp, the ring policies |
| `domain/alarm/` | the alarm engine's pure half: occurrences, the resolver, the ring state, the presentation and scheduling policies, the challenge gate. The *shared* ring vocabulary moved out to `domain/ring/` in M6 |
| `domain/ring/` | what both ring paths share: `RingAudio` (the ramp tick, the volume floor), `VolumeRamp`, `AudioSourcePolicy`/`RingtoneSourceKind`, `RingFallbackPolicy`, `VibrationPattern` |
| `domain/timer/` | the timer path's pure half: `TimerReadings` (the app's one timer precedence), `TimerExpiry` (the sweep and the slot's value), `TimerRingPolicy`, `TimerNotificationPolicy`, `TimerPresets`, `TimerDurationEntry`, `TimerSettings`, `TimerRing` |
| `domain/time/` | `WallClock`, `ElapsedRealtimeClock`, `ZoneProvider`, `BootId`, `Ticker` |
| `domain/live/` | `LivePillSelector` and its state — which running thing the live pill is about, as a pure function |
| `domain/format/` | `ClockFormat` — `M:SS` / `H:MM:SS`, countdowns rounded up, elapsed truncated — and `NextFire` (`NextFireLabel` + `NextFireFormat`), the alarm row's "in 9h 12m" as data |
| `data/db/` | `ClockulaDatabase` — `@Database` v2, `exportSchema = true` — and `Migrations` |
| `data/alarms/` | the alarm and ring-state entities, DAOs, mappers and repositories |
| `data/timers/` | `TimerEntity`, `TimerDao`, `TimerMapper`, `TimerRepository(+Impl)` |
| `data/timers/` | `TimerEntity`, `TimerDao`, `TimerMapper`, `TimerRepository(+Impl)`, `TimerRingStateStore` (the ring session's one DataStore record) |
| `data/worldclocks/` | `WorldClockEntity`, `WorldClockDao`, `WorldClockMapper`, `WorldClockRepository(+Impl)` |
| `data/stopwatch/` | `LapEntity`, `LapDao`, `LapMapper`, `StopwatchStateStore`, `StopwatchRepository(+Impl)` |
| `data/prefs/` | `SettingsPrefs`, `ClockPrefs`, `StopwatchPrefs`, `UiPrefs` |
| `data/prefs/` | `SettingsPrefs`, `ClockPrefs`, `StopwatchPrefs`, `TimerPrefs`, `UiPrefs` |
| `data/ringtones/` | the two ringtone seams — `RingtoneCatalog` (the device's alarm sounds, titles, playability, a SAF grant) and `RingtonePreviewer` — plus their `System*` implementations |
| `data/time/` | `SystemWallClock`, `SystemElapsedRealtimeClock`, `SystemZoneProvider`, `AndroidBootIdProvider`, `RealTicker` |
| `data/di/` | `DataModule`, `DatabaseModule`, `RepositoryModule`, `TimeModule`, `RingtoneModule` |
| `alarm/` | `AlarmEngine` and the four seams it talks to — `AlarmScheduler`, `AlarmCapabilities`, `RingCoordinator`, `AlarmNotifier` — plus `AlarmIntents` |
| `alarm/android/` | the seams' Android implementations: AlarmManager, the capability reads, the service handle, the snoozed notification |
| `alarm/receiver/` | `AlarmFireReceiver`, `AlarmActionReceiver`, `SystemEventReceiver` |
| `alarm/ring/` | the ringing foreground service, its audio player, its vibrator, its notifications |
| `alarm/ring/` | the alarm's ringing foreground service and its notifications. Its audio player and vibrator moved to `ring/` in M6, shared with the timer's ring |
| `alarm/di/` | `AlarmModule` — `@Binds` for the four seams |
| `ring/` | `RingAudioPlayer` and `RingVibrator` — one `MediaPlayer` wrapper and one vibrator for both ring paths. A build rule fails on a second one |
| `timer/` | `TimerEngine` and the three seams it talks to — `TimerScheduler`, `TimerServiceHandle`, `AlarmRingStatus` (+ `AlarmStateRingStatus`) — plus `TimerIntents` |
| `timer/android/` | the seams' Android implementations: the one elapsed-realtime AlarmManager slot, the service handle |
| `timer/receiver/` | `TimerExpiryReceiver` (the slot arriving), `TimerActionReceiver` (the notification's buttons) |
| `timer/service/` | `TimerService` — one foreground service for the countdown and the ring — and `TimerNotifications` |
| `timer/di/` | `TimerModule` — `@Binds` for the three seams |
| `system/` | `RebootRepair` — the boot-id gate |
| `ui/theme/`, `ui/crash/` | the M0/M1 theme and the crash-report surface |
| `ui/shell/` | the navigation policy (`ShellNavigation`), the adaptive shell, the live pill and its source/ViewModel, the notification-permission ask |
| `ui/common/` | `rememberAlarmTimeFormatter` — the one 12/24-hour formatter the list, the editor and the ring screen share |
| `ui/alarms/` | the real Alarms tab (M5): the routes, the list's pure row builder and its source, the two ViewModels, the list, the editor, the M3 time-picker host, the repeat-day selector and the override and ringtone pickers |
| `ui/timers/`, `ui/stopwatch/`, `ui/worldclock/` | one top-level tab each — a title bar and an empty state until M6–M8 fill them |
| `ui/common/` | `rememberAlarmTimeFormatter` — the one 12/24-hour formatter the list, the editor and the ring screen share — and, since M6, the ringtone picker (`RingtonePickerState`, `RingtonePickerScreen`), which both editors need |
| `ui/alarms/` | the real Alarms tab (M5): the routes, the list's pure row builder and its source, the two ViewModels, the list, the editor, the M3 time-picker host, the repeat-day selector and the override pickers (the ringtone picker moved to `ui/common/` in M6) |
| `ui/timers/` | the real Timers tab (M6): the routes, the pure row builder and its source, the two ViewModels, the list, the row, the setup panel and its keypad, the editor |
| `ui/stopwatch/`, `ui/worldclock/` | one top-level tab each — a title bar and an empty state until M7–M8 fill them |
| `ui/ring/` | `AlarmRingActivity`, its ViewModel, `RingScreen` and the dismiss-challenge controls |
---
@@ -238,6 +247,17 @@ occurrence, so skipping it is dismissing it in advance.
| `sort_order` | INTEGER | indexed |
| `created_at`, `updated_at` | INTEGER | wall-clock epoch millis |
**M6 changes this table by not one column.** No migration, no version bump: the
database stays at v2. The only new persistent fact the Timers tab needs is
*when the current ring session began sounding*, and that is a **single record**,
which §5 of `docs/PLAN.md` sends to DataStore rather than to Room. It is not a
`timers` column for the three reasons this section already gives: volatile ring
state must not appear in M10's JSON backup of a timer, it must not survive the
timer's deletion, and the timer mapper's tests should stay about timers. It is
not a `timer_states` table either, because at most one timer ring sounds at a
time — so it is `timer_ring_sounding_since_millis` behind `TimerRingStateStore`
(§6).
### `world_clocks`
| Column | Type | Notes |
@@ -389,14 +409,47 @@ approximately-right rather than to never noticing a reboot. The id is stored as
one DataStore string, `last_boot_id`, and decoding is strict — anything
malformed reads as "no known previous boot", i.e. "repair".
The mirror image of the reboot repair is the **clock change**. A reboot kills
the monotonic anchors and leaves `endsAtWallClock` standing;
`ACTION_TIME_CHANGED`/`ACTION_TIMEZONE_CHANGED` does the opposite — the
monotonic anchors still hold and the wall-clock one has just become a lie. That
value is not decoration: it is the countdown notification's chronometer base and
the reboot fallback, so a stale one makes a running timer's notification read
*finished* and a reboot after the change ring the timer at the wrong minute. So
`TimerEngine.onSystemTimeOrZoneChanged` calls `reanchorWallClocks`, which
re-derives `endsAtWallClock` for every RUNNING row from what its **monotonic**
anchor says is left, writes nothing when the value has not moved, and leaves a
row whose anchor is already stale to `repairAfterReboot`. Nothing is
rescheduled: the expiry slot is `ELAPSED_REALTIME_WAKEUP`, so a clock change
cannot move it.
Every timer write is a read-modify-write inside **one transaction**
(`TimerDao.updateWithin`): the row is read, the domain rule applied and the
result written back before another writer can interleave. The ringing service
marking a timer expired and the user adding a minute to it therefore cannot
swallow each other's edit. `addTime` on a running timer rebases on the clamped
remaining and rewrites all three anchors from a single reading of both clocks,
so "+1 min" always grants a whole minute even when the end anchor has already
gone by.
swallow each other's edit.
`addTime` — "+1 min" — is one rule over all four states, and the extra is
measured **from the tap, never from an anchor that has gone by**:
| Before | After |
|---|---|
| `RUNNING` | still running; both end anchors rebased on `snapshot.remaining + extra`, from one reading of both clocks |
| `PAUSED` | still paused, `remaining + extra`. The user paused deliberately |
| `EXPIRED` | **`RUNNING` with exactly `extra` left**, all three anchors written, in the same one transaction |
| `IDLE` | nothing. A stale notification button is a silent no-op |
`duration_millis` is never moved on any branch, so `reset` still returns the
timer to the length the user configured — which is what makes a `timers` row its
own preset.
M6 rewrote the `EXPIRED` branch, which M2 left "paused, not running: the user
still has to press start". "+1 min" on a timer that has just rung is the single
most common gesture in a timer app, and a second tap on Start is a papercut.
Resuming in the same transaction is also the only way to avoid emitting an
intermediate `PAUSED` frame to the live pill and to the row — and the reasoning
is M2's own, generalised: *the user asked for `extra` more than zero, not
`extra` more than an anchor that has gone by.*
A `RUNNING` row missing either elapsed anchor is treated as corrupt, not as
stale-by-reboot: it reads back whatever it last banked, flagged stale. It never
@@ -432,7 +485,7 @@ presentation.**
## 6. Preferences
One DataStore file, `clockula_prefs`, behind floret-kit's typed `PrefStore`.
Three slices:
Four slices:
| Slice | Keys | Owner |
|---|---|---|
@@ -440,6 +493,7 @@ Three slices:
| Clock defaults (M2) | `default_snooze_minutes`, `default_snooze_limit`, `default_vibrate`, `default_volume_ramp_seconds`, `default_alarm_ringtone_uri`, `default_timer_ringtone_uri`, `default_dismiss_challenge`, `default_timer_duration_millis`, `home_zone_id` | `ClockPrefs`, surfaced as `SettingsPrefs.defaults: Flow<ClockDefaults>` |
| Stopwatch run record (M2) | `stopwatch_state`, `stopwatch_started_elapsed_millis`, `stopwatch_accumulated_millis`, `stopwatch_last_lap_cumulative_millis` | `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` |
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.
@@ -457,6 +511,21 @@ value the user could never have chosen. Unknown enum names degrade to their
default; a `home_zone_id` the device's tzdata no longer knows reads back as
absent; blank strings read back as `null`.
`timer_presets` is the same discipline over a *list*: one comma-joined string of
millis, sanitised both ways through `TimerPresets.sanitise` — non-numeric
entries dropped, anything outside 1 s…24 h **dropped rather than clamped**
(a clamp would silently turn one preset into another, possibly a duplicate),
de-duplicated, sorted ascending, capped at twelve. An **absent** key means the
built-in set (1, 2, 3, 5, 10, 15, 30 min, 1 h); an explicitly **empty** value
means the user removed them all, and is honoured. Adds and removes go through
`PrefStore.update`, so two concurrent edits cannot swallow each other.
`timer_ring_sounding_since_millis` is wall clock, deliberately: "how long has
this been making noise" is a question a reboot must not reset, which is why
`alarm_states.ringing_since` is wall clock too. §5's elapsed-realtime rule
governs the *countdown*, not the ring's age. Null — the key absent — means no
session, and the engine writes it only when the value changed.
`SettingsPrefs.defaults` and `StopwatchStateStore.run` are each built **once**
as a property, not per access, and are distinct-until-changed. A collector keyed
on the flow instance (as `collectAsStateWithLifecycle` is) would otherwise tear
@@ -472,7 +541,7 @@ before the first frame, and the alternative is an app only "clear data" can fix.
## 7. Dependency injection
Hilt, `SingletonComponent` throughout. Five app modules plus the kit's:
Hilt, `SingletonComponent` throughout. Seven app modules plus the kit's:
| Module | Provides |
|---|---|
@@ -482,13 +551,14 @@ Hilt, `SingletonComponent` throughout. Five app modules plus the kit's:
| `TimeModule` | `@Binds` for `WallClock`, `ElapsedRealtimeClock`, `ZoneProvider` and `BootIdProvider` |
| `AlarmModule` | `@Binds` for the alarm engine's four seams: `AlarmScheduler`, `AlarmCapabilities`, `RingCoordinator`, `AlarmNotifier` |
| `RingtoneModule` | `@Binds` for the two ringtone seams: `RingtoneCatalog`, `RingtonePreviewer` |
| `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") |
From floret-kit: `core-di`'s `CoroutinesModule` supplies `@IoDispatcher` and the
process-lifetime `@ApplicationScope` the receivers launch on, and `core-prefs`
supplies `PrefStore`, `Pref` and the appearance keys.
`BroadcastReceiver.onReceive` is abstract, so a Kotlin subclass cannot call
`super.onReceive(...)` — which is exactly where Hilt injects. The three
`super.onReceive(...)` — which is exactly where Hilt injects. All five
receivers therefore extend one concrete no-op base, `HiltBroadcastReceiver`; the
Hilt plugin rewrites each receiver's superclass to its generated `Hilt_…` class
and the `super` call lands on the injecting one.
@@ -508,10 +578,10 @@ block whichever thread asked.
## 8. Testing
JVM-first. 624 unit tests run in the gate; 28 instrumentation tests compile in
JVM-first. 843 unit tests run in the gate; 32 instrumentation tests compile in
it and run only on a device. There is **no Robolectric** and no plan for it:
everything that would have needed a shadow is behind one of the injected seams,
or is a pure function in `domain/alarm/`.
or is a pure function in `domain/alarm/`, `domain/ring/` or `domain/timer/`.
That posture had to be *earned* again when the UI arrived, not merely kept. The
shell's whole decision surface was pushed out of the composables and into pure
@@ -595,6 +665,46 @@ removing the row. **They have not been run**: no device is attached to the
machine this milestone was built on, so the gate compiles them and this
paragraph says so.
**M6 kept it for a second screen — and for a foreground service**, which is the
other place a project usually gives in. Every decision left the composables and
the service: `TimerReadings` (the app's one timer precedence, extracted from
M4's pill so the pill, the notification and the ring cannot disagree),
`TimerExpiry` (what is due, and what the single slot should hold),
`TimerRingPolicy` (the whole audio arbitration), `TimerNotificationPolicy`
(the subject, the count, the two actions, the alert-once rule), `TimerPresets`,
`TimerDurationEntry` (the keypad, as a state machine over a digit string) and
`TimerListRows`. `TimerService` holds **no policy and no state** beyond one
`alreadyAlerted` flag: it asks `TimerEngine.serviceState(...)` and actuates the
answer, and even the two `delay` amounts come from the engine — so the timings
are asserted in a JVM test.
Three new fakes complete the seam set in M3's shape: `FakeTimerScheduler` holds
the expiry slot **as the value it currently holds**, `FakeTimerServiceHandle`
records the up/down transitions **in order**, and `FakeAlarmRingStatus` is a
settable boolean behind a `MutableStateFlow`, so an arbitration test hands the
engine a `true` instead of building alarm state.
`testing/TimerEngineHarness.kt` is `AlarmEngineHarness`'s arrangement for the
other engine: the **real** `TimerEngine` over the real `TimerRepositoryImpl`,
the fake DAO, the three fake seams, the real `RebootRepair` and a real DataStore
under a `@TempDir` — with a counting `DataStore` wrapper, so "the session anchor
is persisted **once** across three reads" is assertable without reaching into
the store.
Three more `ArchitectureRulesTest` rules: `timer/TimerEngine.kt` joins the
Android-free list; no file under `ui/` *uses* `TimerScheduler`, `TimerService`,
`TimerRingPolicy` or `AlarmManager` (the mirror of the alarm rule — the screen
schedules nothing and decides no ring); and `MediaPlayer` appears only under
`ring/` and `data/ringtones/`, which makes "reuses M3's audio path" mechanical:
a third audio path cannot be added without the build failing.
What was left to the device is four more instrumentation tests: the keypad and
the bottom sheet really composing and really writing, the typed digits
surviving a real activity recreation (the only thing that proves
`rememberSaveable`), one row's Pause leaving a second running row alone, and a
real back press from the timer editor landing on a still-selected Timers tab.
**They have not been run**, for the same reason M5's have not: no device is
attached to the machine this milestone was built on.
Stack: JUnit 5 + Truth + Turbine + `kotlinx-coroutines-test`, with
`useJUnitPlatform()` and `isReturnDefaultValues = true`.
@@ -638,9 +748,16 @@ declared because a specific path would otherwise fail — silently, at 07:00.
| `RECEIVE_BOOT_COMPLETED` | AlarmManager keeps nothing across a reboot |
| `USE_FULL_SCREEN_INTENT` | the ring screen over the lock screen |
| `POST_NOTIFICATIONS` | the ring and snoozed notifications |
| `FOREGROUND_SERVICE`, `FOREGROUND_SERVICE_SYSTEM_EXEMPTED` | the ringing service |
| `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` | the alarm vibrates, and vibrates alone when no audio source opens |
| `VIBRATE` | an alarm and a timer both vibrate, and vibrate alone when no audio source opens |
**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
it minutes ago and is in the room, so the heads-up notification, the audio, the
pill's expired state and the tab's expired row are four ways to notice and none
of them seizes the device. The foreground-service, wake-lock, vibrate and
notification set above already covers the rest.
Neither of the two revocable ones can silence an alarm. A denied
`POST_NOTIFICATIONS` costs the user the notification and nothing else — the
@@ -649,7 +766,7 @@ degrades to a `PRIORITY_MAX`, `CATEGORY_ALARM` heads-up notification on the same
HIGH-importance channel, which still rings. M3 declares them; M4 asks for them
and M10's self-check screen explains them.
The ringing service's `android:foregroundServiceType` is **`systemExempted`**,
Both ringing services' `android:foregroundServiceType` is **`systemExempted`**,
which is the case Android's own `fgs-types-required` guidance names: an app
holding `SCHEDULE_EXACT_ALARM` or `USE_EXACT_ALARM` and using a foreground
service to continue alarms in the background. Not `mediaPlayback` — an alarm is
@@ -657,7 +774,7 @@ not the user's media session, and it would owe the store a media justification
and emphatically not `shortService`, which caps at about three minutes against a
ten-minute ring window.
The three receivers are `android:exported="false"`: a protected system broadcast
All five receivers are `android:exported="false"`: a protected system broadcast
is delivered to an unexported receiver anyway (this is how `androidx.work`
declares its own `RescheduleReceiver`), and a `PendingIntent` the app created is
delivered regardless — so nothing else can fake a fire.
@@ -666,8 +783,10 @@ delivered regardless — so nothing else can fake a fire.
storage that is unreadable before the first unlock.
Components: `MainActivity`, the non-exported `CrashReportActivity` and
`AlarmRingActivity`, the `AlarmRingService`, the three receivers, and AppCompat's
locale metadata holder service.
`AlarmRingActivity`, two foreground services (`AlarmRingService` and M6's
`TimerService`), five receivers — `AlarmFireReceiver`, `AlarmActionReceiver`,
`SystemEventReceiver`, `TimerExpiryReceiver`, `TimerActionReceiver` — and
AppCompat's locale metadata holder service.
---
@@ -676,15 +795,20 @@ locale metadata holder service.
| Not here | Milestone |
|---|---|
| The exact-alarm and full-screen-intent grant deep links, and any explanation of a denial. M4 asks for `POST_NOTIFICATIONS` once on first launch; the rest waits for the self-check screen, because a bare jump into system settings with no reason given is hostile | M10 |
| Every tab's real content beyond Alarms — creating and running a timer (M6), starting and lapping the stopwatch (M7), the zone list and analog face (M8). Those three keep M4's title bars and empty states | M6–M8 |
| Every tab's real content beyond Alarms and Timers — starting and lapping the stopwatch (M7), the zone list and analog face (M8). Those two keep M4's title bars and empty states | M7–M8 |
| Reordering alarms by hand. The list is ordered by time of day (§13) and `alarms` has no `sort_order` column, so this would be a schema change for a preference the time order already answers | not planned for v1 |
| A per-alarm auto-silence duration. Every other per-alarm setting became an override picker in M5; this one is still global and still `AlarmRing.AUTO_SILENCE_AFTER` | M10 at the earliest |
| Timers ringing — M6 reuses this milestone's audio path; M3 wires no timer to it | M6 |
| Stopwatch presentation, best/worst lap analysis (its post-reboot repair shipped in M3) | M7 |
| ICU city and zone display names, offsets, day differences | M8 |
| The `android.provider.AlarmClock` intent surface and its hostile-extra validation (`TimeOfDay.clamped` and `Zones.normalise` exist so M9 has something to call) | M9 |
| The self-check screen ("why might my alarm not ring?"), settings screen, JSON backup / SAF export | M10 |
| A user-configurable auto-silence duration, an unlimited-snooze option, per-alarm auto-silence | M10 at the earliest |
| Reordering timers by hand. `sort_order` and `TimerRepository.reorder` exist and stay caller-less: the list is in storage order, M5 gave the same answer for alarms, and a drag surface would also mean abandoning the `LazyColumn` | not planned for v1 |
| Per-timer vibrate, volume-ramp or dismiss-challenge overrides. `timers` has exactly one nullable settings column, its ringtone; four more would be a schema change for settings the roadmap does not ask for | M10 at the earliest |
| A "stop all" for several expired timers, and undo after a timer delete. Each expired timer is acknowledged on its own, and the delete confirmation asks before rather than after | not planned for v1 |
| A presets management screen, and editing `default_timer_duration_millis` or `default_timer_ringtone_uri`. M6 reads both and offers the two-gesture preset surface on the setup panel | M10 |
| A timer that ramps its volume, a dismiss challenge or a snooze for a timer | not planned for v1 |
| Surfacing `TimerSnapshot.anchorIsStale` in the UI. The boot repair makes it vanishingly rare and the value is already honest; a row does not say "estimated" in v1 | not planned for v1 |
| Screenshots and store listing polish | M11 |
Also absent by design: any seeded default data — no starter alarm and no home
@@ -852,6 +976,18 @@ would edit the user's own alarm volume and leave it edited.
`soundsAnyway` is true in all eight capability combinations — asserted
exhaustively, because that is the invariant the app lives on.
**M6 shares this chain with the timer path.** The `MediaPlayer` wrapper, the
`USAGE_ALARM` attributes, the transient focus request, the prepare-and-release
loop, the ramp stepper, the looped waveform, `AudioSourcePolicy`,
`RingFallbackPolicy` and `VolumeRamp` all moved to `ring/` and `domain/ring/`
and are used verbatim by both — so "silent" still means "vibration only" for a
timer too. What the alarm **keeps to itself** is the full-screen intent, the
ring activity, the dismiss challenge and the snooze; what the timer
parameterises is its own ringtone, a ramp of **zero seconds** (a timer is set by
someone awake, and a gentle start is just a quiet start) and its own
auto-silence constant. §14 has the rest, including which of the two wins the
one audio stream.
---
## 12. The app shell
@@ -913,13 +1049,35 @@ wins" rule the alarm engine's precedence already uses, so the app has one rule.
Timers outrank the stopwatch because a timer has a deadline: a missed timer costs
something, a missed stopwatch tick costs nothing.
**M6 moved rows 1–3 into `TimerReadings` and rewrote `LivePillSelector` to call
it.** The notification and the ring need the same answer — "which of several
running timers is this about" — and three copies of one comparator is how three
surfaces start disagreeing. `LivePillSelectorTest` passing **unmodified** is the
proof the extraction changed no behaviour; `LivePillSelectorPrecedenceTest`
cross-checks the two callers against each other. The pill keeps its own
three-valued `LivePillMode`, because it has a stopwatch to describe too, mapped
from `TimerMode` in one total `when`.
**M6 also re-pointed the pill's timer actions at `TimerEngine`.** M4 called
`TimerRepository.pause/start/reset` directly; after M6 there is an AlarmManager
registration and a foreground service that have to move with the state, so
pausing from the pill would otherwise leave the expiry slot pointing at a dead
deadline and the service posting a stale notification. Self-healing — the next
sweep would find nothing due — but wasteful and wrong. The stopwatch branches
keep calling `StopwatchRepository` until M7.
The mode and value come from `Timer.snapshotAt` / `StopwatchRun.snapshotAt`, not
from the stored row — so §5's whole elapsed-realtime-versus-wall-clock story,
stale anchor and all, is honoured once. The consequence that matters: a running
timer that reaches zero flips the pill to `EXPIRED` immediately, without waiting
for M6's expiry service to write `markExpired`. The pill never counts into
for the expiry sweep to write `markExpired`. The pill never counts into
negative time, and never reads `0:00` while claiming to run.
M4 noted that this two-writer case could not be demonstrated by hand. **It can
now**, and it is correct: the pill and the sweep both read through
`Timer.snapshotAt`, so the frame the pill shows before the write lands says
exactly what the row will say after it.
The pill is **not** a live region: at 1 Hz TalkBack would recite the countdown
for as long as it ran. The readout carries a full sentence as its content
description instead, read when focused and never announced unprompted.
@@ -1117,3 +1275,312 @@ Delete lives in the editor behind a confirmation that names the alarm, and there
is deliberately no undo chip: the delete happens on the editor, so an undo chip
would have to live on the list, and an accidentally deleted alarm is discovered
at 07:00 rather than now — the cheap moment to ask is before.
---
## 14. Timers
The section to read before changing anything timer-shaped. `AlarmEngine` and
`TimerEngine` are **peers**: neither drives the other, and the only thing they
share is one read-only question (below).
### The shape
`TimerEngine` is the timers' analogue of §11's engine: plain Kotlin, no
`android.*` import ever, reaching the platform through three seams it does not
implement — `TimerScheduler` (one AlarmManager slot), `TimerServiceHandle` (the
foreground service) and `AlarmRingStatus` (read-only: "is an alarm ringing").
It owns every write the pure policies ask for, and every public entry point is
serialised on one non-reentrant `Mutex`, because they arrive from threads that
know nothing about each other: a broadcast on `Dispatchers.Default`, the
service's collector, a ViewModel, `ClockulaApp`'s launch pass.
It is **not** driven by a Flow, for the same reason the alarm engine is not:
resolution writes state, so an engine that resynced on every repository emission
would re-trigger itself on its own writes. `resync()` is called explicitly.
The engine owns the **lifecycle** verbs — `start`, `pause`, `reset`, `addTime`,
`delete`, plus `onExpiryDue`/`resync`/`onBootCompleted` — because each of them
can move the slot, the service or the ring session, and every one of them is
reachable from a notification button with no ViewModel in sight. `delete` is on
the engine and not on a ViewModel-plus-`resync` path deliberately: deleting a
*ringing* thing and leaving the global ring slot sounding is a bug class M5
already met once, and the cheapest way not to have it again is to make the wrong
call impossible to write. **Configuration** edits (`rename`, `setDuration`,
`setRingtoneUri`) stay on `TimerRepository` and the editor calls them directly.
`TimerRepository` deliberately has **no generic `edit(id, transform)`** the way
`AlarmRepository` does: a timer row carries three derived anchors whose
consistency is the whole of §5, and a caller handed a `(Timer) -> Timer` can
break them. `setDuration` is refused unless the timer is `IDLE`.
### Expiry: one slot, two triggers, an idempotent sweep
There is **one** AlarmManager registration for every timer, holding the earliest
running deadline, and it carries **no timer id**. The fire is
`TimerEngine.onExpiryDue()`: mark *every* timer whose snapshot has reached zero
as `EXPIRED`, then re-register the next earliest. Three properties fall out, and
each is a test:
- a fire delivered **late** still expires everything that came due while it was
delayed;
- a fire delivered **early**, or for a timer the user has since stopped, finds
nothing due and writes nothing — so the trigger needs no grace window and no
watermark of its own: the anchors *are* the watermark;
- two timers expiring in the same second are **one pass**, not a race.
The base is **`ELAPSED_REALTIME_WAKEUP`**, mirroring the clock the domain
anchors on: **alarms are `RTC_WAKEUP`, timers are `ELAPSED_REALTIME_WAKEUP`.**
That is not tidiness, it is the requirement — a wall-clock registration moves
when the user changes the system time, so a `TIME_SET` would warp a running
timer's expiry, which §5 forbids. With this base a `TIME_SET` needs no
re-registration at all, and a test asserts the slot's value and every stored row
are unchanged across a three-hour clock jump in each direction.
`setExactAndAllowWhileIdle` is tried first and `setAndAllowWhileIdle` is the
fallback. It does **not** go through `SchedulingPolicy`, whose
`EXACT_ALARM_CLOCK` branch names `setAlarmClock` — and **a timer must never
populate `getNextAlarmClock()`**, because that slot draws the status-bar alarm
icon and the lockscreen line and belongs to the user's next *alarm*. Late is
survivable; silent is not, so there is no third branch.
*Known limitation, recorded rather than glossed:* `*AllowWhileIdle` alarms are
rate-limited per app while the device is idle. Clockula holds `USE_EXACT_ALARM`,
which exempts it, but a device that refuses the grant could deliver a
back-to-back sequence of short timers late. The idempotent sweep is one
mitigation; the second is that **the service, while alive, `delay`s to the same
deadline and calls the same `onExpiryDue()`**. Neither trigger has to be
reliable alone.
Both `delay` amounts come **from the engine** (`TimerServiceState.expiresIn` and
`silenceIn`), so the timings are asserted in a JVM test and the service only
ever does `delay(d)`. Both are floored at `TimerRing.WAKE_FLOOR` (100 ms) so a
rounding error cannot spin the loop.
### One foreground service, alive exactly while something is active
`TimerService` is a single `systemExempted` foreground service covering both
phases — the countdown and the ring. Its lifetime predicate is the live pill's:
**active, not running.** It is up while any timer is `RUNNING`, `PAUSED` or
`EXPIRED` and down when every timer is `IDLE` or gone. `PAUSED` is in the
predicate for §12's reason: dropping the notification on pause would leave a
user who paused from the shade with no way to resume without opening the app.
One "active" predicate in the whole app.
One service and not two, because a timer expiring while another runs must not
need a second one, and because the ring phase needs nothing the countdown phase
does not already have.
**The countdown holds no wake lock.** A forty-five-minute timer keeping the CPU
awake is a battery bug; the AlarmManager slot is what wakes the device. The
*ring* holds one, with a fifteen-minute timeout, exactly as `AlarmRingService`
does.
The service is a **readout and a control surface, never the timekeeper** — the
stored anchors and the AlarmManager slot are, and both survive its absence. So
`TimerServiceHandle.sync` wraps both the start and the stop in `runCatching`,
and the service holds no policy and no state of its own beyond one
`alreadyAlerted` flag. Its trigger flow is
`combine(timers, alarmIsRinging, defaults)` and deliberately **excludes** the
ring session's anchor, so the engine's own anchor write cannot re-trigger the
collector that caused it.
### The audio arbitration: an alarm wins, and the timer's ring is deferred, not lost
Two `USAGE_ALARM` streams at once is not a feature, and §11's ring service and
auto-silence backstop are single global slots. So a timer that expires while an
alarm is ringing **is still marked `EXPIRED`** — its data is truthful and the
user must learn the pasta is done — and its notification says so, but it **does
not open audio**. When the alarm's cycle closes, the timer starts sounding, and
*nothing re-arms it*: the decision is a pure function of current state rather
than an event.
```
TimerRingPolicy.decide(expired, defaults, alarmIsRinging, soundingSince, now)
```
`alarmIsRinging` arrives through one read-only seam, `AlarmRingStatus`, backed
by `AlarmStateRepository.states()`. The timer engine therefore never reaches
into `alarm_states` itself, never calls `AlarmEngine`, and never touches the
alarm's ring slot or its auto-silence registration — asserted. A test hands the
engine a boolean instead of building alarm state.
The reverse case is free and symmetric: an alarm firing over a sounding timer
flips the flag, the flow re-emits, and the timer's audio stops. `AlarmEngine`
writes `ringingSince` *before* it starts its own audio, so the flip leads the
sound.
### One ring session for however many timers, and one window
- **Expiry is a set.** Every due timer is marked `EXPIRED`.
- **The ring is one.** The subject is the first expired timer in §12's order,
and its ringtone is the one that plays.
- **Stop acknowledges one timer.** Stopping the subject resets *that* timer; if
another expired timer remains, the ring continues for the next subject. Each
timer is a separate thing the user set, and silently resetting all of them
because one was acknowledged destroys the information "which ones finished".
Two timers cost two taps, which is correct — there is no "stop all".
- **A session is one window.** It opens when the audio *actually starts
sounding* and closes when the expired set returns to empty. A second timer
expiring mid-session inherits the session's remaining window rather than
buying a fresh ten minutes — the conservative direction, and the same rule as
§11's "a reboot does not buy the alarm another ten minutes". The anchor is the
instant the audio *opened*, not the instant of expiry, so a long alarm ring in
front of it does not eat the timer's whole window.
- **A lapsed window does not silence the next timer.** A window that has run out
belongs to the timers the user chose to leave `EXPIRED`; a timer reaching zero
*after* it is a new event and opens a session of its own. The sweep is where
that is decided, because the sweep is the only place that knows a timer has
just come due: a non-empty due set drops a lapsed anchor, and the next pass
opens a fresh window with its own heads-up. Inheriting stays the rule for a
timer expiring *inside* a live window.
`TimerRing.AUTO_SILENCE_AFTER` is **ten minutes** — its own constant with the
same value as the alarm's, free to diverge, because coupling the two would make
one number answer two questions. The load-bearing difference from an alarm:
**auto-silence stops the noise and leaves the row `EXPIRED`.** An auto-silenced
alarm is a *missed* alarm and its cycle closes; an auto-silenced timer has still
finished, and the user coming back must be able to see that and add a minute
to it. Auto-silence is decided **before** suppression, so an alarm ringing over
a window that has run out cannot hand it a fresh one.
`TimerRing.RAMP_SECONDS` is **0**. The volume ramp exists so an alarm does not
jolt a sleeping person awake; a timer is set by someone awake who wants to know
*now*, and a gentle start is just a quiet start. `VolumeRamp.levelAt(_, 0s)`
already returns `1f`, so "parameterised" means literally one different argument
to the same player.
### The notification: one channel, one id, alert once
The subject is §12's; the rest are a count.
| Subject reads | Title | Body | Actions |
|---|---|---|---|
| `RUNNING` | the label, or "Timer" | the **platform chronometer** counting down | Pause · +1 min |
| `PAUSED` | the label | "Paused · 4:32" | Resume · Reset |
| `EXPIRED` | the label | "Finished" | Stop · +1 min |
plus "+2 more timers" when other active timers exist. Two actions at most, and
they are the *same pair* the row offers for that mode — one vocabulary, two
surfaces. What this gives up knowingly: per-timer controls in the shade for the
timers that are not the subject. Those are one tap away in the app, and the
alternative — a notification group with one entry per timer — multiplies ids,
request codes and orphan-on-kill cases for a rare case.
`setUsesChronometer(true)` + `setChronometerCountDown(true)` + `setWhen(base)`
means the **system** renders the ticking, so the service posts once per state
change instead of once per second for forty-five minutes. The base is
`timers.ends_at_wall_clock_millis`, which makes it the single wall-clock value
in the whole timer path, with one honest consequence: a user changing the system
clock while a timer runs leaves that stored base — and so the notification's
readout — pointing at the wrong instant, while the timer itself does not move at
all. So `SystemEventReceiver`'s `TIME_SET`/`TIMEZONE_CHANGED` branch calls
`timerEngine.onSystemTimeOrZoneChanged()` — **not to reschedule anything**, but
to re-anchor `ends_at_wall_clock_millis` from the monotonic anchor (§5) and
re-post with a base that is true again. A running row with no wall-clock end (a
corrupt row) falls back to a static readout rather than a chronometer counting
to a wrong instant.
One channel (`timers`, `IMPORTANCE_HIGH`, sound and vibration off — the service
owns both), one id, and **only the first post of a ring session may alert**. So a
countdown update never heads-ups and never buzzes, an expiry heads-ups exactly
once, and there is no channel gymnastics and no second notification. Every
non-alerting post carries `setSilent(true)` as well as `setOnlyAlertOnce(true)`,
because `setOnlyAlertOnce` speaks only about *updates* to a notification already
on screen — on a high-importance channel the first post of a countdown, and the
placeholder the service goes foreground with, would otherwise pop a banner. The
rule is a pure function (`TimerNotificationState.alertOnce`); the service owns
the flag that spends it and scopes it to the session's window, so it resets both
when the decision goes `Silent` and when a new window opens.
Every action is a `PendingIntent.getBroadcast` into `TimerActionReceiver`, in
the shape `AlarmActionReceiver` already has: `goAsync()`, `@ApplicationScope`,
one call into the engine, `finish()`. Deliberately **not**
`PendingIntent.getService` (a background foreground-service start can be
refused; a broadcast cannot) and not an activity (a control should not have to
open the app). Nothing is carried across but the timer id, which can be stale —
so **every engine verb is guarded by the state it makes sense for**, a mismatch
is a silent no-op, and the notification is re-posted from the truth afterwards.
Request codes use the `base * 100_000 + (id % 100_000)` stride
`RingNotifications` already uses, and a test asserts the **union** of alarm and
timer codes is distinct, because that collision is how one `PendingIntent`
silently eats another's extras.
Tapping the notification opens the **Timers tab**, through the smallest honest
deep link: the content intent carries `TimerIntents.ACTION_SHOW_TIMERS`,
`MainActivity` reads it in `onCreate` **and** `onNewIntent` into one
`mutableStateOf`, and `ClockulaShell` consumes it once by replaying
`ShellNavigation.onTabSelected` — the same command a tab tap produces.
`startDestination` stays **Alarms**: it is the `popUpTo` anchor and the root of
M4's asserted back policy, so changing it for a notification tap would rewrite
that policy. The pure part is `ShellNavigation.tabForAction(action)`, which M9
extends with the rest of the `AlarmClock` contract.
### Process death and reboot
- **Process death** costs nothing. The countdown lives in
`ends_at_elapsed_realtime` and the AlarmManager registration survives the
process. `ClockulaApp.onCreate` calls `timerEngine.onBootCompleted()` after
the alarm engine's pass, which sweeps, re-registers and re-syncs the service.
If the service itself was killed, the expiry broadcast starts it again.
- **Reboot** is §5's `RebootRepair`, and M6 gave it a caller that *rings*:
`TimerEngine.onBootCompleted()` calls `repairIfRebooted()` inside its own
lock, then sweeps — so a timer whose end passed during the reboot is expired
by the repair and **sounds when the device comes back**. The boot-id gate
makes the second call of a boot a no-op, so neither engine depends on the
other's ordering.
- **`MY_PACKAGE_REPLACED`** clears AlarmManager, so it calls `resync()`.
### The tab
A `LazyColumn` in storage order — `sortOrder`, then `id`, which is exactly what
the repository emits, so the screen re-asserts the order rather than inventing
one. **An expired timer does not jump to the top**: a row that moves under a
thumb is worse than a row the user has to look for, which is the answer M5 gave
for alarms. The pill and the notification pick a subject; the list does not
reorder for it.
Setting a timer is a keypad, a row of preset chips, the readout and one
**Start** — and the same panel is hosted in a `ModalBottomSheet` over a
non-empty list and *inline* on an empty one, where it **is** the empty state, so
there is no "no timers yet" card to look at and then dismiss. Deliberately not
M5's create-then-navigate: an alarm is live the moment it exists, so creating it
first is honest, while a timer has a natural commit point and a row created by
an accidental FAB tap would be litter. There is still no sentinel id, because
the panel is not bound to a row at all.
The typed digits and the sheet's open flag live in the **composable** as
`rememberSaveable`, not in a ViewModel, so they survive rotation *and* process
death; the pure `TimerDurationEntry` is derived from the saved digit string, and
`Start` is guarded by a ViewModel re-entrancy flag. `TimerDurationEntry` shifts
digits in from the right and reads them as `h*3600 + m*60 + s` **without
clamping minutes or seconds to 59**, so `0:00:99` is 99 seconds — Google Clock's
forgiving behaviour, and a pure function with obvious tests. `canStart` is false
outside 1 s…24 h, which is `ClockPrefs`' own clamp, so nothing the user can
choose is clamped away on the next read.
Presets are **app-wide** quick-start durations in DataStore (§6), not a property
of one row: a `timers` row already *is* its own preset, because it persists and
`reset` returns it to its configured duration. The whole management surface is
two gestures on the setup panel — a Save chip when the typed duration is not
already one, and each chip's own remove affordance. A new timer's pre-filled
duration is `ClockDefaults.timerDuration`, the `default_timer_duration_millis`
preference M2 built and nothing had called until now.
A timer has exactly **one** per-setting override, its ringtone, because `timers`
has exactly one nullable settings column. Vibrate and the (zero) ramp come from
`ClockDefaults`. The picker is M5's, moved to `ui/common/`, so the row inherits
all of its behaviour for free: "App default" naming what it resolves to, Silent
as an explicit sentinel that forces vibration, a SAF-picked file that is still
stored when its grant cannot be made persistent, and an unreadable sound
**reported, never rewritten**.
The expressive parts are `LinearWavyProgressIndicator` across each card — whose
**amplitude is the state**: the default wave while running, flat while paused or
finished — a kit `GroupedSurface` per card with `positionOf(index, count)`, a
`ButtonGroup` of `FilledTonalButton`s with the press-widening `animateWidth`,
and `tertiaryContainer` for an expired card, which is *exactly* the token the
live pill already uses for an expired timer, so the two surfaces agree. The
readout is `headlineLarge` and the setup panel's entry is `displayMedium` —
plain scale roles, because `ui/theme/Type.kt` says the big-readout ramp gets
settled against a working stopwatch in M7, not guessed at in the scaffolding.