A pure-Kotlin JSON codec (so the backup round-trip test runs on the
JVM) and Jetpack Glance (the three M10 widgets). Both resolve from
google()/mavenCentral() only, no verification-metadata file exists to
update, and the repro guard checks vcsInfo/dependenciesInfo/foojay —
none of which this touches.
The Timers tab gets a full rewrite of how a timer is created, after Google
Clock as the interaction reference (PLAN.md §11):
- A dedicated TimerSetupScreen replaces the bottom-sheet/inline setup panel:
a per-unit "00h 00m 00s" readout, a "00" key, and a big centred play
button that never shifts position when Clear fades in beside it.
- The keypad grows into the thumb zone on its own screen (92dp keys, pushed
toward the bottom), and both it and the readout read bold.
- Presets are dropped from the flow; TimerDurationEntry gains the matching
domain support (hoursSegment/minutesSegment/secondsSegment,
plusDoubleZero()).
- An empty timer list shows the same readout/keypad/Start inline, in place
of a "tap + to add" card — the keypad is still the empty state, just the
redesigned one.
- Timer cards are disconnected like the Alarms tab's own list, each with an
instant, unconfirmed delete via a small corner button (re-creating a
timer is faster than reading a confirmation dialog would be).
- Hero readouts go bold app-wide to match: stopwatch, alarm rows, world
clock, the ring screen, and both editors' length/time displays
(ClockulaReadoutDefaults.Hero and matching call sites).
Alongside: Android 16 Live Update support for the running-timer notification
(POST_PROMOTED_NOTIFICATIONS, ProgressStyle), the live pill restyled to a
plain tonal circle, the alarm dismiss-chooser dialog moved onto Floret's
OptionCard, and small fixes to addTime's zero/negative-result handling,
repeat-day selection and alarm routes.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Three new rules. Nothing under `domain/` may name `android.content.Intent`, so
the validator stays testable without a device. The `AlarmClock` action strings
may be named only under `domain/interop/` and `interop/`. And
`AndroidAlarmScheduler` may not name `AlarmRingActivity` — the defect this
milestone fixed would otherwise come back the next time someone reaches for a
"show the alarm" intent.
"Dismiss my alarm" with two alarms set is a question, not an instruction. When
the search matches more than one the app asks, in a dialog listing the
candidates; when it matches exactly one it acts without asking.
The Timers tab's setup effect no longer re-keys on a list that changes with
every readout tick.
An incoming request is more than a tab now — it can open an editor, compose a
timer or ask which alarm to dismiss. So it is read once, when the activity is
created rather than on every configuration change: re-deriving it on a rotation
would drag the user back into an editor they had just left.
The extras are unparcelled behind a guard here too. `MainActivity` is exported,
and before this it only ever read the action, which never unparcels.
`AlarmClockActivity` is the only exported surface that takes a platform intent,
guarded by the caller-side `SET_ALARM` permission — which cannot go on
`MainActivity`, because the system checks it against the Launcher too. It is
invisible, does its work, and finishes.
Two intent-filters, not one: a filter carrying a `<data>` element never matches
an intent without data, so the deeplink actions need a filter of their own.
`IntentExtrasReader` turns the `Bundle` into a plain map and guards the
unparcel, because an exported door is reachable by anything on the device and a
hostile Parcelable must not crash the launch.
The `AlarmClockInfo` show intent — what the status-bar alarm icon and the
lockscreen's next-alarm line open — targeted `AlarmRingActivity` with
`CLEAR_TASK`. Tapping it opened a ring screen for an alarm that was not
ringing, which resolved to "finished" and closed itself again.
It now opens the app on its Alarms tab. That show intent is what "next-alarm
publishing" means, so the rule pinning it belongs to this milestone.
The verbs an assistant can ask for go on the engines, not on a handler that
orchestrates repositories behind their backs. `dismissUpcoming` and
`snooze(id, minutesOverride)` move the ring slot and the backstop;
`dismissExpired` and `dismissAllExpired` move the timer's expiry registration.
Each of those is why they belong under the engine's mutex.
A snooze duration a caller sends is clamped to one hour, because that one is
the user's own request happening now. An alarm or timer marked to delete
itself is deleted as its cycle closes rather than left disabled.
The platform's contract as constants, and everything that decides what an
incoming intent *means* — parsed, validated and range-checked as pure Kotlin,
so hostile input can be tested without a device.
`IntentExtras` reads a map rather than a `Bundle`, because the platform's typed
getters cannot tell "absent" from "wrong type" and that distinction **is** the
validation. The range rule: clamp what the user is doing now, drop what would
define a future ring — hour 25 clamped to 23 rings at an hour nobody chose, so
it is dropped and the editor opens instead.
A link we cannot read names nothing, rather than falling back to "all of them":
a caller who mistypes one timer's id must not dismiss every expired timer the
user left standing. A repeat list sent non-empty from which nothing survives
leaves the alarm incomplete, because a repeating alarm silently becoming a
one-shot is a missed alarm next week.
M8 folded text so "sao" would find São Paulo. M9 needs the same fold to match
an alarm by its label, so the fold moves to `domain/text/` and the zone search
calls it rather than owning it.
The `AlarmClock` contract says twice that an alarm or a timer created with
`SKIP_UI` should be removed once it has been dismissed. Without somewhere to
record that, a voice user's alarm list becomes a graveyard of one-shots nobody
asked to keep.
One column on each of `alarms` and `timers`, defaulting to 0, and a migration
that adds them. Existing rows keep the behaviour they have always had.
Three new rules. `android.icu` may be named only under `data/zones/`, so the
platform's name database stays behind its seam. `MaterialShapes` and
`androidx.graphics.shapes` may be named only under `ui/worldclock/`, so the
showpiece stays the one place that morphs. And nothing under `domain/` or
`ui/` may read `ZoneId.systemDefault()` or `TimeZone.getDefault()` — the
device's zone is an argument, or the tests cannot pin a single one.
All three hold retroactively for M0 through M7.
A hero face for home over a list of cities, each carrying its time, its offset
and how many days apart it is from you. The dial morphs between a circle and a
sun with the hour *there*: the one showpiece M0 reserved, spent on information
rather than decoration — a glance says "it is the middle of the night for them"
with no sentence needed.
The picker searches the device's own zones by word prefix. The list reorders by
drag **and** by move-up/move-down actions, because a drag-only reorder is
unreachable by a screen reader; each row reads as one sentence rather than
three texts in child order. Home is set by hand or left following the device.
At the twenty-fourth city the refusal is written on the screen, not only in the
content description.
The analog face imports `androidx.graphics.shapes.Morph` directly rather than
inheriting it transitively from Material 3. Pinned to the version that already
resolved, so nothing about the resolved graph changes — only its honesty.
`ZoneNames` is the seam; `IcuZoneNames` is the only file in the app allowed to
name `android.icu`, and a build rule keeps it that way. Every read is guarded —
ICU returning blank or throwing gives back null rather than a half-written
city.
`ZoneDirectory` caches the catalog, the entries and the display names, all
keyed on the locale tag, so a per-app language change re-resolves every name
instead of leaving the cities in the old language under a freshly translated
zone name. It answers in batches, so a tab with two dozen cities costs two
dispatches a tick rather than two dozen.
The pure-Kotlin half of the world clock, with no Android on it anywhere and no
ambient zone read — the device's zone arrives as an argument, and an
architecture rule now enforces that.
`ZoneCatalog` de-duplicates the device's own tzdata by canonical id and keeps
the region ids, with `UTC` as the one stated exception — and keeps an alias
whose canonical id the device does not have, because tzdata and CLDR ship as
separate modules and can disagree. `ZoneSearch` matches word prefixes over
diacritic-folded text, so "sao" finds São Paulo and "erl" does not find Berlin.
`ZoneComparison` reads the offset and the day difference **at the instant**, so
Berlin to Sydney is ten hours in January and eight in July. `AnalogFace` turns
an instant into hand angles and says whether it is day or night there.
`Zones.isValid` now tests a snapshot rather than allocating the platform's set
on every call; tzdata takes effect at reboot, so the answer cannot change under
a running process.
Two new rules and two extended. The stopwatch may not name a player, a
vibrator, an audio manager — or a wake lock: it is the one timekeeper in the
app that never makes a sound and never holds the CPU awake, and that is now
a build failure rather than a promise in a comment.
`fontFeatureSettings` may appear only under `ui/theme/`, so the figure
settings stay in one place instead of spreading to call sites. The
Android-free list gains the engine; the screen's needle list gains the
service and its notifications.
M6 left this as M7's: the pill wrote to the stopwatch repository directly
because there was no engine to ask. There is one now, so the pill's buttons
mean exactly what the tab's and the shade's mean, service transitions
included.
`LivePillSelector` is re-pointed at `StopwatchReadings` — the same extraction
M6 did for the timers — and its existing cases pass unmodified, which is the
proof that the reading moved rather than changed. A stopwatch notification
now opens the Stopwatch tab.
A fixed readout over a lap list, newest first, the lap in progress counting
as its own row once there is a lap to compare it against. The fastest and
the slowest are marked in `primary` and `tertiary` — never `error`, and never
by colour alone: each carries a spoken marker so the emphasis survives being
read aloud.
The state rebuilds only what actually moves: the recorded rows and their
emphasis come off the lap table once, and the tick recomputes the hero
figure and the row in progress. Laps stop at 999 and the readout ticks only
while the stopwatch is running.
M0 said the big-readout typography would be settled once there was a
stopwatch to settle it against. `ClockulaTypography` now puts `tnum` on the
display, headline and title roles, which is every role a counting number
uses, and `ClockulaReadoutDefaults` names the three sizes the readouts share.
Digits stop changing width as they change, so the timer row, the setup panel
and the live pill stop twitching too — none of them needed a call-site
change to get it.
The service is alive exactly while the stopwatch is not idle, and it is
`specialUse` with the subtype spelled out — reusing `systemExempted` would
have the app claim it is continuing alarm functionality, which it is not.
No wake lock, ever: the notification carries the platform chronometer
counting up from a base derived when it is posted and never stored, so the
system draws the ticking and the process can sleep through it.
Its channel is `IMPORTANCE_LOW` because nothing here ever alerts. The Lap,
Pause, Resume and Reset buttons are broadcasts carrying no extras — the
receiver reads the stored run rather than trusting an intent — and the body
opens the Stopwatch tab. Boot, time change and package replacement all reach
the third engine now, alongside the other two.
A third engine beside the alarms' and the timers', and a small one: four
verbs behind a single mutex, no scheduler and nothing that rings. Start,
pause, lap and reset are here rather than on the repository because three of
them arrive as notification buttons and must mean the same thing whichever
surface pressed them.
The verbs guard on the *reading's* mode, not the stored row, so a run whose
anchor a reboot invalidated resumes when the tab says Resume instead of
silently doing nothing; resuming banks the last lap's total first, so the
readout never stands still and never walks backwards. `StopwatchIntents`
namespaces the actions and hands out request codes that cannot collide with
the timers'. The service is reached through one seam, which keeps the engine
free of Android and lets the tests watch the transitions in order.
The pure-Kotlin half of the stopwatch, with no Android on it anywhere.
`StopwatchFormat` splits a duration into a major field it asks `ClockFormat`
for and two truncated hundredths, so the fraction can be drawn smaller than
the seconds beside it. `StopwatchReadings` turns a stored run plus the
monotonic clock into the one reading the tab, the pill and the notification
all draw — a run whose anchor belongs to a previous boot reads paused at what
it banked, never negative and never below the last lap's total. `LapStats`
marks the fastest and the slowest, but only once three laps exist, with ties
going to the earlier lap and an all-equal set marking neither.
`StopwatchNotification` says what the shade shows without knowing what a
`Notification` is.
Multiple concurrent timers with labels, presets, add, pause, reset and +1 min.
The setup panel is inline on an empty tab, so the first timer costs no
navigation.
The live pill has been waiting since M4 for something that could start a
timer. It now goes through the engine rather than the repository, because a
scheduler registration and a foreground service have to move with the state —
its own contract is unchanged.
Three new architecture rules keep it that way: no screen may name the
scheduler, the service or AlarmManager, and a second MediaPlayer anywhere
outside the ring path fails the build.
One AlarmManager slot for every timer, registered on ELAPSED_REALTIME_WAKEUP
so the clock the domain anchors on is the clock the platform wakes on. Its
fire carries no id and is an idempotent sweep, backed up by a second trigger
inside the service, because neither has to be reliable on its own.
The foreground service posts per state change rather than per second — the
platform chronometer draws the countdown — and its actions are broadcasts, so
pause and reset still work with the process dead. Every verb is guarded
against a stale id.
Expiry reuses the alarm's audio path: the same player, vibrator, source policy
and fallback-to-vibration chain, with a zero ramp and its own channel. No
full-screen intent, no challenge, no snooze — a timer is not an alarm. Two
timers expiring together share one ring; a timer expiring after a previous
one's auto-silence window lapsed gets a sound of its own, which is the whole
point of having a timer.
"+1 min" on a timer that has just rung resumes it with exactly a minute, in
one transaction — the user asked for a minute more than zero, not a minute
more than an anchor that has gone by. M2 left that branch paused with no test
to pin it; this is the gesture a timer app is judged on.
Every running row carries both anchors: the monotonic one it actually runs on,
and a wall-clock fallback for a reboot. A system clock change re-derives the
fallback from the monotonic remainder rather than leaving it stale, because a
stale fallback is how a thirty-minute timer rings twenty-nine minutes early
after a reboot.
Presets are app-wide, sanitised on read as well as on write, and go through
the store's update so two edits cannot swallow each other.
One ordering for all of it. The pill, the notification and the ring used to be
three places that each decided which timer mattered most; TimerReadings is now
the single answer, and the pill's own test passing unmodified is the proof the
extraction changed none of M4's behaviour.
Expiry is a pure function of state, which is what makes the arbitration with a
ringing alarm symmetric: the alarm wins the audio, and the timer's ring is
deferred rather than lost. Durations stay anchored to elapsed realtime, so
moving the system clock cannot move an expiry.
The audio player, the vibrator, the volume ramp and the audio-source policy
were never alarm-specific — timers need the same ones, and a second
MediaPlayer in the app would be a bug. Moved and renamed, no behaviour
changed: the moved tests differ by their package line and one constant name.
The ringtone picker moves to ui/common for the same reason.
The tab the app has been missing. Rows carry the time, the repeat summary and
the next-fire countdown, ordered by time of day rather than by when they fire
next — a row must not move under the thumb reaching for it, so the next alarm
is marked by colour instead. The editor covers time, repeat days, label,
ringtone, vibration and snooze, with the five per-alarm overrides as
three-state pickers: a switch cannot say "I have not chosen".
Two things that look like details and are not. A ringing alarm is dismissed
through the engine before it is disabled, edited or deleted — otherwise its
state is cleared while the service keeps sounding and the auto-silence backstop
returns early on its own guard, leaving the alarm ringing until the wake-lock
timeout. And clearing the last repeat day disarms a pending skip, because
otherwise the resolver disables the alarm and the control that would have
explained it is already hidden.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wmy1BpCKi8KeSjaWhYuCPV
`upcoming` takes the cadence it re-resolves on, so a live countdown comes from
the engine's own resolution — the grace window, the watermarks and the DST
walk stay in one place instead of being copied into a screen. Re-resolving
writes nothing, which is asserted rather than assumed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wmy1BpCKi8KeSjaWhYuCPV
The catalogue closes its cursor and survives a device with no alarm tones at
all: the picker is never empty, because "Silent" and the app default are
always offerable. A chosen URI that later becomes unreadable is disclosed on
the row rather than quietly rewritten — the user should know their alarm
cannot play what they picked.
The previewer serialises behind a mutex and releases a tone that finished
preparing after a stop, so two taps cannot leave two alarm sounds playing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wmy1BpCKi8KeSjaWhYuCPV
The editor and the engine write to the same row from different threads, so an
edit is a transaction: read, transform, write back, with the id and the
creation stamp forced from the stored row. Without it the editor would
re-enable an alarm the engine had just resolved as disabled, or move a row's
history.
`AudioSourcePolicy` is where the silent sentinel becomes an empty source list
— the audio player then fails to start by design, and the ring falls through
to vibration instead of to nothing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wmy1BpCKi8KeSjaWhYuCPV
The countdown on a row is formatting, not scheduling: it turns the engine's
resolved instant into "in 9h 12m", rounding whole minutes up so a row never
reads a minute it has already passed. Repeat masks become their own short
summaries with the week starting where the locale says it does.
A ringtone is a URI, plus one explicit silent sentinel — "Silent" has to be a
choice a user can make, and it resolves to no audio source at all, which is
what forces vibration rather than a quiet alarm.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wmy1BpCKi8KeSjaWhYuCPV
Shows over the lock screen, turns it on, keeps it on, and swallows back for
the whole lifetime of the window — including while the session is still
loading, which is where a back press used to finish the activity and leave
the alarm sounding with nothing on screen to stop it.
The optional dismiss challenge cannot strand the user: the standing wrong
answer clears the moment they start typing the next one.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wmy1BpCKi8KeSjaWhYuCPV
`NavigationSuiteScaffold` gives the bar on a phone and the rail on a wide
layout from one declaration. Each tab keeps its own back stack; re-selecting
a tab returns to its root, and predictive back is wired only where back
actually goes somewhere.
The pill is the running-state surface from PLAN §9: it floats above the bar,
speaks for whichever subject is closest to needing attention, and pauses or
stops it from any tab. Its callbacks are gated on a live subject, so a second
tap during its exit animation cannot restart a timer the user just reset.
The notification permission is asked once, and the flag is written from the
request's result rather than before it — a process death mid-dialog leaves
the ask due again rather than recorded and never made. Three tabs are empty
shells until M5 through M8 fill them.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wmy1BpCKi8KeSjaWhYuCPV
Which of a running timer, a running stopwatch and a snoozed alarm the pill
speaks for is a pure selection over the repositories' flows, ordered so the
thing closest to needing attention wins. Countdown text truncates where a
stopwatch rounds up, because a timer reading 0:01 must still have a second
left in it.
The dismiss challenge is monotone in both escape routes — neither a wrong
answer nor a re-roll can make the way out shorter — so a half-awake user is
never stranded.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wmy1BpCKi8KeSjaWhYuCPV
The shell's navigation bar and its rail on wide layouts are one component,
`NavigationSuiteScaffold`, pinned to the material3 version already in the
catalogue rather than a version of its own.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wmy1BpCKi8KeSjaWhYuCPV
The engine and the domain stay plain Kotlin, which is the only reason the
DST, skip and snooze behaviour can be pinned by 326 JVM tests instead of an
emulator. That is worth a test that fails the build when it stops being true.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A systemExempted foreground service holds the ring: audio focus, USAGE_ALARM,
the volume ramp, vibration, and the ringtone URI. It goes foreground
synchronously before it reads anything from the database, because the platform
kills a service that reaches startForeground late — including on the path where
there turns out to be no session to ring.
The chain that matters is the one that never ends in silence. A full-screen
intent when canUseFullScreenIntent() allows it; a high-priority heads-up
notification that still rings when it does not; the device's default alarm
ringtone when the configured URI will not open; and forced vibration when no
audio source will play at all.
Preparing a MediaPlayer is done off the main thread — a synchronous prepare at
the moment the alarm fires is an ANR — and a player is released rather than
orphaned on every failure path, so a dismissal cannot be outlived by the ring
it cancelled.
The ring screen here is plumbing and a placeholder: window flags to show over
the lockscreen and turn the screen on. Its UI is M4.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
All five are thin: inject, goAsync, make one call into the engine, finish in a
finally. Anything resembling a decision belongs in the engine where it can be
tested without a device.
TIME_SET and TIMEZONE_CHANGED both mean the same thing to the engine — every
resolved instant is now suspect — so both re-resolve from scratch rather than
trying to patch the registrations they already made.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Only the single next alarm is ever registered, via setAlarmClock, so the
platform draws the status-bar icon and the alarm is exempt from doze. A second
slot holds the auto-silence backstop, kept deliberately separate so cancelling
one cannot cancel the other.
USE_EXACT_ALARM is declared for the versions that grant it outright, with a
SCHEDULE_EXACT_ALARM fallback path behind canScheduleExactAlarms() above that
boundary. Capabilities are exposed as a snapshot; asking the user for the
permission is M4's job and explaining it is M10's.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The engine owns every write and talks to Android through four interfaces it
does not implement, which is what keeps it a plain Kotlin class with 54 JVM
tests over fakes rather than something that needs a device to exercise.
At most one alarm rings, and a newly-firing alarm takes over — so closing a
cycle only touches the shared ring and auto-silence slots when that alarm is
the one actually ringing. Dismissing a snoozed alarm from its notification must
not silence a different alarm mid-ring.
Every read-modify-write over alarm_states runs under one mutex. A cold start
triggered by the fire broadcast otherwise races its own reschedule pass and can
overwrite ringingSince, which silences the alarm that woke the process.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Closes M2's known blind spot. On the first pass of a new boot, every RUNNING
timer has its monotonic anchor rewritten from its wall-clock fallback — one
already past its end becomes EXPIRED, one with no fallback becomes PAUSED at
its banked remaining. The stopwatch keeps no wall-clock fallback at all, so it
is paused at what it had banked rather than being allowed to invent a segment
it never ran.
The gate is persisted, so the repair runs once per boot and survives a process
death in between. ARCHITECTURE §5 booked the stopwatch half of this to M7; it
belongs here with the timer half, and the roadmap is amended to say so.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>