Files
clockula/docs/ARCHITECTURE.md
T
makiolaj 319d176ae8 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.
2026-09-12 16:49:05 +02:00

1587 lines
93 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Clockula — architecture
How Clockula is built **today**, after M3. Where something does not exist yet,
this document says so and names the milestone that builds it, rather than
describing a plan as though it were code. `PLAN.md` is the "why"; this is the
"what, right now".
---
## 1. The thesis
**Clockula owns its storage, and pays for that privilege with a hard seam.**
Its siblings read a platform provider — Calendula the calendar, Agendula
tasks — so their data is open by construction. There is no open provider behind
a clock (`PLAN.md` §0), so Clockula keeps its own SQLite database. The honest
asterisk is that "open data" here has to be *earned* rather than inherited: by a
committed, reviewable schema; by a JSON export the user can actually take with
them (M10); and by a boundary strict enough that the storage engine stays an
implementation detail rather than becoming the app's shape.
The discipline that keeps that honest is one rule: **only `data/` knows Room
exists**. Everything above it talks to four repository interfaces and to
plain-Kotlin models. `ArchitectureRulesTest` fails the build if anyone reaches
through.
---
## 2. Layers
```
┌─────────────────────────────────────────────────────────┐
│ UI — Compose (M4+) │
│ today: MainActivity + ui/theme only │
└───────────────────────────┬─────────────────────────────┘
│ plain-Kotlin models, Flows
┌───────────────────────────┴─────────────────────────────┐
│ domain/ — Alarm, Timer, WorldClock, Stopwatch, │
│ ClockDefaults, WallClock, ElapsedRealtimeClock │
│ no android.*, no androidx.*, no Room │
└───────────────────────────┬─────────────────────────────┘
│
┌───────────────────────────┴─────────────────────────────┐
│ data/…/…Repository — four interfaces │
│ AlarmRepository · TimerRepository · │
│ WorldClockRepository · StopwatchRepository │
└──────────────┬──────────────────────────┬───────────────┘
│ entities │ typed prefs
┌──────────────┴─────────────┐ ┌─────────┴───────────────┐
│ DAOs + mappers (Room) │ │ PrefStore (DataStore) │
│ ClockulaDatabase v1 │ │ clockula_prefs │
└──────────────┬─────────────┘ └─────────┬───────────────┘
│ │
SQLite preferences_pb
```
The seam is the repository interface list. Above it there is no `AlarmEntity`,
no `@Query`, no `androidx.room` import and no `ClockulaDatabase` reference —
`ArchitectureRulesTest` greps the whole main source set for exactly those names
and for `import android.`/`import androidx.` inside `domain/`, and fails with
the offending paths listed. A grep is a blunter tool than a compiler, but it is
the one that runs in the gate.
There is deliberately **no `internal`** on anything the data layer adds. Room's
KSP processor generates Java against the DAO types and Kotlin mangles
`internal` member names; Clockula is a single Gradle module, so `internal` would
buy no encapsulation the architecture test does not already buy, and would buy a
class of build failures.
---
## 3. Modules and packages
One app module, `:app`, plus floret-kit as a git submodule wired in as a Gradle
composite build (`includeBuild("floret-kit")`, consumed as
`de.jeanlucmakiola.floret:<module>`). M3 added no Gradle module and touched no
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 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)`, `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`, `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 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 — 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 |
---
## 4. The data model
`ClockulaDatabase` is at **version 2** with five tables. Every column is a
primitive: `Long`, `Int`, `String` or `Boolean`. There are **no Room
`TypeConverter`s** — enums are stored as `Enum.name` in a `TEXT` column,
instants and durations as milliseconds, the repeat set as an `INTEGER` bitmask.
That puts the whole entity↔domain translation inside the mappers, which are
plain JVM objects that a unit test can feed a corrupt row. A converter would
move the same translation into generated code, where an unknown enum name throws
*inside a cursor read* — which is a crashed list, not a degraded row.
Column names are snake_case via `@ColumnInfo`, so the SQL, the exported schema
JSON and M10's JSON backup all read the same vocabulary in a diff.
### `alarms`
| Column | Type | Notes |
|---|---|---|
| `id` | INTEGER pk | autogenerated |
| `hour`, `minute` | INTEGER | read back through `TimeOfDay.clamped` |
| `label` | TEXT | |
| `enabled` | INTEGER | indexed — `enabled()` filters on it |
| `repeat_days` | INTEGER | 7-bit mask, see below |
| `skip_next_occurrence` | INTEGER | |
| `ringtone_uri`, `vibrate`, `snooze_minutes`, `snooze_limit`, `volume_ramp_seconds`, `dismiss_challenge` | nullable | **overrides**; `NULL` = inherit `ClockDefaults`. For `ringtone_uri`: `NULL` = inherit, `clockula://silent` = deliberate silence (§13), anything else a content URI |
| `created_at`, `updated_at` | INTEGER | wall-clock epoch millis |
Per-alarm settings are nullable *overrides*, never concrete copies of the
defaults. Storing concrete values would mean a later change to a default
silently failed to reach alarms the user never customised — the opposite of what
"default" means. `Alarm.resolveSettings(defaults)` is the pure function that
collapses the two, and it uses `?:` throughout: a stored `false` for `vibrate`
is a choice, not an absent value.
`AlarmDao` is an **`abstract class`** rather than an interface, so it can carry
a `@Transaction open suspend fun updateWithin(id, transform)` — a read, a
transform and a write as one unit, mirroring `TimerDao.updateWithin`.
`AlarmRepository.edit(id, transform)` is its domain face: it stamps `updated_at`
from the wall clock and forces the id, so a transform can change a field but
never move a row. The editor writes through it exclusively, because the engine
can call `setEnabled(id, false)` underneath an open editor when a one-shot
alarm's cycle closes — and a whole-row write from a stale read would re-enable
an alarm that had just rung. **M5 changes no schema**: no column was added, no
version bumped, no migration written; a DAO becoming an abstract class changes
no SQL.
### `alarm_states`
Volatile ring state, one row per alarm, added at **v2** (M3).
| Column | Type | Notes |
|---|---|---|
| `alarm_id` | INTEGER pk | also a `FOREIGN KEY … ON DELETE CASCADE` to `alarms(id)` |
| `snoozed_until` | INTEGER? | the absolute instant a snooze is due |
| `snooze_count` | INTEGER | snoozes taken in the current ring cycle |
| `ringing_since` | INTEGER? | non-null exactly while the alarm is ringing |
| `handled_occurrence` | INTEGER? | the occurrence of the most recent ring cycle |
| `skipped_occurrence` | INTEGER? | the occurrence `skip_next_occurrence` is armed on |
It is a table and not five more columns on `alarms` for three reasons: volatile
ring state must not appear in M10's JSON backup of an alarm, deleting an alarm
must take its ring state with it (hence the cascade), and the alarm mapper's
tests stay about alarms.
**A missing row is not an error.** `AlarmStateRepository.state(id)` returns
`AlarmRingState.initial(id)` — every instant null, count 0 — and nothing is
written until there is something to write. `upcoming()` therefore resolves every
alarm without creating a single row.
### The two rules that keep re-resolution honest
An alarm's next fire time is never stored; it is re-resolved from the local
time-of-day and the current zone on every fire, boot, `TIME_SET`, zone change
and edit (`PLAN.md` §4). Two failure modes fall out of that, and each has
exactly one rule. They are the app's least obvious invariants, and the ones a
future reader will otherwise "simplify" away.
**The fire-grace window.** Candidates are generated strictly after
`now - AlarmRing.FIRE_GRACE` (2 minutes). A fire delayed by doze, a slow boot or
a `TIME_SET` nudge still counts, and a device powered on at 07:01 still rings
its 07:00 alarm. An alarm missed by hours does *not* ring hours later.
**The `handled_occurrence` watermark.** Written the moment an alarm fires. A
candidate is suppressed **iff** `candidate <= handledOccurrence` **and**
`candidate <= now`. The second half is load-bearing: without it, a user who set
the clock forward, let an alarm fire, then set it back would have every future
occurrence suppressed forever. With it, only a past-or-present candidate can be
suppressed — the watermark can silence a re-fire, but it can never silence the
future.
One column, three bugs: "do not ring the same occurrence twice", "a backwards
`TIME_SET` must not re-ring a dismissed alarm" and "a snoozed alarm's natural
occurrence must stay quiet" are the same rule.
### The skip watermark
`alarms.skip_next_occurrence` is the user-facing boolean;
`alarm_states.skipped_occurrence` is the concrete instant the skip is armed on.
The flag alone is unusable: resolve at 06:00 on Monday for a daily 07:00 alarm
and you correctly get Tuesday, but resolve again at 08:00 — after the skipped
occurrence has gone by — and a naive "drop the first candidate" gives Wednesday,
so the skip eats a second alarm. With the watermark:
- flag set, no watermark ⇒ arm it on the first eligible candidate and fire the
one after;
- watermark still in the future ⇒ fire the first candidate after it; write
nothing;
- watermark at or before now ⇒ consumed: clear the flag and the watermark, and
*still* return the first candidate after it, so the skipped occurrence cannot
ring on its way out through the grace window.
Arming is deferred while a snooze is pending, and a **non-repeating** alarm with
the flag set is *disabled* rather than skipped: a one-shot has exactly one
occurrence, so skipping it is dismissing it in advance.
### `timers`
| Column | Type | Notes |
|---|---|---|
| `id` | INTEGER pk | |
| `label` | TEXT | |
| `duration_millis` | INTEGER | the configured length; `addTime` never moves it |
| `state` | TEXT | `IDLE`/`RUNNING`/`PAUSED`/`EXPIRED`; unknown degrades to `IDLE` |
| `remaining_millis` | INTEGER | authoritative when not `RUNNING` |
| `started_at_elapsed_realtime_millis` | INTEGER? | `RUNNING` only |
| `ends_at_elapsed_realtime_millis` | INTEGER? | `RUNNING` only — **authoritative** |
| `ends_at_wall_clock_millis` | INTEGER? | `RUNNING` only — **post-reboot fallback only** |
| `ringtone_uri` | TEXT? | `NULL` = inherit `ClockDefaults.timerRingtoneUri` |
| `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 |
|---|---|---|
| `id` | INTEGER pk | |
| `zone_id` | TEXT | **unique index**; IANA zone id |
| `label` | TEXT? | `NULL` = show the ICU-resolved city name (M8) |
| `sort_order` | INTEGER | indexed |
`zone_id` being unique is what makes `WorldClockRepository.add` idempotent: it
trims the id, rejects a blank or non-IANA one with `IllegalArgumentException`,
and returns the *existing* row's id when the zone is already there. The lookup,
the sort-order read and the insert happen in one transaction
(`WorldClockDao.addIfAbsent`), so no other writer can slip a row in — or delete
the row that won — between them, and the id handed back is always a row that
exists rather than the insert's `-1` sentinel. Validity
means membership of `java.time.ZoneId.getAvailableZoneIds()`, so `UTC` is
accepted and fixed offsets like `+02:00` are not — IANA zone ids, not a bespoke
city table (`PLAN.md` §5). A zone the device's tzdata later drops is **kept**,
not blanked; `WorldClock.isKnownZone` reports it and the UI can show it as
broken. Losing the user's row silently would be worse.
### `stopwatch_laps`
| Column | Type | Notes |
|---|---|---|
| `id` | INTEGER pk | not exposed to the domain |
| `lap_index` | INTEGER | **unique index**; 1-based, and the lap's identity |
| `split_millis`, `cumulative_millis` | INTEGER | |
`LapDao.appendLap` is `@Transaction`: it reads the previous lap, derives the
index and the split, and inserts — so two concurrent laps cannot collide on the
unique index.
### The repeat mask
Bit `n` is ISO day `n + 1`: **Monday is bit 0, Sunday bit 6**, so the conversion
is `1 shl (day.value - 1)` against `java.time.DayOfWeek.value` with no lookup
table. `RepeatDays`'s constructor is private and every entry point sanitises, so
a corrupt stored mask (high bits, negative) can only ever *narrow* to the seven
valid bits.
Note for M9: the platform `AlarmClock.EXTRA_DAYS` contract speaks
`java.util.Calendar` constants (Sunday = 1 … Saturday = 7). **That translation
happens at the intent boundary in M9, never in storage.**
### Reading is forgiving
Every mapper read degrades rather than throws: out-of-range hours clamp, unknown
enum names fall back through `core-prefs`' `toEnum`, blank strings read back as
`null`, negative millis clamp to zero. An absent `dismiss_challenge` stays
`null` (it is an unset override); a *present but unknown* one degrades to `NONE`.
### The schema is the contract
The exported JSON lives at
`app/schemas/de.jeanlucmakiola.clockula.data.db.ClockulaDatabase/`, one file per
version, all committed, and the directory is also wired in as an `androidTest`
asset directory so `MigrationTestHelper` can open a v1 database on device.
`SchemaExportTest` fails the JVM test run if a version goes missing — the
previous one is never regenerated away. There is **no
`fallbackToDestructiveMigration`**: `PLAN.md` §12 requires tested migrations
from v1, and a destructive fallback would quietly eat a user's alarms.
`Migrations.ALL` is the single list the database builder is handed and the list
the tests assert on. `MIGRATION_1_2` is one `CREATE TABLE alarm_states`, copied
verbatim from the exported schema's `createSql`, and the instrumentation test
runs it against a real v1 database and *validates* the result against `2.json` —
a migration that fails eats the user's alarms, so it is proved rather than
eyeballed.
---
## 5. The two clocks
`PLAN.md` §5 calls the wall-clock / elapsed-realtime distinction "the single
easiest thing to get wrong in a clock app". This is the section to read before
touching anything time-shaped.
```kotlin
interface WallClock { fun now(): Instant }
interface ElapsedRealtimeClock { fun elapsedRealtime(): Duration }
```
Both are **injected everywhere and never called statically** — that is the only
reason the distinction can be tested from both sides. Their Android
implementations are two one-line classes in `data/time/`
(`System.currentTimeMillis()` and `SystemClock.elapsedRealtime()`). Any new API
here must name which clock it takes in its parameter list; a bare `now()` is
forbidden.
| Uses the wall clock | Uses elapsed realtime |
|---|---|
| alarms (they are calendar facts) | a running timer's countdown |
| `created_at` / `updated_at` on every row | the stopwatch |
| a running timer's post-reboot fallback, and nothing else | |
### A running timer's three anchors
`started_at_elapsed_realtime_millis` is the monotonic instant of the last
start/resume; `ends_at_elapsed_realtime_millis` is when it expires and is
**authoritative**; `ends_at_wall_clock_millis` is the same instant on the wall
clock and is a **fallback only**.
`Timer.snapshotAt(elapsedRealtime, wallClock)` decides staleness
**monotonically, without touching the wall clock at all**:
> `elapsedRealtime() < startedAtElapsedRealtime` ⇒ this is a different boot,
> because `elapsedRealtime()` never decreases within one boot.
So a user moving the system clock cannot make the anchor look stale and cannot
warp a running timer — forwards or backwards. The wall-clock value is read
*only* when that monotonic test says "different boot", and then only to answer
"approximately how much is left", with `TimerSnapshot.anchorIsStale = true` so
the UI can say so. Without it, a reboot would strand every running timer.
**Known blind spot, accepted:** the monotonic test only catches a reboot while
the *new* boot's uptime is still below the old `startedAtElapsedRealtime`. Once
the device has been up longer than that, the same comparison says "same boot"
and the row is resolved against a dead pre-reboot anchor, reading as **live and
not stale** — `endsAtWallClock` is never consulted.
This window opens sooner than "after it would have expired". A timer started two
minutes into a boot re-enters it about two minutes into the *next* boot: a
30-minute timer started at uptime 2 min, with the device rebooted ten minutes
later, reads as live and non-stale from uptime 2 min of the new boot onwards, and
counts down from a number that means nothing. So: **a stale anchor can read as
live, well before expiry.** The cost is accepted here because the alternative —
consulting the wall clock to decide staleness — would let a user moving the
system clock warp a running timer, which `PLAN.md` §5 forbids outright.
Closing it properly needs a persisted boot identifier, and **M3 added one**.
`RebootRepair` runs at the top of every boot — from `SystemEventReceiver`'s
`BOOT_COMPLETED` and again from `ClockulaApp.onCreate`, so a broadcast the
system never delivered is still caught — and rewrites every running timer from
`endsAtWallClock` before the new uptime can climb past any stored anchor. A
timer already past its wall-clock end becomes `EXPIRED`; one with no wall-clock
fallback is paused at what it banked.
The gate is the boot id, not a flag in memory: `BootId` is
`Settings.Global.BOOT_COUNT` (API 24+, no permission), and two ids that both
have a count are the same boot exactly when the counts match. When either side
has no count — a device that will not give `BOOT_COUNT` up — it falls back to
comparing `wallClock.now() - elapsedRealtime()`, the instant the device booted,
within one minute. That fallback is **best-effort and documented as such**: the
derived instant drifts under NTP correction, which is what the tolerance
absorbs. It costs nine lines and means an unreadable `BOOT_COUNT` degrades to
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` — "+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
throws — a throw inside a list read is a crashed screen.
### The stopwatch has no fallback, on purpose
`StopwatchRun` carries **no wall-clock field whatsoever**, and `StopwatchPrefs`
stores no wall-clock value (a test asserts that on the stored bytes). A
stopwatch that spans a reboot has lost information nobody can reconstruct, and
unlike a timer there is nothing to ring. A stale run therefore resolves to
**paused at the accumulated time**, discarding the lost segment, with
`anchorIsStale = true`. Discarding is the honest answer; guessing would be a lie
displayed to two decimal places.
`StopwatchRun.snapshotAt` decides staleness with the same monotonic test as the
timer, and had the same blind spot: once the new boot's uptime passes the stored
`startedAtElapsedRealtime`, the reboot goes unnoticed and the run reports
`accumulated + (elapsedRealtime - startedAtElapsedRealtime)` as **live and not
stale** — a segment it never actually ran.
**M3 closes it.** The same boot gate that repairs timers also calls
`StopwatchRepository.pauseAfterReboot()`: a `RUNNING` run is written back
`PAUSED` at exactly its banked `accumulated`, with the start anchor dropped and
nothing invented in its place. A paused or idle run is not written at all. That
is three lines on top of machinery the alarm engine needs anyway, and leaving a
knowingly-fabricated elapsed reading on screen for two more milestones was not
defensible. **M3 owns the stopwatch's post-reboot repair; M7 owns its
presentation.**
---
## 6. Preferences
One DataStore file, `clockula_prefs`, behind floret-kit's typed `PrefStore`.
Four slices:
| Slice | Keys | Owner |
|---|---|---|
| Appearance (M1) | `theme_mode`, `dynamic_color` | `AppearancePrefs` in `core-prefs` — the family's shared key names |
| 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.
It is also read and written as a *record*: `StopwatchPrefs.read`/`write` map all
four keys in one snapshot and one `PrefStore.edit` transaction, and
`StopwatchStateStore.run` maps that snapshot rather than combining four key
flows. A state and its anchor only mean anything together, so no reader — and no
process killed mid-write — ever sees `RUNNING` without its start anchor.
**Clamp on read as well as on write.** Every bounded value goes through
`Pref.map`, which applies the same clamp in both directions: snooze 1–60
minutes, snooze limit 0–10, volume ramp 0–60 s, timer duration 1 s–24 h. A
hand-edited file or a restore from a future version therefore cannot produce a
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
down and restart the DataStore collection on every recomposition. Each is
composed from the individual key flows, so writing an unrelated preference never
re-emits them.
A corrupt `preferences_pb` is replaced with empty preferences rather than
throwing `CorruptionException` out of every launch — these values feed the theme
before the first frame, and the alternative is an app only "clear data" can fix.
---
## 7. Dependency injection
Hilt, `SingletonComponent` throughout. Seven app modules plus the kit's:
| Module | Provides |
|---|---|
| `DataModule` | the `DataStore<Preferences>` (built explicitly so its scope runs on the kit's `@IoDispatcher`, with the corruption handler) and `PrefStore` |
| `DatabaseModule` | `ClockulaDatabase` (`@Singleton`, built with `.addMigrations(*Migrations.ALL)`) and the five DAOs |
| `RepositoryModule` | `@Binds` for the five repository interfaces |
| `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. 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.
Repositories take **no `CoroutineDispatcher`**. Room already dispatches suspend
queries onto its own executor and runs Flow queries off the main thread, so a
`withContext(io)` wrapper around a DAO call would be cargo cult. The DataStore
half already runs on `@IoDispatcher`, wired once in `DataModule`.
`SystemRingtoneCatalog` is the one documented exception: it takes
`@IoDispatcher`, because a raw `ContentResolver` query — and
`RingtoneManager`'s cursor, and `MediaPlayer.prepare` in
`SystemRingtonePreviewer` — dispatches nothing of its own and would otherwise
block whichever thread asked.
---
## 8. Testing
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/`, `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
Kotlin: `ShellNavigation` (what a tab tap does to the back stack, and where back
goes), `LivePillSelector` (which of several running things the pill is about),
`ClockFormat` (the readout) and `ChallengeGate` + `MathProblems` (the barrier in
front of a dismissal, and the promise that it always opens). What is left in the
composables is `Text(...)` over those values. The two ViewModels that need a main
dispatcher get one from a twenty-line JUnit 5 `MainDispatcherExtension`.
What genuinely needs hardware is written as the nine new instrumentation tests
and honestly labelled as such: a window really appearing over a lock screen with
`FLAG_KEEP_SCREEN_ON` set, a real system back press, a real activity recreation,
and a TalkBack-shaped accessibility click completing the hold challenge.
- **Fakes over `MutableStateFlow`.** The five fake DAOs are backed by a
`MutableStateFlow<List<Entity>>`, so a write re-emits on the observing flow
exactly as Room's would. The three abstract DAOs are *extended*, not
reimplemented, so their real `@Transaction` bodies (`reorder`, `appendLap`,
`updateWithin`, `addIfAbsent`) are the ones under test. The fakes also mirror the constraints SQLite
enforces: a duplicate `zone_id` insert returns `-1`, a duplicate `lap_index`
throws, ring state for an alarm that is not there fails the foreign key, and
an explicit id moves the row-id allocator past it as SQLite's would.
- **Six fakes for the engine's seams.** `FakeAlarmScheduler` holds each of the
two AlarmManager slots as the value it currently holds, so a test asserts on
what the system would be showing rather than on a call log;
`FakeRingCoordinator` records start/stop *in order*, because the order is the
take-over contract; `FakeAlarmNotifier`, `FakeAlarmCapabilities`,
`FakeZoneProvider` (a zone a test can change under a scheduled alarm) and
`FakeBootIdProvider` complete the set.
- **A real DataStore on a temp file.** The preference and stopwatch tests write
an actual `preferences_pb` under a JUnit 5 `@TempDir`, which is what makes
"the run survives process death" a real assertion — a second repository is
constructed over the same file — rather than a fake's memory.
- **Injected fake clocks.** `FakeWallClock` moves by hand, including backwards,
as a user can; `FakeElapsedRealtimeClock` has an explicit `reboot()`. The
headline test starts a timer, jumps the wall clock three hours each way, and
asserts both that the remaining time does not move and that the stored row is
byte-for-byte unchanged.
- **Pure functions carry the hard parts.** DST, the resolver's precedence, the
volume ramp and the "never to silence" policies are all `object`s with no
collaborators, so the milestone's riskiest behaviour is asserted a hundred
times over with hand-built inputs — including all 128 repeat masks and four
zones with awkward transitions.
- **Instrumentation-only** is the half a fake cannot prove: generated SQL,
unique indices, real `@Transaction` behaviour, the foreign key cascading, the
v1 → v2 migration validated against the exported schema, and the database
opening at version 2.
- **`ArchitectureRulesTest`** greps the main source set for Room leakage above
`data/` and Android imports inside `domain/`, `alarm/AlarmEngine.kt` and
`system/`. It is the boundary of §2 made mechanical — and the engine's
testability is a build-gate fact rather than a habit.
**M5 kept the posture for a whole form**, which is where a screen usually
reaches for Robolectric. Everything a tap decides was pushed out of the
composables: `AlarmListRows` (what the list shows, in what order, which row is
"next"), `NextFireFormat` ("in 9h 12m"), `RepeatDaysSummary`/`RepeatDaysOrder`
("Weekdays", and which day column comes first), `AlarmDefaults` (a new alarm's
time, resolved through a spring-forward night), `AlarmOverrides` (inherited or
chosen), `RingtonePicker` (which rows exist and which is checked),
`AudioSourcePolicy` (the fallback order, silence included) and `AlarmRoutes`
(the route strings). Every write, every reschedule and every preview is a method
on one of the two ViewModels, which the tests build over the **real**
`AlarmEngine` across the fake DAOs and fake seams — the arrangement
`testing/AlarmEngineHarness.kt` now lifts out of `AlarmRingViewModelTest`. What
is left in the composables is `Text(...)`, `Switch(...)` and `stringResource`.
Three `ArchitectureRulesTest` rules pin that mechanically: no `*ViewModel.kt`,
`*UiState.kt` or `*Source.kt` imports `androidx.compose.`; the same files import
no `android.`; and no file under `ui/` names `AlarmResolver` or
`AlarmOccurrences` — the screen takes the engine's resolution and re-derives
none of its time arithmetic. All three held retroactively for M4's files, so
they pin existing discipline rather than an aspiration.
What was left to the device is the seven new instrumentation tests: the row's
switch really toggling and the row really dimming, the FAB creating an alarm and
landing in its editor, the M3 time picker writing a typed time, a day toggle
changing the repeat summary, a cancelled system document pick changing nothing,
back returning to a still-selected Alarms tab, and the delete confirmation
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`.
---
## 9. Build and tooling
AGP 9.2.1 · KSP 2.3.9 · Hilt 2.59.2 · Room 2.8.3 · DataStore 1.2.1, all pinned
in `gradle/libs.versions.toml`. `compileSdk 37`, `targetSdk 36`, **`minSdk 29`**,
Java/Kotlin target 17.
Room's schema export is switched on with
`ksp { arg("room.schemaLocation", "$projectDir/schemas") }`, and the same
directory is added as an `androidTest` assets source dir. It is written during
`compileDebugKotlin`, so `assembleDebug` must run before the test that reads it.
Reproducibility rules, checked by `scripts/check_reproducible_release.sh`:
`vcsInfo { include = false }` on release, `dependenciesInfo` out of the APK and
bundle, and **no foojay toolchain resolver anywhere** — not in this repo, not in
floret-kit, not in a comment.
floret-kit is a git submodule consumed as a composite build; it needs a
gitignored `floret-kit/local.properties` pointing at the SDK locally, and
`ANDROID_HOME` in CI.
Time types: the domain speaks `kotlin.time.Instant`, `kotlin.time.Duration`,
`java.time.DayOfWeek` and `java.time.ZoneId`. `java.time` is native at
`minSdk 29`, so there is no desugaring. `kotlinx-datetime` is on the classpath
for M3's DST work; the DST arithmetic itself is `java.time`'s, wrapped in one
function (§11).
### Manifest
Clockula's permission set is the alarm engine's, and nothing more. Each one is
declared because a specific path would otherwise fail — silently, at 07:00.
| Permission | Why |
|---|---|
| `USE_EXACT_ALARM` | install-granted from API 33 for an app whose core function is alarms; no dialog, nothing to revoke |
| `SCHEDULE_EXACT_ALARM` `android:maxSdkVersion="32"` | covers 31–32, where it is pre-granted. Bounded at 32 so it never overlaps the one above — that is the documented pattern and it keeps the store listing's story clean. Below 31 an exact alarm needs no permission at all |
| `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 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 |
**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
audio belongs to the foreground service. A denied `USE_FULL_SCREEN_INTENT`
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.
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
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.
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.
`ACTION_LOCKED_BOOT_COMPLETED` is deliberately not handled: it needs
`directBootAware`, and the database and DataStore live in credential-encrypted
storage that is unreadable before the first unlock.
Components: `MainActivity`, the non-exported `CrashReportActivity` and
`AlarmRingActivity`, two foreground services (`AlarmRingService` and M6's
`TimerService`), five receivers — `AlarmFireReceiver`, `AlarmActionReceiver`,
`SystemEventReceiver`, `TimerExpiryReceiver`, `TimerActionReceiver` — and
AppCompat's locale metadata holder service.
---
## 10. What is not built yet
| 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 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 |
| 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
world clock on first run, so the empty states are real empty states.
Four more deliberate gaps the alarm engine leaves open:
- **No missed-alarm notification or history.** An auto-silenced alarm is missed
silently. Not on the roadmap for v1.
- **No upcoming-alarm notification.** `getNextAlarmClock()` already draws the
status-bar icon, which is the platform's answer.
- **No direct-boot awareness**, so an alarm cannot ring in the window between a
reboot and the first unlock — see §9.
- **No bundled fallback ringtone.** The audio chain ends in forced vibration
(§11), which makes a shipped asset a path a real device cannot reach.
---
## 11. The alarm engine
The milestone's real deliverable, and the section to read before changing
anything alarm-shaped.
### The shape
`AlarmResolver.resolve(alarm, state, now, zone)` is **pure**. It returns the next
fire instant, where it came from, *the ring state as it should now be persisted*,
and two instructions that live outside the state table (`clearSkipFlag`,
`disableAlarm`). It never touches a repository, a clock or
`ZoneId.systemDefault()`, which is why the hard half of this milestone is
callable from a plain JUnit test with hand-built inputs and no fakes at all.
`AlarmEngine` owns every write the resolver asks for and every call into the
four seams. It is **never driven by a Flow**: resolution writes state, so an
engine that rescheduled on each repository emission would re-trigger itself on
its own writes. `reschedule()` is called explicitly — from `ClockulaApp`, from
the receivers, and from the alarm editor. `upcoming()` runs the same resolver in
preview mode and *discards* the state it returns; a read must not write, and a
test asserts the store recorded nothing across a full collection.
`upcoming(refresh: Flow<Unit>)` takes its **re-read cadence as a parameter**.
The resolver needs a `now`, and `upcoming` reads one inside a `combine` of the
two repository flows — so a countdown built on it would freeze until the next
database write. Making the cadence a parameter keeps `PLAN.md` §5's discipline
(time is a parameter, never an ambient read) without giving the engine a
`Ticker` and an Android-shaped dependency: the default, `flowOf(Unit)`, means
"resolve once per repository emission" and leaves every existing caller alone,
while the alarms screen passes `ticker.ticks(30.seconds)`. Re-resolving is still
read-only, and a test collects several refreshes and asserts `alarm_states`
recorded nothing.
Every entry point is a read-modify-write over `alarm_states`, and they arrive
from threads that know nothing about each other — a broadcast on
`Dispatchers.Default`, `ClockulaApp`'s launch-time re-sync, the ring screen — so
they are **serialised on one `Mutex`**. Without it, a cold start *caused by* the
fire broadcast can read the pre-ring state and write it back over `ringingSince`
or the handled-occurrence watermark, which either silences the alarm or lets it
ring twice. The lock is not reentrant, so exactly one layer takes it: the public
entry points. `ClockulaApp` goes through `onBootCompleted()` rather than calling
repair, resume and reschedule one by one, so its whole pass is inside the lock.
### Precedence, fixed and total
1. **Disabled** ⇒ clear the snooze, the ring and the skip watermark; keep
`handled_occurrence`, because re-enabling must not un-suppress a dismissal.
2. **Ringing** within `AUTO_SILENCE_AFTER` ⇒ `RESUMED_RING`, nothing scheduled:
it is not an alarm to come, it is one that is happening. A clock moved
*backwards* under a ringing alarm reads as a negative age, which is inside the
window — so it keeps ringing. Beyond the window it is auto-dismissed as
missed and the resolution falls through.
3. **Snoozed** in the future ⇒ the snooze is the next fire, unless the natural
occurrence is earlier (defined so the function is total). Due-but-past by less
than the ring window ⇒ still the snooze, at its own past instant, which
AlarmManager fires at once: a snooze promised before a reboot is kept. Older
than that ⇒ abandoned.
4. **The natural occurrence**, with §4's grace window, watermark and skip rules.
### The ring cycle
A cycle opens when the alarm fires and closes on dismiss, on the snooze limit
being reached, or on auto-silence after `AUTO_SILENCE_AFTER` = **10 minutes**
(AOSP DeskClock's default; not user-configurable in v1). Snoozing keeps it open.
A **non-repeating alarm is disabled when its cycle closes, never when it opens** —
disabling it at the start would clear its own snooze and the alarm would vanish
mid-snooze.
Closing a cycle only touches the ring service and the auto-silence registration
**when that alarm is the one actually ringing**. Both are single, global slots
(there is one ring service, and stopping it stops whatever it is playing), so
dismissing a merely *snoozed* alarm from its notification must leave a different,
genuinely ringing alarm sounding with its backstop intact.
**At most one alarm rings, and a newly-firing alarm takes over.** Two ringtones
at once is not a feature, and queueing invents a state machine nobody can debug
at 07:00. The alarm that is displaced keeps its watermark, so it does not come
back the moment the slot is re-resolved.
`snoozeLimit == 0` means **snoozing is disabled**, not unlimited — v1 offers no
unlimited, and "0 means infinite" is a trap. A refused snooze *dismisses*; it
never leaves the user with a ringing alarm they cannot silence.
### Two AlarmManager slots, deliberately separate
| Slot | Registered with | Purpose |
|---|---|---|
| next alarm | `setAlarmClock(AlarmClockInfo(fireAt, showIntent), …)` | the user's next alarm. Doze-exempt, and the only variant that populates `getNextAlarmClock()` |
| auto-silence backstop | `setExactAndAllowWhileIdle` | stops a ring nobody dismissed |
They are separate because `getNextAlarmClock()` is what draws the status-bar icon
and the lockscreen line: a backstop sharing that slot would overwrite the user's
07:00 with an internal 07:10. Two `PendingIntent`s, two request codes, two
independent cancels — and a test asserts no two request codes collide, because
that collision is exactly how a backstop eats a user's alarm. A **snooze does**
go through the next-alarm slot: a snoozed alarm *is* the next alarm.
If exact alarms are unavailable the alarm is still registered, with
`setAndAllowWhileIdle`. There is no third branch: late is survivable, silent is
not.
### DST
`AlarmOccurrences` walks *local dates* forward and maps each to an instant with
one `ZonedDateTime.of(date, time, zone)` call — the only DST-aware call in the
app. Adding 24 hours to yesterday's fire time is the bug this milestone exists
not to have.
| Transition | java.time's default resolver | What the user sees |
|---|---|---|
| **Gap** (spring forward) — the local time does not exist | shifts forward by the gap's own length | a 02:30 alarm on a Berlin spring-forward night rings at **03:30** local. It rings; it is never skipped. A 30-minute gap moves it by 30 minutes, not an hour |
| **Overlap** (fall back) — the local time happens twice | takes the **earlier** offset | a 02:30 alarm on a fall-back night rings **once**, at the first 02:30. Never late, never twice |
These are AOSP DeskClock's behaviours, they are the two answers a user would
defend ("I still got woken", "I only got woken once"), and they come free from
the platform rather than from hand-rolled offset maths. They are locked by tests
that assert exact instants in Europe/Berlin, America/New_York,
Australia/Lord_Howe (a 30-minute gap) and Pacific/Apia (a calendar day that never
existed), not by a comment.
A snooze, by contrast, is an **absolute instant**: no transition can move it.
### Never to silence
The one non-negotiable, and it is a chain of fallbacks rather than a hope. It
begins at `AudioSourcePolicy.sourcesFor(ringtoneUri)`, a pure function a JVM
test can enumerate, and the silent sentinel resolves there to an **empty source
list** — which is precisely what makes step 4 fire, so "silent" means "vibration
only" rather than "nothing at all":
1. the alarm's own ringtone URI, else
2. the device's default alarm URI, else
3. `RingtoneManager.getDefaultUri(TYPE_ALARM)`, else
4. **forced vibration** — `RingFallbackPolicy.vibrationRequired` turns vibration
on even for an alarm the user set not to vibrate, because with no audio source
the alternative is silence.
The audio is the foreground service's, played over `USAGE_ALARM` /
`CONTENT_TYPE_SONIFICATION` so Do Not Disturb's alarm exemption applies, with
`AUDIOFOCUS_GAIN_TRANSIENT` — an alarm interrupts; it does not duck. The volume
ramp is a pure function, `MIN + (1 - MIN) · progress²`, stepped every 200 ms into
`MediaPlayer.setVolume` and **never** into `AudioManager.setStreamVolume`, which
would edit the user's own alarm volume and leave it edited.
`RingPresentationPolicy` decides how the ring is *presented*, and its
`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
Four tabs, one host, and one surface that follows whatever is running.
### Navigation
`NavigationSuiteScaffold` from `material3-adaptive-navigation-suite`, taking its
default `navigationSuiteType`: the M3 Expressive **short navigation bar** on a
compact width and the **wide rail** on medium and expanded ones. Taking the
default rather than writing `if (compact) NavigationBar else NavigationRail` is
the point — the default also answers the tabletop posture and the compact-*height*
case a hand-rolled branch quietly gets wrong.
The `NavHost` is the single source of truth for the selected tab. There is no
`var selectedTab by rememberSaveable`: the selection is derived from
`currentBackStackEntryAsState()`, and Navigation Compose already saves its back
stack through a `SavedStateHandle`, so the tab survives rotation and process
death with no mirror of ours to drift.
The policy that back stack follows is a pure function, `ShellNavigation`:
- a tab tap is `popUpTo(start) { saveState = true }` + `launchSingleTop` +
`restoreState`; re-selecting the tab you are already on is the same command
with `restoreState = false`, which pops that tab's own inner stack back to its
root — the standard behaviour, and the contract M5's editor will rely on;
- back from any of the other three tabs returns to **Alarms**; back from Alarms,
from a null route and from any route the shell does not recognise leaves the
app — a nested destination's back belongs to the `NavController`, not to us.
Predictive back is the kit's `Modifier.predictiveBack`, gated on exactly that
policy: on Alarms the handler is *off*, so the gesture falls through to the
system and previews leaving the app, which is the truthful preview. Tab switches
use the kit's `fadeThrough()` — peer destinations have no spatial relationship,
and a slide would claim a hierarchy that is not there.
### The live pill
`PLAN.md` §9 asks for a running-state surface reachable from every tab. Its real
predicate is **active, not running**: pausing from the pill must not make the
pill vanish under the thumb that pressed it, or there is no way to resume or
stop from another tab — which is the flaw the pill exists to fix. So it shows for
`RUNNING`, `PAUSED` *and* `EXPIRED`, and its own Stop — which is `reset()`,
never `delete()` — is what removes it.
Precedence is total; first match wins:
| # | Subject |
|---|---|
| 1 | a timer that reads as **expired right now** (stored `EXPIRED`, or stored `RUNNING` whose snapshot has reached zero) |
| 2 | a timer that reads as **running**, the one with the smallest remaining |
| 3 | a timer that reads as **paused** |
| 4 | the **stopwatch**, when its state is not `IDLE` |
| 5 | otherwise no pill |
Ties inside a timer bucket break on `sortOrder` then `id` — the same "lower id
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 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.
### The third time-shaped seam
`Ticker` joins `WallClock`, `ElapsedRealtimeClock` and `ZoneProvider` in
`domain/time/`. A readout has to advance without a repository write, and the
discipline of §5 is that time is a *parameter*: so "re-read the clocks now"
became an injected flow rather than a `delay` at a call site. It emits once
immediately and then every period — one second for the pill, since the pill
formats to whole seconds — so a fresh subscriber is never blank. `RealTicker` is
nothing but `delay`, which makes it testable under `runTest`'s virtual clock.
### The dismiss challenge, and why it cannot strand anyone
`ChallengeGate` is a pure state machine whose single boolean `open` means
"dismissing is permitted now". `PLAN.md` §4's "must never be able to strand the
user" is made executable rather than promised: the escape hatch becomes
available on **either** three wrong answers **or** sixty seconds of ringing,
whichever comes first, for every challenge kind — asserted as a parameterised
invariant over `DismissChallenge`, not as three happy paths.
Three more guarantees hold alongside it. Snooze is never gated: the gate protects
dismissal only. Auto-silence still ends the ring at ten minutes regardless of the
gate, because that is the engine's, and the screen only follows the session to
`Finished`. And a wrong answer carries no lockout and no penalty timer — it
replaces the problem and that is all.
A completed hold *is* the dismissal, and a correct answer *is* the dismissal: no
"solve it, then press Dismiss again", because a half-awake person should not have
to discover a second step. The hold target also carries an accessibility
`onClick` that completes it outright — TalkBack cannot perform a press-and-hold,
and a challenge a screen reader cannot answer is precisely the stranding the
requirement forbids.
---
## 13. The alarms screen
The first tab with real content, and the milestone that gives the schema of §4
and the engine of §11 a face.
### Two destinations in one flat host
The list stays on the `ALARMS` tab route; the editor is a **sibling** route in
the same `NavHost`, `alarms/edit/{alarmId}`, with a `NavType.LongType`
argument. Three properties fall straight out of §12's existing policy rather
than being re-invented: `destinationOf("alarms/edit/7")` is null, so the Alarms
tab stays highlighted while the editor is open; `onBack` of that route is
`Exit`, so the shell's predictive-back handler stands aside and the editor pops
itself; and a tab tap's `popUpTo(start.route)` pops the editor, which is the
right behaviour and exactly the contract §12 wrote down for this milestone.
There is **no "new alarm" sentinel id**. The FAB creates the alarm — enabled,
non-repeating, at the next whole hour, every override null — and then navigates
to its id. One route shape, one load path, and no branch in the ViewModel for
"the thing I am editing does not exist yet".
### The row
A canonical two-line M3 `ListItem` through the kit's `GroupedRow`: the formatted
time with the label beside it in `onSurfaceVariant`, a summary of
`repeat · next fire · skipping next`, and a `Switch`. A **disabled** alarm is
`dimmed` — the kit's own answer for "present but switched off", which fades the
text to the M3 disabled emphasis and leaves the switch at full opacity. The
**next** alarm is marked with `colorScheme.primary` on its next-fire clause and
nothing else: `PLAN.md` §8's "Expressive ≠ big or bold", so the emphasis is a
colour rather than a third line or a larger type ramp.
The list is a `LazyColumn` inside `CollapsingScaffold(scrollable = false)`,
which is the case that parameter exists for, and the FAB sits in the kit's new
`floatingActionButton` slot (floret-kit 0.6.0). Both the FAB and the list's
bottom `contentPadding` clear `LocalLivePillInset`, because the pill is
bottom-centre and the FAB bottom-end.
### Ordered by time of day, not by next fire
`AlarmEngine.upcoming()` sorts by next fire, because its job is "which alarm is
earliest to register". That is the wrong order for a list: an 07:00 and a 22:00
alarm would **swap places at 07:00**, reordering rows under a thumb. So
`AlarmListRows.from` re-sorts by `time.minutesOfDay` then id — which is also
exactly what `AlarmDao.observeAll` emits, so the screen's order re-asserts the
storage order rather than inventing one. A disabled alarm keeps its place: it is
already dimmed, and sinking it would say the same thing twice while moving a row
the user did not move.
### The next-fire line
`NextFireFormat.labelFor(nextFire, now)` returns data, not a string —
`NotScheduled`, `Imminent`, `InMinutes`, `InHoursMinutes`, `InDaysHours` — and
the wording lives in `strings.xml`, following `RingUiState`'s precedent. Whole
units round **up**, the direction `ClockFormat.countdown` already rounds: "in 9h
12m" must mean *at least* nine hours and eleven-and-a-bit minutes, and an alarm
61 seconds out must not read "in 1m" when it is nearer two. `InDaysHours`
exists because a weekly alarm can be six days out.
The cadence is 30 seconds (`AlarmsDefaults.NextFireTick`): the readout's unit is
a minute, so half a unit bounds the displayed error, and the one-second cadence
the live pill needs would re-resolve every alarm sixty times for a readout that
can change once. "Ringing now" does not wait for a tick — it arrives as an
`alarm_states` write, which re-emits at once.
### Every override is a picker, and its first option is "App default"
A per-alarm setting is a nullable override (§4), so the stored value is
**ternary**: absent, or a choice that happens to be `false` or `0`. A `Switch`
cannot say "I have not chosen" — toggling one would silently turn "follows your
default" into "explicitly on", with no way back. So all five overrides (vibrate,
gradual volume, snooze length, snooze limit, dismiss challenge) use the kit's
`OptionPicker`, whose first row is "App default (On)" and names what the default
currently is. Every option offered is inside `ClockPrefs`' clamps, so nothing
chosen can be clamped away on the next read, and `snoozeLimit == 0` is labelled
"No snoozing", never "unlimited" — §11's "0 means infinite is a trap".
Each row's summary shows the effective value, prefixed while inherited, so the
inheritance is visible without opening anything. There is no tri-state hack and
no "reset this alarm" button to discover.
### The ringtone picker
Bespoke over `FullScreenPicker(scrollable = false)` rather than `OptionPicker`,
because a device's tone list can be hundreds of rows. `RingtonePicker.optionsFor`
is pure and yields four kinds of row, de-duplicated by URI:
1. **App default** — summarised with what it currently resolves to, or "Device
default alarm sound" when the app default is itself null;
2. **Silent** — `Ringtones.SILENT_URI`, `clockula://silent`. `null` already
means *inherit*, so silence needs a value of its own, and a private scheme is
one no ContentProvider URI can collide with. It resolves to an empty audio
source list, which forces vibration (§11) — the label is "Silent" with the
summary "Vibration only", so it is not a lie;
3. **the alarm's own sound when the device does not list it** — a SAF-picked
file, or a URI another app wrote. Without this row the user's own selection
would be invisible, and so uncheckable, in the picker that chose it;
4. the device's alarm tones, title-sorted.
"Choose from files…" is an **action** row, not an option, so the pure list stays
a list of selectable states. A picked document's read grant is made persistent
through `RingtoneCatalog.persistPickedDocument`; if that fails the URI is
**still stored** (the fallback chain protects the ring) and the row discloses
"Clockula may lose access to this file later". A stored sound the catalog cannot
open right now is likewise **reported, never rewritten** — "unreadable now" and
"gone forever" are indistinguishable, an unmounted card comes back, and
discarding the user's choice silently would be the worse failure.
Previewing goes through `RingtonePreviewer`, a two-method seam over the same
`USAGE_ALARM` attributes the ring service uses, so a preview sounds like the
alarm will. One previewer, so a second preview stops the first; selecting an
option, closing the picker and `onCleared()` all stop it. Deliberately no audio
focus and no ramp: a preview is not an alarm.
### The rule a future reader will otherwise simplify away
**A ringing alarm is dismissed through the engine before it is edited, disabled
or deleted.**
`AlarmResolver`'s disabled branch clears `ringingSince` and the engine persists
that — but nothing in that path stops the ring service. The service and the
auto-silence backstop are single *global* slots (§11), touched only by
`closeCycleLocked`, and `onAutoSilence` returns early once `ringingSince` is
null. So switching off or deleting a ringing alarm from this screen would clear
its state and leave it **sounding until the 15-minute wake-lock timeout, with
the backstop disarmed by its own guard**. Both ViewModels therefore call
```kotlin
if (engine.ringSession()?.alarm?.id == alarmId) engine.dismiss(alarmId)
```
before `setEnabled(id, false)` and before `delete(id)`. It reads storage under
the engine's own mutex, so it cannot race the fire it is asking about, and it is
deliberately **conditional**: dismissing unconditionally would also drop a
merely *snoozed* alarm's pending snooze, which is wrong for a label edit.
### Immediate apply
There is no Save button, no dirty state and no discard dialog: `PLAN.md` §11
names Google Clock as the interaction reference, and this is its model. The
editor edits a persisted row and every control writes at once. The label is
written on every keystroke through one conflating `MutableStateFlow` — which
writes the first value and the latest, in order, never interleaved — and
`onCleared()` re-launches the last value on the kit's `@ApplicationScope`, so
leaving the editor mid-word cannot lose the word. `reschedule()` is called only
after a change that can move a fire time (create, delete, enabled, time, repeat
days, skip-next), so fiddling with sound settings costs no AlarmManager traffic;
a snooze *length* cannot move a pending snooze, which is stored as an absolute
instant.
Skip-next-occurrence, built in M3 with no caller, is the editor's — and only for
a **repeating** alarm. The resolver answers a one-shot-with-skip by disabling the
alarm, and a switch labelled "skip" that silently switched the alarm off would
be a lie; the user who wants that has the enable switch.
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.