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

93 KiB
Raw Blame History

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 TypeConverters — 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.

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 objects 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 PendingIntents, 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

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, delays 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 FilledTonalButtons 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.