`ARCHITECTURE.md` gains §16 for the tab — where the zone list comes from, why ICU sits behind a seam, what `ZoneDirectory` caches and on what key, and why every comparison is read at the instant rather than against a stored offset. The module, package and rule tables count the new arrivals.
2131 lines
128 KiB
Markdown
2131 lines
128 KiB
Markdown
# Clockula — architecture
|
||
|
||
How Clockula is built **today**, after M8. 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 v2 │ │ clockula_prefs │
|
||
└──────────────┬─────────────┘ └─────────┬───────────────┘
|
||
│ │
|
||
SQLite preferences_pb
|
||
```
|
||
|
||
One seam sits **beside** the repositories rather than under them:
|
||
`data/zones/`'s `ZoneNames` and `ZoneDirectory` (M8). They read the *platform* —
|
||
the device's tzdata through `ZoneProvider.available()` and ICU's localised names
|
||
through `android.icu` — not storage, so they have no entity, no DAO and no
|
||
migration. They live in `data/` for the same reason `data/ringtones/` does: the
|
||
thing behind them is an Android API the rest of the app must not name.
|
||
|
||
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/stopwatch/` | the stopwatch path's pure half: `StopwatchReadings` (the one reading the pill, the shade and the tab share), `LapStats` (best and worst), `StopwatchLaps` (the lap cap), `StopwatchNotificationPolicy` |
|
||
| `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` (`current()` and, since M8, `available()` — the device's tzdata), `BootId`, `Ticker` |
|
||
| `domain/worldclock/` | the world clock's pure half (M8): `ZoneCatalog` (`ZoneEntry`, the pickable-id filter, the city fallback, the sort), `ZoneSearch` (folding and word-prefix matching), `ZoneComparison` (the offset and day arithmetic, `OffsetLabel`/`DayLabel`, `ZoneOffsetFormat`), `WorldClocks` (`MAX`, the move arithmetic) + `HomeZone`, `AnalogFace` (hand and tick angles) + `DayNight` (the day fraction) |
|
||
| `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 — `NextFire` (`NextFireLabel` + `NextFireFormat`), the alarm row's "in 9h 12m" as data, and `StopwatchFormat` — the hundredths readout, split into a major field and two digits so the fraction can be drawn smaller |
|
||
| `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)` — unchanged since M2; M8 is the milestone that finally calls all of it |
|
||
| `data/zones/` | the ICU seam (M8): `ZoneNames`, `IcuZoneNames` (the only `android.icu` file in the app) and `ZoneDirectory` — the locale-keyed catalog cache and the DST-keyed display-name memo |
|
||
| `data/stopwatch/` | `LapEntity`, `LapDao` (one `COUNT(*)` added in M7), `LapMapper`, `StopwatchStateStore`, `StopwatchRepository(+Impl)` |
|
||
| `data/prefs/` | `SettingsPrefs`, `ClockPrefs`, `StopwatchPrefs`, `TimerPrefs`, `UiPrefs` |
|
||
| `data/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`, `ZoneModule` (M8 — one `@Binds`, `ZoneNames → IcuZoneNames`) |
|
||
| `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 |
|
||
| `stopwatch/` | `StopwatchEngine` and the one seam it talks to — `StopwatchServiceHandle` — plus `StopwatchIntents` |
|
||
| `stopwatch/android/` | `ServiceStopwatchHandle`, the seam's Android implementation |
|
||
| `stopwatch/receiver/` | `StopwatchActionReceiver` (the notification's buttons; no extras, because there is one stopwatch) |
|
||
| `stopwatch/service/` | `StopwatchService` — the `specialUse` foreground service — and `StopwatchNotifications` |
|
||
| `stopwatch/di/` | `StopwatchModule` — `@Binds` for the one seam |
|
||
| `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. `EmptyTabScreen` is **gone**: its KDoc said it existed only until each of M5–M8 replaced its own tab, and the world clock was its last caller |
|
||
| `ui/common/` | `rememberLocalTimeFormatter` — the app's **one** 12/24-hour formatter, over a `java.time.LocalTime`, with `rememberAlarmTimeFormatter` delegating to it (M8 D17) — 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/` | the real Stopwatch tab (M7): the pure row builder (`StopwatchRows`) and its source, the ViewModel, the readout panel with its controls, the lap table and its column header, `StopwatchDefaults` |
|
||
| `ui/worldclock/` | the real World clock tab (M8): the pure row builders (`WorldClockRows`, `ZonePickerRows`) and their state, `WorldClockSource`, the ViewModel, the tab, the analog face, the row and the zone picker, `WorldClockDefaults` |
|
||
| `ui/ring/` | `AlarmRingActivity`, its ViewModel, `RingScreen` and the dismiss-challenge controls |
|
||
|
||
---
|
||
|
||
## 4. The data model
|
||
|
||
`ClockulaDatabase` is at **version 2** with five tables. Every column is a
|
||
primitive: `Long`, `Int`, `String` or `Boolean`. There are **no Room
|
||
`TypeConverter`s** — enums are stored as `Enum.name` in a `TEXT` column,
|
||
instants and durations as milliseconds, the repeat set as an `INTEGER` bitmask.
|
||
That puts the whole entity↔domain translation inside the mappers, which are
|
||
plain JVM objects that a unit test can feed a corrupt row. A converter would
|
||
move the same translation into generated code, where an unknown enum name throws
|
||
*inside a cursor read* — which is a crashed list, not a degraded row.
|
||
|
||
Column names are snake_case via `@ColumnInfo`, so the SQL, the exported schema
|
||
JSON and M10's JSON backup all read the same vocabulary in a diff.
|
||
|
||
### `alarms`
|
||
|
||
| Column | Type | Notes |
|
||
|---|---|---|
|
||
| `id` | INTEGER pk | autogenerated |
|
||
| `hour`, `minute` | INTEGER | read back through `TimeOfDay.clamped` |
|
||
| `label` | TEXT | |
|
||
| `enabled` | INTEGER | indexed — `enabled()` filters on it |
|
||
| `repeat_days` | INTEGER | 7-bit mask, see below |
|
||
| `skip_next_occurrence` | INTEGER | |
|
||
| `ringtone_uri`, `vibrate`, `snooze_minutes`, `snooze_limit`, `volume_ramp_seconds`, `dismiss_challenge` | nullable | **overrides**; `NULL` = inherit `ClockDefaults`. For `ringtone_uri`: `NULL` = inherit, `clockula://silent` = deliberate silence (§13), anything else a content URI |
|
||
| `created_at`, `updated_at` | INTEGER | wall-clock epoch millis |
|
||
|
||
Per-alarm settings are nullable *overrides*, never concrete copies of the
|
||
defaults. Storing concrete values would mean a later change to a default
|
||
silently failed to reach alarms the user never customised — the opposite of what
|
||
"default" means. `Alarm.resolveSettings(defaults)` is the pure function that
|
||
collapses the two, and it uses `?:` throughout: a stored `false` for `vibrate`
|
||
is a choice, not an absent value.
|
||
|
||
`AlarmDao` is an **`abstract class`** rather than an interface, so it can carry
|
||
a `@Transaction open suspend fun updateWithin(id, transform)` — a read, a
|
||
transform and a write as one unit, mirroring `TimerDao.updateWithin`.
|
||
`AlarmRepository.edit(id, transform)` is its domain face: it stamps `updated_at`
|
||
from the wall clock and forces the id, so a transform can change a field but
|
||
never move a row. The editor writes through it exclusively, because the engine
|
||
can call `setEnabled(id, false)` underneath an open editor when a one-shot
|
||
alarm's cycle closes — and a whole-row write from a stale read would re-enable
|
||
an alarm that had just rung. **M5 changes no schema**: no column was added, no
|
||
version bumped, no migration written; a DAO becoming an abstract class changes
|
||
no SQL.
|
||
|
||
### `alarm_states`
|
||
|
||
Volatile ring state, one row per alarm, added at **v2** (M3).
|
||
|
||
| Column | Type | Notes |
|
||
|---|---|---|
|
||
| `alarm_id` | INTEGER pk | also a `FOREIGN KEY … ON DELETE CASCADE` to `alarms(id)` |
|
||
| `snoozed_until` | INTEGER? | the absolute instant a snooze is due |
|
||
| `snooze_count` | INTEGER | snoozes taken in the current ring cycle |
|
||
| `ringing_since` | INTEGER? | non-null exactly while the alarm is ringing |
|
||
| `handled_occurrence` | INTEGER? | the occurrence of the most recent ring cycle |
|
||
| `skipped_occurrence` | INTEGER? | the occurrence `skip_next_occurrence` is armed on |
|
||
|
||
It is a table and not five more columns on `alarms` for three reasons: volatile
|
||
ring state must not appear in M10's JSON backup of an alarm, deleting an alarm
|
||
must take its ring state with it (hence the cascade), and the alarm mapper's
|
||
tests stay about alarms.
|
||
|
||
**A missing row is not an error.** `AlarmStateRepository.state(id)` returns
|
||
`AlarmRingState.initial(id)` — every instant null, count 0 — and nothing is
|
||
written until there is something to write. `upcoming()` therefore resolves every
|
||
alarm without creating a single row.
|
||
|
||
### The two rules that keep re-resolution honest
|
||
|
||
An alarm's next fire time is never stored; it is re-resolved from the local
|
||
time-of-day and the current zone on every fire, boot, `TIME_SET`, zone change
|
||
and edit (`PLAN.md` §4). Two failure modes fall out of that, and each has
|
||
exactly one rule. They are the app's least obvious invariants, and the ones a
|
||
future reader will otherwise "simplify" away.
|
||
|
||
**The fire-grace window.** Candidates are generated strictly after
|
||
`now - AlarmRing.FIRE_GRACE` (2 minutes). A fire delayed by doze, a slow boot or
|
||
a `TIME_SET` nudge still counts, and a device powered on at 07:01 still rings
|
||
its 07:00 alarm. An alarm missed by hours does *not* ring hours later.
|
||
|
||
**The `handled_occurrence` watermark.** Written the moment an alarm fires. A
|
||
candidate is suppressed **iff** `candidate <= handledOccurrence` **and**
|
||
`candidate <= now`. The second half is load-bearing: without it, a user who set
|
||
the clock forward, let an alarm fire, then set it back would have every future
|
||
occurrence suppressed forever. With it, only a past-or-present candidate can be
|
||
suppressed — the watermark can silence a re-fire, but it can never silence the
|
||
future.
|
||
|
||
One column, three bugs: "do not ring the same occurrence twice", "a backwards
|
||
`TIME_SET` must not re-ring a dismissed alarm" and "a snoozed alarm's natural
|
||
occurrence must stay quiet" are the same rule.
|
||
|
||
### The skip watermark
|
||
|
||
`alarms.skip_next_occurrence` is the user-facing boolean;
|
||
`alarm_states.skipped_occurrence` is the concrete instant the skip is armed on.
|
||
The flag alone is unusable: resolve at 06:00 on Monday for a daily 07:00 alarm
|
||
and you correctly get Tuesday, but resolve again at 08:00 — after the skipped
|
||
occurrence has gone by — and a naive "drop the first candidate" gives Wednesday,
|
||
so the skip eats a second alarm. With the watermark:
|
||
|
||
- flag set, no watermark ⇒ arm it on the first eligible candidate and fire the
|
||
one after;
|
||
- watermark still in the future ⇒ fire the first candidate after it; write
|
||
nothing;
|
||
- watermark at or before now ⇒ consumed: clear the flag and the watermark, and
|
||
*still* return the first candidate after it, so the skipped occurrence cannot
|
||
ring on its way out through the grace window.
|
||
|
||
Arming is deferred while a snooze is pending, and a **non-repeating** alarm with
|
||
the flag set is *disabled* rather than skipped: a one-shot has exactly one
|
||
occurrence, so skipping it is dismissing it in advance.
|
||
|
||
### `timers`
|
||
|
||
| Column | Type | Notes |
|
||
|---|---|---|
|
||
| `id` | INTEGER pk | |
|
||
| `label` | TEXT | |
|
||
| `duration_millis` | INTEGER | the configured length; `addTime` never moves it |
|
||
| `state` | TEXT | `IDLE`/`RUNNING`/`PAUSED`/`EXPIRED`; unknown degrades to `IDLE` |
|
||
| `remaining_millis` | INTEGER | authoritative when not `RUNNING` |
|
||
| `started_at_elapsed_realtime_millis` | INTEGER? | `RUNNING` only |
|
||
| `ends_at_elapsed_realtime_millis` | INTEGER? | `RUNNING` only — **authoritative** |
|
||
| `ends_at_wall_clock_millis` | INTEGER? | `RUNNING` only — **post-reboot fallback only** |
|
||
| `ringtone_uri` | TEXT? | `NULL` = inherit `ClockDefaults.timerRingtoneUri` |
|
||
| `sort_order` | INTEGER | indexed |
|
||
| `created_at`, `updated_at` | INTEGER | wall-clock epoch millis |
|
||
|
||
**M6 changes this table by not one column.** No migration, no version bump: the
|
||
database stays at v2. The only new persistent fact the Timers tab needs is
|
||
*when the current ring session began sounding*, and that is a **single record**,
|
||
which §5 of `docs/PLAN.md` sends to DataStore rather than to Room. It is not a
|
||
`timers` column for the three reasons this section already gives: volatile ring
|
||
state must not appear in M10's JSON backup of a timer, it must not survive the
|
||
timer's deletion, and the timer mapper's tests should stay about timers. It is
|
||
not a `timer_states` table either, because at most one timer ring sounds at a
|
||
time — so it is `timer_ring_sounding_since_millis` behind `TimerRingStateStore`
|
||
(§6).
|
||
|
||
### `world_clocks`
|
||
|
||
| Column | Type | Notes |
|
||
|---|---|---|
|
||
| `id` | INTEGER pk | |
|
||
| `zone_id` | TEXT | **unique index**; IANA zone id |
|
||
| `label` | TEXT? | `NULL` = show the ICU-resolved city name (M8) |
|
||
| `sort_order` | INTEGER | indexed |
|
||
|
||
`zone_id` being unique is what makes `WorldClockRepository.add` idempotent: it
|
||
trims the id, rejects a blank or non-IANA one with `IllegalArgumentException`,
|
||
and returns the *existing* row's id when the zone is already there. The lookup,
|
||
the sort-order read and the insert happen in one transaction
|
||
(`WorldClockDao.addIfAbsent`), so no other writer can slip a row in — or delete
|
||
the row that won — between them, and the id handed back is always a row that
|
||
exists rather than the insert's `-1` sentinel. Validity
|
||
means membership of `java.time.ZoneId.getAvailableZoneIds()`, so `UTC` is
|
||
accepted and fixed offsets like `+02:00` are not — IANA zone ids, not a bespoke
|
||
city table (`PLAN.md` §5). A zone the device's tzdata later drops is **kept**,
|
||
not blanked; `WorldClock.isKnownZone` reports it and the UI can show it as
|
||
broken. Losing the user's row silently would be worse.
|
||
|
||
**M8 changes no schema here.** M2 built exactly the surface the world clock
|
||
needs, so the milestone that finally uses this table adds no column, no version
|
||
bump and no migration, and `WorldClockEntity`, `WorldClockDao`,
|
||
`WorldClockMapper` and `WorldClockRepository(+Impl)` are not edited at all. What
|
||
it does is give all three of `label`, `sort_order` and the unique `zone_id`
|
||
their **first caller**: a row shows the user's `label` when it has one and the
|
||
ICU city otherwise, `sort_order` carries the hand reorder (`reorder`'s first
|
||
caller, from both a drag and the accessibility actions), and `zone_id`'s
|
||
uniqueness is what lets the picker leave an already-added zone tappable. The
|
||
read path M2's comment promised is real too: a zone the tzdata dropped is kept
|
||
and drawn with no time, no offset and no day (§16).
|
||
|
||
### `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.
|
||
|
||
**M7 changes no schema here.** The table already had every column the tab
|
||
needs, so the stopwatch milestone adds no column, no version bump and no
|
||
migration; `LapDao` gains exactly one `@Query("SELECT COUNT(*) …")`, which the
|
||
engine's lap cap reads so appending a lap never materialises 999 rows. The
|
||
database stays at **version 2**.
|
||
|
||
### The repeat mask
|
||
|
||
Bit `n` is ISO day `n + 1`: **Monday is bit 0, Sunday bit 6**, so the conversion
|
||
is `1 shl (day.value - 1)` against `java.time.DayOfWeek.value` with no lookup
|
||
table. `RepeatDays`'s constructor is private and every entry point sanitises, so
|
||
a corrupt stored mask (high bits, negative) can only ever *narrow* to the seven
|
||
valid bits.
|
||
|
||
Note for M9: the platform `AlarmClock.EXTRA_DAYS` contract speaks
|
||
`java.util.Calendar` constants (Sunday = 1 … Saturday = 7). **That translation
|
||
happens at the intent boundary in M9, never in storage.**
|
||
|
||
### Reading is forgiving
|
||
|
||
Every mapper read degrades rather than throws: out-of-range hours clamp, unknown
|
||
enum names fall back through `core-prefs`' `toEnum`, blank strings read back as
|
||
`null`, negative millis clamp to zero. An absent `dismiss_challenge` stays
|
||
`null` (it is an unset override); a *present but unknown* one degrades to `NONE`.
|
||
|
||
### The schema is the contract
|
||
|
||
The exported JSON lives at
|
||
`app/schemas/de.jeanlucmakiola.clockula.data.db.ClockulaDatabase/`, one file per
|
||
version, all committed, and the directory is also wired in as an `androidTest`
|
||
asset directory so `MigrationTestHelper` can open a v1 database on device.
|
||
`SchemaExportTest` fails the JVM test run if a version goes missing — the
|
||
previous one is never regenerated away. There is **no
|
||
`fallbackToDestructiveMigration`**: `PLAN.md` §12 requires tested migrations
|
||
from v1, and a destructive fallback would quietly eat a user's alarms.
|
||
|
||
`Migrations.ALL` is the single list the database builder is handed and the list
|
||
the tests assert on. `MIGRATION_1_2` is one `CREATE TABLE alarm_states`, copied
|
||
verbatim from the exported schema's `createSql`, and the instrumentation test
|
||
runs it against a real v1 database and *validates* the result against `2.json` —
|
||
a migration that fails eats the user's alarms, so it is proved rather than
|
||
eyeballed.
|
||
|
||
---
|
||
|
||
## 5. The two clocks
|
||
|
||
`PLAN.md` §5 calls the wall-clock / elapsed-realtime distinction "the single
|
||
easiest thing to get wrong in a clock app". This is the section to read before
|
||
touching anything time-shaped.
|
||
|
||
```kotlin
|
||
interface WallClock { fun now(): Instant }
|
||
interface ElapsedRealtimeClock { fun elapsedRealtime(): Duration }
|
||
```
|
||
|
||
Both are **injected everywhere and never called statically** — that is the only
|
||
reason the distinction can be tested from both sides. Their Android
|
||
implementations are two one-line classes in `data/time/`
|
||
(`System.currentTimeMillis()` and `SystemClock.elapsedRealtime()`). Any new API
|
||
here must name which clock it takes in its parameter list; a bare `now()` is
|
||
forbidden.
|
||
|
||
| Uses the wall clock | Uses elapsed realtime |
|
||
|---|---|
|
||
| alarms (they are calendar facts) | a running timer's countdown |
|
||
| `created_at` / `updated_at` on every row | the stopwatch |
|
||
| a running timer's post-reboot fallback, and nothing else | |
|
||
|
||
### A running timer's three anchors
|
||
|
||
`started_at_elapsed_realtime_millis` is the monotonic instant of the last
|
||
start/resume; `ends_at_elapsed_realtime_millis` is when it expires and is
|
||
**authoritative**; `ends_at_wall_clock_millis` is the same instant on the wall
|
||
clock and is a **fallback only**.
|
||
|
||
`Timer.snapshotAt(elapsedRealtime, wallClock)` decides staleness
|
||
**monotonically, without touching the wall clock at all**:
|
||
|
||
> `elapsedRealtime() < startedAtElapsedRealtime` ⇒ this is a different boot,
|
||
> because `elapsedRealtime()` never decreases within one boot.
|
||
|
||
So a user moving the system clock cannot make the anchor look stale and cannot
|
||
warp a running timer — forwards or backwards. The wall-clock value is read
|
||
*only* when that monotonic test says "different boot", and then only to answer
|
||
"approximately how much is left", with `TimerSnapshot.anchorIsStale = true` so
|
||
the UI can say so. Without it, a reboot would strand every running timer.
|
||
|
||
**Known blind spot, accepted:** the monotonic test only catches a reboot while
|
||
the *new* boot's uptime is still below the old `startedAtElapsedRealtime`. Once
|
||
the device has been up longer than that, the same comparison says "same boot"
|
||
and the row is resolved against a dead pre-reboot anchor, reading as **live and
|
||
not stale** — `endsAtWallClock` is never consulted.
|
||
|
||
This window opens sooner than "after it would have expired". A timer started two
|
||
minutes into a boot re-enters it about two minutes into the *next* boot: a
|
||
30-minute timer started at uptime 2 min, with the device rebooted ten minutes
|
||
later, reads as live and non-stale from uptime 2 min of the new boot onwards, and
|
||
counts down from a number that means nothing. So: **a stale anchor can read as
|
||
live, well before expiry.** The cost is accepted here because the alternative —
|
||
consulting the wall clock to decide staleness — would let a user moving the
|
||
system clock warp a running timer, which `PLAN.md` §5 forbids outright.
|
||
|
||
Closing it properly needs a persisted boot identifier, and **M3 added one**.
|
||
`RebootRepair` runs at the top of every boot — from `SystemEventReceiver`'s
|
||
`BOOT_COMPLETED` and again from `ClockulaApp.onCreate`, so a broadcast the
|
||
system never delivered is still caught — and rewrites every running timer from
|
||
`endsAtWallClock` before the new uptime can climb past any stored anchor. A
|
||
timer already past its wall-clock end becomes `EXPIRED`; one with no wall-clock
|
||
fallback is paused at what it banked.
|
||
|
||
The gate is the boot id, not a flag in memory: `BootId` is
|
||
`Settings.Global.BOOT_COUNT` (API 24+, no permission), and two ids that both
|
||
have a count are the same boot exactly when the counts match. When either side
|
||
has no count — a device that will not give `BOOT_COUNT` up — it falls back to
|
||
comparing `wallClock.now() - elapsedRealtime()`, the instant the device booted,
|
||
within one minute. That fallback is **best-effort and documented as such**: the
|
||
derived instant drifts under NTP correction, which is what the tolerance
|
||
absorbs. It costs nine lines and means an unreadable `BOOT_COUNT` degrades to
|
||
approximately-right rather than to never noticing a reboot. The id is stored as
|
||
one DataStore string, `last_boot_id`, and decoding is strict — anything
|
||
malformed reads as "no known previous boot", i.e. "repair".
|
||
|
||
The mirror image of the reboot repair is the **clock change**. A reboot kills
|
||
the monotonic anchors and leaves `endsAtWallClock` standing;
|
||
`ACTION_TIME_CHANGED`/`ACTION_TIMEZONE_CHANGED` does the opposite — the
|
||
monotonic anchors still hold and the wall-clock one has just become a lie. That
|
||
value is not decoration: it is the countdown notification's chronometer base and
|
||
the reboot fallback, so a stale one makes a running timer's notification read
|
||
*finished* and a reboot after the change ring the timer at the wrong minute. So
|
||
`TimerEngine.onSystemTimeOrZoneChanged` calls `reanchorWallClocks`, which
|
||
re-derives `endsAtWallClock` for every RUNNING row from what its **monotonic**
|
||
anchor says is left, writes nothing when the value has not moved, and leaves a
|
||
row whose anchor is already stale to `repairAfterReboot`. Nothing is
|
||
rescheduled: the expiry slot is `ELAPSED_REALTIME_WAKEUP`, so a clock change
|
||
cannot move it.
|
||
|
||
Every timer write is a read-modify-write inside **one transaction**
|
||
(`TimerDao.updateWithin`): the row is read, the domain rule applied and the
|
||
result written back before another writer can interleave. The ringing service
|
||
marking a timer expired and the user adding a minute to it therefore cannot
|
||
swallow each other's edit.
|
||
|
||
`addTime` — "+1 min" — is one rule over all four states, and the extra is
|
||
measured **from the tap, never from an anchor that has gone by**:
|
||
|
||
| Before | After |
|
||
|---|---|
|
||
| `RUNNING` | still running; both end anchors rebased on `snapshot.remaining + extra`, from one reading of both clocks |
|
||
| `PAUSED` | still paused, `remaining + extra`. The user paused deliberately |
|
||
| `EXPIRED` | **`RUNNING` with exactly `extra` left**, all three anchors written, in the same one transaction |
|
||
| `IDLE` | nothing. A stale notification button is a silent no-op |
|
||
|
||
`duration_millis` is never moved on any branch, so `reset` still returns the
|
||
timer to the length the user configured — which is what makes a `timers` row its
|
||
own preset.
|
||
|
||
M6 rewrote the `EXPIRED` branch, which M2 left "paused, not running: the user
|
||
still has to press start". "+1 min" on a timer that has just rung is the single
|
||
most common gesture in a timer app, and a second tap on Start is a papercut.
|
||
Resuming in the same transaction is also the only way to avoid emitting an
|
||
intermediate `PAUSED` frame to the live pill and to the row — and the reasoning
|
||
is M2's own, generalised: *the user asked for `extra` more than zero, not
|
||
`extra` more than an anchor that has gone by.*
|
||
|
||
A `RUNNING` row missing either elapsed anchor is treated as corrupt, not as
|
||
stale-by-reboot: it reads back whatever it last banked, flagged stale. It never
|
||
throws — a throw inside a list read is a crashed screen.
|
||
|
||
### The stopwatch has no fallback, on purpose
|
||
|
||
`StopwatchRun` carries **no wall-clock field whatsoever**, and `StopwatchPrefs`
|
||
stores no wall-clock value (a test asserts that on the stored bytes). A
|
||
stopwatch that spans a reboot has lost information nobody can reconstruct, and
|
||
unlike a timer there is nothing to ring. A stale run therefore resolves to
|
||
**paused at the accumulated time**, discarding the lost segment, with
|
||
`anchorIsStale = true`. Discarding is the honest answer; guessing would be a lie
|
||
displayed to two decimal places.
|
||
|
||
`StopwatchRun.snapshotAt` decides staleness with the same monotonic test as the
|
||
timer, and had the same blind spot: once the new boot's uptime passes the stored
|
||
`startedAtElapsedRealtime`, the reboot goes unnoticed and the run reports
|
||
`accumulated + (elapsedRealtime - startedAtElapsedRealtime)` as **live and not
|
||
stale** — a segment it never actually ran.
|
||
|
||
**M3 closes it.** The same boot gate that repairs timers also calls
|
||
`StopwatchRepository.pauseAfterReboot()`: a `RUNNING` run is written back
|
||
`PAUSED` at exactly its banked `accumulated`, with the start anchor dropped and
|
||
nothing invented in its place. A paused or idle run is not written at all. That
|
||
is three lines on top of machinery the alarm engine needs anyway, and leaving a
|
||
knowingly-fabricated elapsed reading on screen for two more milestones was not
|
||
defensible. **M3 owns the stopwatch's post-reboot repair; M7 owns its
|
||
presentation.**
|
||
|
||
**M7 closes the loop.** `RebootRepair`, `pauseAfterReboot` and `snapshotAt` are
|
||
untouched; what M7 adds is what the user then sees, and it is decided in one
|
||
pure place, `StopwatchReadings.of` (§15). The rule it carries is that **the mode
|
||
comes from the snapshot, not from the stored row**: a `RUNNING` record whose
|
||
anchor did not survive reads `PAUSED` at what it banked, so a stale run shows
|
||
Resume · Reset rather than a Pause button and a ticking chronometer, on all
|
||
three surfaces at once.
|
||
|
||
One honest consequence, recorded rather than papered over. A lap recorded inside
|
||
a segment the repair later discarded keeps its own cumulative, which can exceed
|
||
the run's banked `accumulated` — a stopwatch that ran forty seconds and lapped
|
||
twice, then met a reboot, has `accumulated = 0` and `lastLapCumulative = 40s`.
|
||
The reading is therefore **floored at the last lap's total** as well as at zero:
|
||
a readout sitting below a lap the same run printed is not one anyone can
|
||
believe, and the lap is a real measurement. The laps themselves are never
|
||
deleted to make the arithmetic tidy, and the in-progress row's split is clamped
|
||
at zero for the same reason. Reset clears all of it in one tap.
|
||
|
||
The floor is **banked, not only displayed**. Resuming such a run writes
|
||
`accumulated = max(accumulated, lastLapCumulative)`, and pausing and lapping
|
||
bank the same floored reading rather than the raw snapshot. Without that the
|
||
record would tick from below its own last lap: the tab would sit frozen at the
|
||
floored value for as long as the discarded segment lasted while the shade's
|
||
chronometer — derived from the floored reading once and then free-running —
|
||
counted on without it, and the next lap would record a cumulative *below* the
|
||
previous one and drag every readout backwards. `pauseAfterReboot` itself is
|
||
still untouched; the banking happens on the way back out, in
|
||
`StopwatchRepositoryImpl.start`, `pause` and `lap`.
|
||
|
||
For the same reason the engine's verbs guard on **the reading's** mode rather
|
||
than the stored row's, so they agree with the controls the user is being
|
||
offered: between a reboot and the repair, a record that still says `RUNNING`
|
||
reads `PAUSED` everywhere, and its Resume button resumes (banking first, as the
|
||
repair would have) instead of hitting a silent "already running" no-op, while
|
||
Lap is refused rather than recorded below the last one.
|
||
|
||
**M8 adds a wall-clock surface.** The world clock is a calendar fact about
|
||
somewhere else, so it reads `WallClock.now()` and never `ElapsedRealtimeClock` —
|
||
the same column of the table above that alarms sit in. What is worth writing
|
||
down is the *cadence*: **both the instant and the device zone are re-read on
|
||
every tick**, from the injected `WallClock` and `ZoneProvider`. That is what
|
||
makes `TIME_SET` and `TIMEZONE_CHANGED` free for this tab — it self-heals
|
||
within one tick, and `SystemEventReceiver` needs no new branch. The zone never
|
||
comes from `ZoneId.systemDefault()` at a call site; a build rule (§8) fails on
|
||
one anywhere under `domain/` or `ui/`.
|
||
|
||
---
|
||
|
||
## 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` — the last of which M7 is the first caller of, for the in-progress lap's split | `StopwatchPrefs`, behind `StopwatchStateStore` |
|
||
| System facts (M3) | `last_boot_id` | `SystemPrefs`, behind `BootStateStore` — the persisted half of the reboot gate |
|
||
| Timers (M6) | `timer_presets`, `timer_ring_sounding_since_millis` | `TimerPrefs` — the presets surfaced as `SettingsPrefs.timerPresets`, the ring session's anchor behind `TimerRingStateStore` |
|
||
|
||
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`.
|
||
|
||
`home_zone_id` got its **first reader and its first writer in M8**, two
|
||
milestones after the key was added. The reader is `HomeZone.resolve(stored,
|
||
deviceZone)`, which is the whole home-zone rule in one pure function — the
|
||
stored zone when the device still knows it, else the device's own — and the
|
||
writer is the World clock tab's top-bar action, which also clears it so home
|
||
follows the device again. Nothing is seeded: a fresh install has no stored home
|
||
zone and no world clock row, and the hero face simply shows the device's zone
|
||
(§16).
|
||
|
||
`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. Nine 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` |
|
||
| `ZoneModule` | `@Binds` for the one ICU seam: `ZoneNames → IcuZoneNames` (M8). `ZoneDirectory` needs no binding — it is a `@Singleton` `@Inject` class |
|
||
| `StopwatchModule` | `@Binds` for the stopwatch engine's one seam: `StopwatchServiceHandle` (the foreground service). It has no scheduler, because nothing the stopwatch does has a deadline |
|
||
| `TimerModule` | `@Binds` for the timer engine's three seams: `TimerScheduler` (the one elapsed-realtime slot), `TimerServiceHandle` (the foreground service), `AlarmRingStatus` (read-only: "is an alarm ringing") |
|
||
|
||
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 first 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.
|
||
|
||
`ZoneDirectory` (M8) is the **second**, for the same reason: building the zone
|
||
catalog is ~450 ids times three ICU calls, ICU dispatches nothing of its own,
|
||
and the build happens the first time a picker opens. It is also cached on the
|
||
locale tag — AppCompat's per-app language can change under a running activity —
|
||
and guarded by one `Mutex`, so two collectors opening the picker at once build
|
||
once.
|
||
|
||
---
|
||
|
||
## 8. Testing
|
||
|
||
JVM-first. 1 139 unit tests run in the gate; 40 instrumentation tests compile in
|
||
it and run only on a device. There is **no Robolectric** and no plan for it:
|
||
everything that would have needed a shadow is behind one of the injected seams,
|
||
or is a pure function in `domain/alarm/`, `domain/ring/` or `domain/timer/`.
|
||
|
||
That posture had to be *earned* again when the UI arrived, not merely kept. The
|
||
shell's whole decision surface was pushed out of the composables and into pure
|
||
Kotlin: `ShellNavigation` (what a tab tap does to the back stack, and where back
|
||
goes), `LivePillSelector` (which of several running things the pill is about),
|
||
`ClockFormat` (the readout) and `ChallengeGate` + `MathProblems` (the barrier in
|
||
front of a dismissal, and the promise that it always opens). What is left in the
|
||
composables is `Text(...)` over those values. The two ViewModels that need a main
|
||
dispatcher get one from a twenty-line JUnit 5 `MainDispatcherExtension`.
|
||
|
||
What genuinely needs hardware is written as the nine new instrumentation tests
|
||
and honestly labelled as such: a window really appearing over a lock screen with
|
||
`FLAG_KEEP_SCREEN_ON` set, a real system back press, a real activity recreation,
|
||
and a TalkBack-shaped accessibility click completing the hold challenge.
|
||
|
||
- **Fakes over `MutableStateFlow`.** The five fake DAOs are backed by a
|
||
`MutableStateFlow<List<Entity>>`, so a write re-emits on the observing flow
|
||
exactly as Room's would. The three abstract DAOs are *extended*, not
|
||
reimplemented, so their real `@Transaction` bodies (`reorder`, `appendLap`,
|
||
`updateWithin`, `addIfAbsent`) are the ones under test. The fakes also mirror the constraints SQLite
|
||
enforces: a duplicate `zone_id` insert returns `-1`, a duplicate `lap_index`
|
||
throws, ring state for an alarm that is not there fails the foreign key, and
|
||
an explicit id moves the row-id allocator past it as SQLite's would.
|
||
- **Six fakes for the engine's seams.** `FakeAlarmScheduler` holds each of the
|
||
two AlarmManager slots as the value it currently holds, so a test asserts on
|
||
what the system would be showing rather than on a call log;
|
||
`FakeRingCoordinator` records start/stop *in order*, because the order is the
|
||
take-over contract; `FakeAlarmNotifier`, `FakeAlarmCapabilities`,
|
||
`FakeZoneProvider` (a zone a test can change under a scheduled alarm) and
|
||
`FakeBootIdProvider` complete the set.
|
||
- **A real DataStore on a temp file.** The preference and stopwatch tests write
|
||
an actual `preferences_pb` under a JUnit 5 `@TempDir`, which is what makes
|
||
"the run survives process death" a real assertion — a second repository is
|
||
constructed over the same file — rather than a fake's memory.
|
||
- **Injected fake clocks.** `FakeWallClock` moves by hand, including backwards,
|
||
as a user can; `FakeElapsedRealtimeClock` has an explicit `reboot()`. The
|
||
headline test starts a timer, jumps the wall clock three hours each way, and
|
||
asserts both that the remaining time does not move and that the stored row is
|
||
byte-for-byte unchanged.
|
||
- **Pure functions carry the hard parts.** DST, the resolver's precedence, the
|
||
volume ramp and the "never to silence" policies are all `object`s with no
|
||
collaborators, so the milestone's riskiest behaviour is asserted a hundred
|
||
times over with hand-built inputs — including all 128 repeat masks and four
|
||
zones with awkward transitions.
|
||
- **Instrumentation-only** is the half a fake cannot prove: generated SQL,
|
||
unique indices, real `@Transaction` behaviour, the foreign key cascading, the
|
||
v1 → v2 migration validated against the exported schema, and the database
|
||
opening at version 2.
|
||
- **`ArchitectureRulesTest`** greps the main source set for Room leakage above
|
||
`data/` and Android imports inside `domain/`, `alarm/AlarmEngine.kt` and
|
||
`system/`. It is the boundary of §2 made mechanical — and the engine's
|
||
testability is a build-gate fact rather than a habit.
|
||
|
||
**M5 kept the posture for a whole form**, which is where a screen usually
|
||
reaches for Robolectric. Everything a tap decides was pushed out of the
|
||
composables: `AlarmListRows` (what the list shows, in what order, which row is
|
||
"next"), `NextFireFormat` ("in 9h 12m"), `RepeatDaysSummary`/`RepeatDaysOrder`
|
||
("Weekdays", and which day column comes first), `AlarmDefaults` (a new alarm's
|
||
time, resolved through a spring-forward night), `AlarmOverrides` (inherited or
|
||
chosen), `RingtonePicker` (which rows exist and which is checked),
|
||
`AudioSourcePolicy` (the fallback order, silence included) and `AlarmRoutes`
|
||
(the route strings). Every write, every reschedule and every preview is a method
|
||
on one of the two ViewModels, which the tests build over the **real**
|
||
`AlarmEngine` across the fake DAOs and fake seams — the arrangement
|
||
`testing/AlarmEngineHarness.kt` now lifts out of `AlarmRingViewModelTest`. What
|
||
is left in the composables is `Text(...)`, `Switch(...)` and `stringResource`.
|
||
|
||
Three `ArchitectureRulesTest` rules pin that mechanically: no `*ViewModel.kt`,
|
||
`*UiState.kt` or `*Source.kt` imports `androidx.compose.`; the same files import
|
||
no `android.`; and no file under `ui/` names `AlarmResolver` or
|
||
`AlarmOccurrences` — the screen takes the engine's resolution and re-derives
|
||
none of its time arithmetic. All three held retroactively for M4's files, so
|
||
they pin existing discipline rather than an aspiration.
|
||
|
||
What was left to the device is the seven new instrumentation tests: the row's
|
||
switch really toggling and the row really dimming, the FAB creating an alarm and
|
||
landing in its editor, the M3 time picker writing a typed time, a day toggle
|
||
changing the repeat summary, a cancelled system document pick changing nothing,
|
||
back returning to a still-selected Alarms tab, and the delete confirmation
|
||
removing the row. **They have not been run**: no device is attached to the
|
||
machine this milestone was built on, so the gate compiles them and this
|
||
paragraph says so.
|
||
|
||
**M6 kept it for a second screen — and for a foreground service**, which is the
|
||
other place a project usually gives in. Every decision left the composables and
|
||
the service: `TimerReadings` (the app's one timer precedence, extracted from
|
||
M4's pill so the pill, the notification and the ring cannot disagree),
|
||
`TimerExpiry` (what is due, and what the single slot should hold),
|
||
`TimerRingPolicy` (the whole audio arbitration), `TimerNotificationPolicy`
|
||
(the subject, the count, the two actions, the alert-once rule), `TimerPresets`,
|
||
`TimerDurationEntry` (the keypad, as a state machine over a digit string) and
|
||
`TimerListRows`. `TimerService` holds **no policy and no state** beyond one
|
||
`alreadyAlerted` flag: it asks `TimerEngine.serviceState(...)` and actuates the
|
||
answer, and even the two `delay` amounts come from the engine — so the timings
|
||
are asserted in a JVM test.
|
||
|
||
Three new fakes complete the seam set in M3's shape: `FakeTimerScheduler` holds
|
||
the expiry slot **as the value it currently holds**, `FakeTimerServiceHandle`
|
||
records the up/down transitions **in order**, and `FakeAlarmRingStatus` is a
|
||
settable boolean behind a `MutableStateFlow`, so an arbitration test hands the
|
||
engine a `true` instead of building alarm state.
|
||
`testing/TimerEngineHarness.kt` is `AlarmEngineHarness`'s arrangement for the
|
||
other engine: the **real** `TimerEngine` over the real `TimerRepositoryImpl`,
|
||
the fake DAO, the three fake seams, the real `RebootRepair` and a real DataStore
|
||
under a `@TempDir` — with a counting `DataStore` wrapper, so "the session anchor
|
||
is persisted **once** across three reads" is assertable without reaching into
|
||
the store.
|
||
|
||
Three more `ArchitectureRulesTest` rules: `timer/TimerEngine.kt` joins the
|
||
Android-free list; no file under `ui/` *uses* `TimerScheduler`, `TimerService`,
|
||
`TimerRingPolicy` or `AlarmManager` (the mirror of the alarm rule — the screen
|
||
schedules nothing and decides no ring); and `MediaPlayer` appears only under
|
||
`ring/` and `data/ringtones/`, which makes "reuses M3's audio path" mechanical:
|
||
a third audio path cannot be added without the build failing.
|
||
|
||
What was left to the device is four more instrumentation tests: the keypad and
|
||
the bottom sheet really composing and really writing, the typed digits
|
||
surviving a real activity recreation (the only thing that proves
|
||
`rememberSaveable`), one row's Pause leaving a second running row alone, and a
|
||
real back press from the timer editor landing on a still-selected Timers tab.
|
||
**They have not been run**, for the same reason M5's have not: no device is
|
||
attached to the machine this milestone was built on.
|
||
|
||
**M8 kept it for a fourth screen**, and it was the milestone most tempted to
|
||
break it, because ICU and `Canvas` both look like they need a device. They do
|
||
not. Everything ICU answers is behind `ZoneNames`, faked in a unit test.
|
||
Everything the face *draws* is `DrawScope` calls over angles from `AnalogFace`
|
||
and a fraction from `DayNight` — two pure objects with no collaborators, so the
|
||
geometry is asserted at every minute of the day without a pixel. Everything the
|
||
list and the picker *decide* is pure too: `WorldClockRows`, `ZonePickerRows`,
|
||
`ZoneSearch`, `ZoneComparisons`, `ZoneCatalog` and `WorldClocks`. And everything
|
||
the tab *writes* is a `WorldClockViewModel` method, tested over the **real**
|
||
`WorldClockRepositoryImpl` across `FakeWorldClockDao` and the real
|
||
`SettingsPrefs` over a real DataStore under a `@TempDir`.
|
||
|
||
One new fake and one new harness. `FakeZoneNames` is settable per method, counts
|
||
calls per method — which is what makes "the catalog is built once" and "the
|
||
display name is asked for once per DST phase" assertable — and carries a
|
||
`failEverything` flag that makes every call throw, which is what makes "the
|
||
directory never propagates an ICU failure" assertable. `WorldClockHarness` is
|
||
`StopwatchEngineHarness`'s arrangement for a tab with no engine.
|
||
|
||
Three more `ArchitectureRulesTest` rules: `android.icu` is named only under
|
||
`data/zones/`, so the ICU seam stays one file; `MaterialShapes` and
|
||
`androidx.graphics.shapes` are named only under `ui/worldclock/`, which makes
|
||
`PLAN.md` §8's "not sprinkled everywhere" mechanical (the ring screen and timer
|
||
progress are sanctioned too, so widening the allowlist is a deliberate edit
|
||
rather than a drift); and no file under `domain/` or `ui/` uses
|
||
`ZoneId.systemDefault()` or `TimeZone.getDefault()` in code — the zone is a
|
||
parameter, from `ZoneProvider`. All three held retroactively for M0–M7's files.
|
||
|
||
Four more instrumentation tests: the hero face really composing and its
|
||
semantics really reaching TalkBack, the picker's real `Dialog` and real IME
|
||
really adding a city, and the **Remove** and **Move up** accessibility actions
|
||
really working the way TalkBack would drive them. **They have not been run** —
|
||
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).
|
||
|
||
M8 adds **one** dependency coordinate: `androidx.graphics:graphics-shapes`,
|
||
pinned at `1.0.1`, the version that already resolved transitively through
|
||
`androidx.compose.material3`. `MaterialShapes` lives in material3, but `Morph`
|
||
and `RoundedPolygon` are `androidx.graphics.shapes`, and the world clock's face
|
||
imports that package directly — a direct import on an undeclared transitive
|
||
dependency is how a Renovate bump breaks a build nobody declared. Pinning to
|
||
what already resolved means the dependency graph does not move in this
|
||
milestone.
|
||
|
||
### 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.
|
||
|
||
**M8 adds no permission and no manifest entry at all.** ICU, `java.time` and
|
||
Compose drawing need none: the world clock schedules nothing, wakes nothing,
|
||
rings nothing and posts no notification.
|
||
|
||
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 |
|
||
| 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 |
|
||
| 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 |
|
||
| Lap export, sharing, deletion or editing, and any "previous run" history. A run's laps belong to the run that produced them; keeping old runs is a different feature with its own table | not planned for v1 |
|
||
| A Lap button on the live pill. The pill has two controls by design, and a third would rewrite a contract M4 asserted | not planned for v1 |
|
||
| A user-configurable lap cap or readout precision. Both are constants with reasons — 999 laps, hundredths — and M10's settings screen is not asked to carry them | not planned for v1 |
|
||
| Reordering, grouping or filtering laps. The list is index order, newest first, and that is the whole of it | not planned for v1 |
|
||
| A countdown-to-start, split-lap alarms, or any sound or vibration from the stopwatch. It is silent, and a build rule says so (§8) | not planned for v1 |
|
||
| An analog stopwatch face or a `MaterialShapes` showpiece on the Stopwatch tab. `PLAN.md` §8 sanctions the showpiece for the analog world-clock face, the ring screen and timer progress; a stopwatch has no progress to be a fraction of | not planned for v1 |
|
||
| Surfacing `StopwatchReading.anchorIsStale` in the UI, for the same reason the timers' is not: the repair makes it momentary, and the value displayed is already honest | not planned for v1 |
|
||
| A rename UI for `world_clocks.label`. The column is honoured on **read** — a row shows the label when it has one — but M8 adds no way to write it; M10's backup import will carry labels | M10 |
|
||
| An automatic "home" row while travelling. Google Clock's automatic home clock is a separate feature with its own preference and its own edge cases; M8's answer to "where am I" is the hero face, which already follows the device when no home zone is stored | not planned for v1 |
|
||
| A per-city detail screen, a map, sunrise/sunset times or a day-length bar. `DayNight` is a stated convention for a shape morph, not an ephemeris (§16) | not planned for v1 |
|
||
| Live times or long zone names in the zone picker. 450 rows of ICU long names is real work for a list the user scrolls past, and a ticking picker is a ticking picker | not planned for v1 |
|
||
| Reordering world clocks by anything but `sort_order` — no grouping by continent, no sort-by-offset, no pinning. The list is the order the user put it in | not planned for v1 |
|
||
| `MaterialShapes` on the ring screen or timer progress. `PLAN.md` §8 sanctions both; neither is rebuilt in M8, and the build rule (§8) makes widening the allowlist a deliberate edit | not planned for v1 |
|
||
| Screenshots and store listing polish | M11 |
|
||
|
||
Also absent by design: any seeded default data — no starter alarm and no home
|
||
world clock on first run, so the empty states are real empty states.
|
||
|
||
Four more deliberate gaps the alarm engine leaves open:
|
||
|
||
- **No missed-alarm notification or history.** An auto-silenced alarm is missed
|
||
silently. Not on the roadmap for v1.
|
||
- **No upcoming-alarm notification.** `getNextAlarmClock()` already draws the
|
||
status-bar icon, which is the platform's answer.
|
||
- **No direct-boot awareness**, so an alarm cannot ring in the window between a
|
||
reboot and the first unlock — see §9.
|
||
- **No bundled fallback ringtone.** The audio chain ends in forced vibration
|
||
(§11), which makes a shipped asset a path a real device cannot reach.
|
||
|
||
---
|
||
|
||
## 11. The alarm engine
|
||
|
||
The milestone's real deliverable, and the section to read before changing
|
||
anything alarm-shaped.
|
||
|
||
### The shape
|
||
|
||
`AlarmResolver.resolve(alarm, state, now, zone)` is **pure**. It returns the next
|
||
fire instant, where it came from, *the ring state as it should now be persisted*,
|
||
and two instructions that live outside the state table (`clearSkipFlag`,
|
||
`disableAlarm`). It never touches a repository, a clock or
|
||
`ZoneId.systemDefault()`, which is why the hard half of this milestone is
|
||
callable from a plain JUnit test with hand-built inputs and no fakes at all.
|
||
|
||
`AlarmEngine` owns every write the resolver asks for and every call into the
|
||
four seams. It is **never driven by a Flow**: resolution writes state, so an
|
||
engine that rescheduled on each repository emission would re-trigger itself on
|
||
its own writes. `reschedule()` is called explicitly — from `ClockulaApp`, from
|
||
the receivers, and from the alarm editor. `upcoming()` runs the same resolver in
|
||
preview mode and *discards* the state it returns; a read must not write, and a
|
||
test asserts the store recorded nothing across a full collection.
|
||
|
||
`upcoming(refresh: Flow<Unit>)` takes its **re-read cadence as a parameter**.
|
||
The resolver needs a `now`, and `upcoming` reads one inside a `combine` of the
|
||
two repository flows — so a countdown built on it would freeze until the next
|
||
database write. Making the cadence a parameter keeps `PLAN.md` §5's discipline
|
||
(time is a parameter, never an ambient read) without giving the engine a
|
||
`Ticker` and an Android-shaped dependency: the default, `flowOf(Unit)`, means
|
||
"resolve once per repository emission" and leaves every existing caller alone,
|
||
while the alarms screen passes `ticker.ticks(30.seconds)`. Re-resolving is still
|
||
read-only, and a test collects several refreshes and asserts `alarm_states`
|
||
recorded nothing.
|
||
|
||
Every entry point is a read-modify-write over `alarm_states`, and they arrive
|
||
from threads that know nothing about each other — a broadcast on
|
||
`Dispatchers.Default`, `ClockulaApp`'s launch-time re-sync, the ring screen — so
|
||
they are **serialised on one `Mutex`**. Without it, a cold start *caused by* the
|
||
fire broadcast can read the pre-ring state and write it back over `ringingSince`
|
||
or the handled-occurrence watermark, which either silences the alarm or lets it
|
||
ring twice. The lock is not reentrant, so exactly one layer takes it: the public
|
||
entry points. `ClockulaApp` goes through `onBootCompleted()` rather than calling
|
||
repair, resume and reschedule one by one, so its whole pass is inside the lock.
|
||
|
||
### Precedence, fixed and total
|
||
|
||
1. **Disabled** ⇒ clear the snooze, the ring and the skip watermark; keep
|
||
`handled_occurrence`, because re-enabling must not un-suppress a dismissal.
|
||
2. **Ringing** within `AUTO_SILENCE_AFTER` ⇒ `RESUMED_RING`, nothing scheduled:
|
||
it is not an alarm to come, it is one that is happening. A clock moved
|
||
*backwards* under a ringing alarm reads as a negative age, which is inside the
|
||
window — so it keeps ringing. Beyond the window it is auto-dismissed as
|
||
missed and the resolution falls through.
|
||
3. **Snoozed** in the future ⇒ the snooze is the next fire, unless the natural
|
||
occurrence is earlier (defined so the function is total). Due-but-past by less
|
||
than the ring window ⇒ still the snooze, at its own past instant, which
|
||
AlarmManager fires at once: a snooze promised before a reboot is kept. Older
|
||
than that ⇒ abandoned.
|
||
4. **The natural occurrence**, with §4's grace window, watermark and skip rules.
|
||
|
||
### The ring cycle
|
||
|
||
A cycle opens when the alarm fires and closes on dismiss, on the snooze limit
|
||
being reached, or on auto-silence after `AUTO_SILENCE_AFTER` = **10 minutes**
|
||
(AOSP DeskClock's default; not user-configurable in v1). Snoozing keeps it open.
|
||
|
||
A **non-repeating alarm is disabled when its cycle closes, never when it opens** —
|
||
disabling it at the start would clear its own snooze and the alarm would vanish
|
||
mid-snooze.
|
||
|
||
Closing a cycle only touches the ring service and the auto-silence registration
|
||
**when that alarm is the one actually ringing**. Both are single, global slots
|
||
(there is one ring service, and stopping it stops whatever it is playing), so
|
||
dismissing a merely *snoozed* alarm from its notification must leave a different,
|
||
genuinely ringing alarm sounding with its backstop intact.
|
||
|
||
**At most one alarm rings, and a newly-firing alarm takes over.** Two ringtones
|
||
at once is not a feature, and queueing invents a state machine nobody can debug
|
||
at 07:00. The alarm that is displaced keeps its watermark, so it does not come
|
||
back the moment the slot is re-resolved.
|
||
|
||
`snoozeLimit == 0` means **snoozing is disabled**, not unlimited — v1 offers no
|
||
unlimited, and "0 means infinite" is a trap. A refused snooze *dismisses*; it
|
||
never leaves the user with a ringing alarm they cannot silence.
|
||
|
||
### Two AlarmManager slots, deliberately separate
|
||
|
||
| Slot | Registered with | Purpose |
|
||
|---|---|---|
|
||
| next alarm | `setAlarmClock(AlarmClockInfo(fireAt, showIntent), …)` | the user's next alarm. Doze-exempt, and the only variant that populates `getNextAlarmClock()` |
|
||
| auto-silence backstop | `setExactAndAllowWhileIdle` | stops a ring nobody dismissed |
|
||
|
||
They are separate because `getNextAlarmClock()` is what draws the status-bar icon
|
||
and the lockscreen line: a backstop sharing that slot would overwrite the user's
|
||
07:00 with an internal 07:10. Two `PendingIntent`s, two request codes, two
|
||
independent cancels — and a test asserts no two request codes collide, because
|
||
that collision is exactly how a backstop eats a user's alarm. A **snooze does**
|
||
go through the next-alarm slot: a snoozed alarm *is* the next alarm.
|
||
|
||
If exact alarms are unavailable the alarm is still registered, with
|
||
`setAndAllowWhileIdle`. There is no third branch: late is survivable, silent is
|
||
not.
|
||
|
||
### DST
|
||
|
||
`AlarmOccurrences` walks *local dates* forward and maps each to an instant with
|
||
one `ZonedDateTime.of(date, time, zone)` call — the only DST-aware call in the
|
||
app. Adding 24 hours to yesterday's fire time is the bug this milestone exists
|
||
not to have.
|
||
|
||
| Transition | java.time's default resolver | What the user sees |
|
||
|---|---|---|
|
||
| **Gap** (spring forward) — the local time does not exist | shifts forward by the gap's own length | a 02:30 alarm on a Berlin spring-forward night rings at **03:30** local. It rings; it is never skipped. A 30-minute gap moves it by 30 minutes, not an hour |
|
||
| **Overlap** (fall back) — the local time happens twice | takes the **earlier** offset | a 02:30 alarm on a fall-back night rings **once**, at the first 02:30. Never late, never twice |
|
||
|
||
These are AOSP DeskClock's behaviours, they are the two answers a user would
|
||
defend ("I still got woken", "I only got woken once"), and they come free from
|
||
the platform rather than from hand-rolled offset maths. They are locked by tests
|
||
that assert exact instants in Europe/Berlin, America/New_York,
|
||
Australia/Lord_Howe (a 30-minute gap) and Pacific/Apia (a calendar day that never
|
||
existed), not by a comment.
|
||
|
||
A snooze, by contrast, is an **absolute instant**: no transition can move it.
|
||
|
||
### Never to silence
|
||
|
||
The one non-negotiable, and it is a chain of fallbacks rather than a hope. It
|
||
begins at `AudioSourcePolicy.sourcesFor(ringtoneUri)`, a pure function a JVM
|
||
test can enumerate, and the silent sentinel resolves there to an **empty source
|
||
list** — which is precisely what makes step 4 fire, so "silent" means "vibration
|
||
only" rather than "nothing at all":
|
||
|
||
1. the alarm's own ringtone URI, else
|
||
2. the device's default alarm URI, else
|
||
3. `RingtoneManager.getDefaultUri(TYPE_ALARM)`, else
|
||
4. **forced vibration** — `RingFallbackPolicy.vibrationRequired` turns vibration
|
||
on even for an alarm the user set not to vibrate, because with no audio source
|
||
the alternative is silence.
|
||
|
||
The audio is the foreground service's, played over `USAGE_ALARM` /
|
||
`CONTENT_TYPE_SONIFICATION` so Do Not Disturb's alarm exemption applies, with
|
||
`AUDIOFOCUS_GAIN_TRANSIENT` — an alarm interrupts; it does not duck. The volume
|
||
ramp is a pure function, `MIN + (1 - MIN) · progress²`, stepped every 200 ms into
|
||
`MediaPlayer.setVolume` and **never** into `AudioManager.setStreamVolume`, which
|
||
would edit the user's own alarm volume and leave it edited.
|
||
|
||
`RingPresentationPolicy` decides how the ring is *presented*, and its
|
||
`soundsAnyway` is true in all eight capability combinations — asserted
|
||
exhaustively, because that is the invariant the app lives on.
|
||
|
||
**M6 shares this chain with the timer path.** The `MediaPlayer` wrapper, the
|
||
`USAGE_ALARM` attributes, the transient focus request, the prepare-and-release
|
||
loop, the ramp stepper, the looped waveform, `AudioSourcePolicy`,
|
||
`RingFallbackPolicy` and `VolumeRamp` all moved to `ring/` and `domain/ring/`
|
||
and are used verbatim by both — so "silent" still means "vibration only" for a
|
||
timer too. What the alarm **keeps to itself** is the full-screen intent, the
|
||
ring activity, the dismiss challenge and the snooze; what the timer
|
||
parameterises is its own ringtone, a ramp of **zero seconds** (a timer is set by
|
||
someone awake, and a gentle start is just a quiet start) and its own
|
||
auto-silence constant. §14 has the rest, including which of the two wins the
|
||
one audio stream.
|
||
|
||
---
|
||
|
||
## 12. The app shell
|
||
|
||
Four tabs, one host, and one surface that follows whatever is running.
|
||
|
||
### Navigation
|
||
|
||
`NavigationSuiteScaffold` from `material3-adaptive-navigation-suite`, taking its
|
||
default `navigationSuiteType`: the M3 Expressive **short navigation bar** on a
|
||
compact width and the **wide rail** on medium and expanded ones. Taking the
|
||
default rather than writing `if (compact) NavigationBar else NavigationRail` is
|
||
the point — the default also answers the tabletop posture and the compact-*height*
|
||
case a hand-rolled branch quietly gets wrong.
|
||
|
||
The `NavHost` is the single source of truth for the selected tab. There is no
|
||
`var selectedTab by rememberSaveable`: the selection is derived from
|
||
`currentBackStackEntryAsState()`, and Navigation Compose already saves its back
|
||
stack through a `SavedStateHandle`, so the tab survives rotation and process
|
||
death with no mirror of ours to drift.
|
||
|
||
The policy that back stack follows is a pure function, `ShellNavigation`:
|
||
|
||
- a tab tap is `popUpTo(start) { saveState = true }` + `launchSingleTop` +
|
||
`restoreState`; re-selecting the tab you are already on is the same command
|
||
with `restoreState = false`, which pops that tab's own inner stack back to its
|
||
root — the standard behaviour, and the contract M5's editor will rely on;
|
||
- back from any of the other three tabs returns to **Alarms**; back from Alarms,
|
||
from a null route and from any route the shell does not recognise leaves the
|
||
app — a nested destination's back belongs to the `NavController`, not to us.
|
||
|
||
Predictive back is the kit's `Modifier.predictiveBack`, gated on exactly that
|
||
policy: on Alarms the handler is *off*, so the gesture falls through to the
|
||
system and previews leaving the app, which is the truthful preview. Tab switches
|
||
use the kit's `fadeThrough()` — peer destinations have no spatial relationship,
|
||
and a slide would claim a hierarchy that is not there.
|
||
|
||
### The live pill
|
||
|
||
`PLAN.md` §9 asks for a running-state surface reachable from every tab. Its real
|
||
predicate is **active, not running**: pausing from the pill must not make the
|
||
pill vanish under the thumb that pressed it, or there is no way to resume or
|
||
stop from another tab — which is the flaw the pill exists to fix. So it shows for
|
||
`RUNNING`, `PAUSED` *and* `EXPIRED`, and its own Stop — which is `reset()`,
|
||
never `delete()` — is what removes it.
|
||
|
||
Precedence is total; first match wins:
|
||
|
||
| # | Subject |
|
||
|---|---|
|
||
| 1 | a timer that reads as **expired right now** (stored `EXPIRED`, or stored `RUNNING` whose snapshot has reached zero) |
|
||
| 2 | a timer that reads as **running**, the one with the smallest remaining |
|
||
| 3 | a timer that reads as **paused** |
|
||
| 4 | the **stopwatch**, when its state is not `IDLE` |
|
||
| 5 | otherwise no pill |
|
||
|
||
Ties inside a timer bucket break on `sortOrder` then `id` — the same "lower id
|
||
wins" rule the alarm engine's precedence already uses, so the app has one rule.
|
||
Timers outrank the stopwatch because a timer has a deadline: a missed timer costs
|
||
something, a missed stopwatch tick costs nothing.
|
||
|
||
**M6 moved rows 1–3 into `TimerReadings` and rewrote `LivePillSelector` to call
|
||
it.** The notification and the ring need the same answer — "which of several
|
||
running timers is this about" — and three copies of one comparator is how three
|
||
surfaces start disagreeing. `LivePillSelectorTest` passing **unmodified** is the
|
||
proof the extraction changed no behaviour; `LivePillSelectorPrecedenceTest`
|
||
cross-checks the two callers against each other. The pill keeps its own
|
||
three-valued `LivePillMode`, because it has a stopwatch to describe too, mapped
|
||
from `TimerMode` in one total `when`.
|
||
|
||
**M6 also re-pointed the pill's timer actions at `TimerEngine`.** M4 called
|
||
`TimerRepository.pause/start/reset` directly; after M6 there is an AlarmManager
|
||
registration and a foreground service that have to move with the state, so
|
||
pausing from the pill would otherwise leave the expiry slot pointing at a dead
|
||
deadline and the service posting a stale notification. Self-healing — the next
|
||
sweep would find nothing due — but wasteful and wrong.
|
||
|
||
**M7 did the same for the stopwatch branches**, for the same reason: after M7
|
||
there is a foreground service that has to move with the state, so pausing from
|
||
the pill would otherwise leave the shade showing a running stopwatch, and Stop
|
||
would leave the service up with nothing to show. The pill's own contract is
|
||
unchanged — it shows for RUNNING and PAUSED, never IDLE; Stop is `reset()` and
|
||
never `delete()`; and it gets **no Lap button**, because the pill has two
|
||
controls by design.
|
||
|
||
The mode and value come from `TimerReadings` / `StopwatchReadings`, both of them
|
||
over the stored row's *snapshot* rather than over the row — so §5's whole elapsed-realtime-versus-wall-clock story,
|
||
stale anchor and all, is honoured once. The consequence that matters: a running
|
||
timer that reaches zero flips the pill to `EXPIRED` immediately, without waiting
|
||
for the expiry sweep to write `markExpired`. The pill never counts into
|
||
negative time, and never reads `0:00` while claiming to run.
|
||
|
||
M4 noted that this two-writer case could not be demonstrated by hand. **It can
|
||
now**, and it is correct: the pill and the sweep both read through
|
||
`Timer.snapshotAt`, so the frame the pill shows before the write lands says
|
||
exactly what the row will say after it.
|
||
|
||
The pill is **not** a live region: at 1 Hz TalkBack would recite the countdown
|
||
for as long as it ran. The readout carries a full sentence as its content
|
||
description instead, read when focused and never announced unprompted.
|
||
|
||
### The third time-shaped seam
|
||
|
||
`Ticker` joins `WallClock`, `ElapsedRealtimeClock` and `ZoneProvider` in
|
||
`domain/time/`. A readout has to advance without a repository write, and the
|
||
discipline of §5 is that time is a *parameter*: so "re-read the clocks now"
|
||
became an injected flow rather than a `delay` at a call site. It emits once
|
||
immediately and then every period — one second for the pill, since the pill
|
||
formats to whole seconds — so a fresh subscriber is never blank. `RealTicker` is
|
||
nothing but `delay`, which makes it testable under `runTest`'s virtual clock.
|
||
|
||
### The dismiss challenge, and why it cannot strand anyone
|
||
|
||
`ChallengeGate` is a pure state machine whose single boolean `open` means
|
||
"dismissing is permitted now". `PLAN.md` §4's "must never be able to strand the
|
||
user" is made executable rather than promised: the escape hatch becomes
|
||
available on **either** three wrong answers **or** sixty seconds of ringing,
|
||
whichever comes first, for every challenge kind — asserted as a parameterised
|
||
invariant over `DismissChallenge`, not as three happy paths.
|
||
|
||
Three more guarantees hold alongside it. Snooze is never gated: the gate protects
|
||
dismissal only. Auto-silence still ends the ring at ten minutes regardless of the
|
||
gate, because that is the engine's, and the screen only follows the session to
|
||
`Finished`. And a wrong answer carries no lockout and no penalty timer — it
|
||
replaces the problem and that is all.
|
||
|
||
A completed hold *is* the dismissal, and a correct answer *is* the dismissal: no
|
||
"solve it, then press Dismiss again", because a half-awake person should not have
|
||
to discover a second step. The hold target also carries an accessibility
|
||
`onClick` that completes it outright — TalkBack cannot perform a press-and-hold,
|
||
and a challenge a screen reader cannot answer is precisely the stranding the
|
||
requirement forbids.
|
||
|
||
---
|
||
|
||
## 13. The alarms screen
|
||
|
||
The first tab with real content, and the milestone that gives the schema of §4
|
||
and the engine of §11 a face.
|
||
|
||
### Two destinations in one flat host
|
||
|
||
The list stays on the `ALARMS` tab route; the editor is a **sibling** route in
|
||
the same `NavHost`, `alarms/edit/{alarmId}`, with a `NavType.LongType`
|
||
argument. Three properties fall straight out of §12's existing policy rather
|
||
than being re-invented: `destinationOf("alarms/edit/7")` is null, so the Alarms
|
||
tab stays highlighted while the editor is open; `onBack` of that route is
|
||
`Exit`, so the shell's predictive-back handler stands aside and the editor pops
|
||
itself; and a tab tap's `popUpTo(start.route)` pops the editor, which is the
|
||
right behaviour and exactly the contract §12 wrote down for this milestone.
|
||
|
||
There is **no "new alarm" sentinel id**. The FAB creates the alarm — enabled,
|
||
non-repeating, at the next whole hour, every override null — and then navigates
|
||
to its id. One route shape, one load path, and no branch in the ViewModel for
|
||
"the thing I am editing does not exist yet".
|
||
|
||
### The row
|
||
|
||
A canonical two-line M3 `ListItem` through the kit's `GroupedRow`: the formatted
|
||
time with the label beside it in `onSurfaceVariant`, a summary of
|
||
`repeat · next fire · skipping next`, and a `Switch`. A **disabled** alarm is
|
||
`dimmed` — the kit's own answer for "present but switched off", which fades the
|
||
text to the M3 disabled emphasis and leaves the switch at full opacity. The
|
||
**next** alarm is marked with `colorScheme.primary` on its next-fire clause and
|
||
nothing else: `PLAN.md` §8's "Expressive ≠ big or bold", so the emphasis is a
|
||
colour rather than a third line or a larger type ramp.
|
||
|
||
The list is a `LazyColumn` inside `CollapsingScaffold(scrollable = false)`,
|
||
which is the case that parameter exists for, and the FAB sits in the kit's new
|
||
`floatingActionButton` slot (floret-kit 0.6.0). Both the FAB and the list's
|
||
bottom `contentPadding` clear `LocalLivePillInset`, because the pill is
|
||
bottom-centre and the FAB bottom-end.
|
||
|
||
### Ordered by time of day, not by next fire
|
||
|
||
`AlarmEngine.upcoming()` sorts by next fire, because its job is "which alarm is
|
||
earliest to register". That is the wrong order for a list: an 07:00 and a 22:00
|
||
alarm would **swap places at 07:00**, reordering rows under a thumb. So
|
||
`AlarmListRows.from` re-sorts by `time.minutesOfDay` then id — which is also
|
||
exactly what `AlarmDao.observeAll` emits, so the screen's order re-asserts the
|
||
storage order rather than inventing one. A disabled alarm keeps its place: it is
|
||
already dimmed, and sinking it would say the same thing twice while moving a row
|
||
the user did not move.
|
||
|
||
### The next-fire line
|
||
|
||
`NextFireFormat.labelFor(nextFire, now)` returns data, not a string —
|
||
`NotScheduled`, `Imminent`, `InMinutes`, `InHoursMinutes`, `InDaysHours` — and
|
||
the wording lives in `strings.xml`, following `RingUiState`'s precedent. Whole
|
||
units round **up**, the direction `ClockFormat.countdown` already rounds: "in 9h
|
||
12m" must mean *at least* nine hours and eleven-and-a-bit minutes, and an alarm
|
||
61 seconds out must not read "in 1m" when it is nearer two. `InDaysHours`
|
||
exists because a weekly alarm can be six days out.
|
||
|
||
The cadence is 30 seconds (`AlarmsDefaults.NextFireTick`): the readout's unit is
|
||
a minute, so half a unit bounds the displayed error, and the one-second cadence
|
||
the live pill needs would re-resolve every alarm sixty times for a readout that
|
||
can change once. "Ringing now" does not wait for a tick — it arrives as an
|
||
`alarm_states` write, which re-emits at once.
|
||
|
||
### Every override is a picker, and its first option is "App default"
|
||
|
||
A per-alarm setting is a nullable override (§4), so the stored value is
|
||
**ternary**: absent, or a choice that happens to be `false` or `0`. A `Switch`
|
||
cannot say "I have not chosen" — toggling one would silently turn "follows your
|
||
default" into "explicitly on", with no way back. So all five overrides (vibrate,
|
||
gradual volume, snooze length, snooze limit, dismiss challenge) use the kit's
|
||
`OptionPicker`, whose first row is "App default (On)" and names what the default
|
||
currently is. Every option offered is inside `ClockPrefs`' clamps, so nothing
|
||
chosen can be clamped away on the next read, and `snoozeLimit == 0` is labelled
|
||
"No snoozing", never "unlimited" — §11's "0 means infinite is a trap".
|
||
|
||
Each row's summary shows the effective value, prefixed while inherited, so the
|
||
inheritance is visible without opening anything. There is no tri-state hack and
|
||
no "reset this alarm" button to discover.
|
||
|
||
### The ringtone picker
|
||
|
||
Bespoke over `FullScreenPicker(scrollable = false)` rather than `OptionPicker`,
|
||
because a device's tone list can be hundreds of rows. `RingtonePicker.optionsFor`
|
||
is pure and yields four kinds of row, de-duplicated by URI:
|
||
|
||
1. **App default** — summarised with what it currently resolves to, or "Device
|
||
default alarm sound" when the app default is itself null;
|
||
2. **Silent** — `Ringtones.SILENT_URI`, `clockula://silent`. `null` already
|
||
means *inherit*, so silence needs a value of its own, and a private scheme is
|
||
one no ContentProvider URI can collide with. It resolves to an empty audio
|
||
source list, which forces vibration (§11) — the label is "Silent" with the
|
||
summary "Vibration only", so it is not a lie;
|
||
3. **the alarm's own sound when the device does not list it** — a SAF-picked
|
||
file, or a URI another app wrote. Without this row the user's own selection
|
||
would be invisible, and so uncheckable, in the picker that chose it;
|
||
4. the device's alarm tones, title-sorted.
|
||
|
||
"Choose from files…" is an **action** row, not an option, so the pure list stays
|
||
a list of selectable states. A picked document's read grant is made persistent
|
||
through `RingtoneCatalog.persistPickedDocument`; if that fails the URI is
|
||
**still stored** (the fallback chain protects the ring) and the row discloses
|
||
"Clockula may lose access to this file later". A stored sound the catalog cannot
|
||
open right now is likewise **reported, never rewritten** — "unreadable now" and
|
||
"gone forever" are indistinguishable, an unmounted card comes back, and
|
||
discarding the user's choice silently would be the worse failure.
|
||
|
||
Previewing goes through `RingtonePreviewer`, a two-method seam over the same
|
||
`USAGE_ALARM` attributes the ring service uses, so a preview sounds like the
|
||
alarm will. One previewer, so a second preview stops the first; selecting an
|
||
option, closing the picker and `onCleared()` all stop it. Deliberately no audio
|
||
focus and no ramp: a preview is not an alarm.
|
||
|
||
### The rule a future reader will otherwise simplify away
|
||
|
||
**A ringing alarm is dismissed through the engine before it is edited, disabled
|
||
or deleted.**
|
||
|
||
`AlarmResolver`'s disabled branch clears `ringingSince` and the engine persists
|
||
that — but nothing in that path stops the ring service. The service and the
|
||
auto-silence backstop are single *global* slots (§11), touched only by
|
||
`closeCycleLocked`, and `onAutoSilence` returns early once `ringingSince` is
|
||
null. So switching off or deleting a ringing alarm from this screen would clear
|
||
its state and leave it **sounding until the 15-minute wake-lock timeout, with
|
||
the backstop disarmed by its own guard**. Both ViewModels therefore call
|
||
|
||
```kotlin
|
||
if (engine.ringSession()?.alarm?.id == alarmId) engine.dismiss(alarmId)
|
||
```
|
||
|
||
before `setEnabled(id, false)` and before `delete(id)`. It reads storage under
|
||
the engine's own mutex, so it cannot race the fire it is asking about, and it is
|
||
deliberately **conditional**: dismissing unconditionally would also drop a
|
||
merely *snoozed* alarm's pending snooze, which is wrong for a label edit.
|
||
|
||
### Immediate apply
|
||
|
||
There is no Save button, no dirty state and no discard dialog: `PLAN.md` §11
|
||
names Google Clock as the interaction reference, and this is its model. The
|
||
editor edits a persisted row and every control writes at once. The label is
|
||
written on every keystroke through one conflating `MutableStateFlow` — which
|
||
writes the first value and the latest, in order, never interleaved — and
|
||
`onCleared()` re-launches the last value on the kit's `@ApplicationScope`, so
|
||
leaving the editor mid-word cannot lose the word. `reschedule()` is called only
|
||
after a change that can move a fire time (create, delete, enabled, time, repeat
|
||
days, skip-next), so fiddling with sound settings costs no AlarmManager traffic;
|
||
a snooze *length* cannot move a pending snooze, which is stored as an absolute
|
||
instant.
|
||
|
||
Skip-next-occurrence, built in M3 with no caller, is the editor's — and only for
|
||
a **repeating** alarm. The resolver answers a one-shot-with-skip by disabling the
|
||
alarm, and a switch labelled "skip" that silently switched the alarm off would
|
||
be a lie; the user who wants that has the enable switch.
|
||
|
||
Delete lives in the editor behind a confirmation that names the alarm, and there
|
||
is deliberately no undo chip: the delete happens on the editor, so an undo chip
|
||
would have to live on the list, and an accidentally deleted alarm is discovered
|
||
at 07:00 rather than now — the cheap moment to ask is before.
|
||
|
||
---
|
||
|
||
## 14. Timers
|
||
|
||
The section to read before changing anything timer-shaped. `AlarmEngine` and
|
||
`TimerEngine` are **peers**: neither drives the other, and the only thing they
|
||
share is one read-only question (below).
|
||
|
||
### The shape
|
||
|
||
`TimerEngine` is the timers' analogue of §11's engine: plain Kotlin, no
|
||
`android.*` import ever, reaching the platform through three seams it does not
|
||
implement — `TimerScheduler` (one AlarmManager slot), `TimerServiceHandle` (the
|
||
foreground service) and `AlarmRingStatus` (read-only: "is an alarm ringing").
|
||
It owns every write the pure policies ask for, and every public entry point is
|
||
serialised on one non-reentrant `Mutex`, because they arrive from threads that
|
||
know nothing about each other: a broadcast on `Dispatchers.Default`, the
|
||
service's collector, a ViewModel, `ClockulaApp`'s launch pass.
|
||
|
||
It is **not** driven by a Flow, for the same reason the alarm engine is not:
|
||
resolution writes state, so an engine that resynced on every repository emission
|
||
would re-trigger itself on its own writes. `resync()` is called explicitly.
|
||
|
||
The engine owns the **lifecycle** verbs — `start`, `pause`, `reset`, `addTime`,
|
||
`delete`, plus `onExpiryDue`/`resync`/`onBootCompleted` — because each of them
|
||
can move the slot, the service or the ring session, and every one of them is
|
||
reachable from a notification button with no ViewModel in sight. `delete` is on
|
||
the engine and not on a ViewModel-plus-`resync` path deliberately: deleting a
|
||
*ringing* thing and leaving the global ring slot sounding is a bug class M5
|
||
already met once, and the cheapest way not to have it again is to make the wrong
|
||
call impossible to write. **Configuration** edits (`rename`, `setDuration`,
|
||
`setRingtoneUri`) stay on `TimerRepository` and the editor calls them directly.
|
||
|
||
`TimerRepository` deliberately has **no generic `edit(id, transform)`** the way
|
||
`AlarmRepository` does: a timer row carries three derived anchors whose
|
||
consistency is the whole of §5, and a caller handed a `(Timer) -> Timer` can
|
||
break them. `setDuration` is refused unless the timer is `IDLE`.
|
||
|
||
### Expiry: one slot, two triggers, an idempotent sweep
|
||
|
||
There is **one** AlarmManager registration for every timer, holding the earliest
|
||
running deadline, and it carries **no timer id**. The fire is
|
||
`TimerEngine.onExpiryDue()`: mark *every* timer whose snapshot has reached zero
|
||
as `EXPIRED`, then re-register the next earliest. Three properties fall out, and
|
||
each is a test:
|
||
|
||
- a fire delivered **late** still expires everything that came due while it was
|
||
delayed;
|
||
- a fire delivered **early**, or for a timer the user has since stopped, finds
|
||
nothing due and writes nothing — so the trigger needs no grace window and no
|
||
watermark of its own: the anchors *are* the watermark;
|
||
- two timers expiring in the same second are **one pass**, not a race.
|
||
|
||
The base is **`ELAPSED_REALTIME_WAKEUP`**, mirroring the clock the domain
|
||
anchors on: **alarms are `RTC_WAKEUP`, timers are `ELAPSED_REALTIME_WAKEUP`.**
|
||
That is not tidiness, it is the requirement — a wall-clock registration moves
|
||
when the user changes the system time, so a `TIME_SET` would warp a running
|
||
timer's expiry, which §5 forbids. With this base a `TIME_SET` needs no
|
||
re-registration at all, and a test asserts the slot's value and every stored row
|
||
are unchanged across a three-hour clock jump in each direction.
|
||
|
||
`setExactAndAllowWhileIdle` is tried first and `setAndAllowWhileIdle` is the
|
||
fallback. It does **not** go through `SchedulingPolicy`, whose
|
||
`EXACT_ALARM_CLOCK` branch names `setAlarmClock` — and **a timer must never
|
||
populate `getNextAlarmClock()`**, because that slot draws the status-bar alarm
|
||
icon and the lockscreen line and belongs to the user's next *alarm*. Late is
|
||
survivable; silent is not, so there is no third branch.
|
||
|
||
*Known limitation, recorded rather than glossed:* `*AllowWhileIdle` alarms are
|
||
rate-limited per app while the device is idle. Clockula holds `USE_EXACT_ALARM`,
|
||
which exempts it, but a device that refuses the grant could deliver a
|
||
back-to-back sequence of short timers late. The idempotent sweep is one
|
||
mitigation; the second is that **the service, while alive, `delay`s to the same
|
||
deadline and calls the same `onExpiryDue()`**. Neither trigger has to be
|
||
reliable alone.
|
||
|
||
Both `delay` amounts come **from the engine** (`TimerServiceState.expiresIn` and
|
||
`silenceIn`), so the timings are asserted in a JVM test and the service only
|
||
ever does `delay(d)`. Both are floored at `TimerRing.WAKE_FLOOR` (100 ms) so a
|
||
rounding error cannot spin the loop.
|
||
|
||
### One foreground service, alive exactly while something is active
|
||
|
||
`TimerService` is a single `systemExempted` foreground service covering both
|
||
phases — the countdown and the ring. Its lifetime predicate is the live pill's:
|
||
**active, not running.** It is up while any timer is `RUNNING`, `PAUSED` or
|
||
`EXPIRED` and down when every timer is `IDLE` or gone. `PAUSED` is in the
|
||
predicate for §12's reason: dropping the notification on pause would leave a
|
||
user who paused from the shade with no way to resume without opening the app.
|
||
One "active" predicate in the whole app.
|
||
|
||
One service and not two, because a timer expiring while another runs must not
|
||
need a second one, and because the ring phase needs nothing the countdown phase
|
||
does not already have.
|
||
|
||
**The countdown holds no wake lock.** A forty-five-minute timer keeping the CPU
|
||
awake is a battery bug; the AlarmManager slot is what wakes the device. The
|
||
*ring* holds one, with a fifteen-minute timeout, exactly as `AlarmRingService`
|
||
does.
|
||
|
||
The service is a **readout and a control surface, never the timekeeper** — the
|
||
stored anchors and the AlarmManager slot are, and both survive its absence. So
|
||
`TimerServiceHandle.sync` wraps both the start and the stop in `runCatching`,
|
||
and the service holds no policy and no state of its own beyond one
|
||
`alreadyAlerted` flag. Its trigger flow is
|
||
`combine(timers, alarmIsRinging, defaults)` and deliberately **excludes** the
|
||
ring session's anchor, so the engine's own anchor write cannot re-trigger the
|
||
collector that caused it.
|
||
|
||
### The audio arbitration: an alarm wins, and the timer's ring is deferred, not lost
|
||
|
||
Two `USAGE_ALARM` streams at once is not a feature, and §11's ring service and
|
||
auto-silence backstop are single global slots. So a timer that expires while an
|
||
alarm is ringing **is still marked `EXPIRED`** — its data is truthful and the
|
||
user must learn the pasta is done — and its notification says so, but it **does
|
||
not open audio**. When the alarm's cycle closes, the timer starts sounding, and
|
||
*nothing re-arms it*: the decision is a pure function of current state rather
|
||
than an event.
|
||
|
||
```
|
||
TimerRingPolicy.decide(expired, defaults, alarmIsRinging, soundingSince, now)
|
||
```
|
||
|
||
`alarmIsRinging` arrives through one read-only seam, `AlarmRingStatus`, backed
|
||
by `AlarmStateRepository.states()`. The timer engine therefore never reaches
|
||
into `alarm_states` itself, never calls `AlarmEngine`, and never touches the
|
||
alarm's ring slot or its auto-silence registration — asserted. A test hands the
|
||
engine a boolean instead of building alarm state.
|
||
|
||
The reverse case is free and symmetric: an alarm firing over a sounding timer
|
||
flips the flag, the flow re-emits, and the timer's audio stops. `AlarmEngine`
|
||
writes `ringingSince` *before* it starts its own audio, so the flip leads the
|
||
sound.
|
||
|
||
### One ring session for however many timers, and one window
|
||
|
||
- **Expiry is a set.** Every due timer is marked `EXPIRED`.
|
||
- **The ring is one.** The subject is the first expired timer in §12's order,
|
||
and its ringtone is the one that plays.
|
||
- **Stop acknowledges one timer.** Stopping the subject resets *that* timer; if
|
||
another expired timer remains, the ring continues for the next subject. Each
|
||
timer is a separate thing the user set, and silently resetting all of them
|
||
because one was acknowledged destroys the information "which ones finished".
|
||
Two timers cost two taps, which is correct — there is no "stop all".
|
||
- **A session is one window.** It opens when the audio *actually starts
|
||
sounding* and closes when the expired set returns to empty. A second timer
|
||
expiring mid-session inherits the session's remaining window rather than
|
||
buying a fresh ten minutes — the conservative direction, and the same rule as
|
||
§11's "a reboot does not buy the alarm another ten minutes". The anchor is the
|
||
instant the audio *opened*, not the instant of expiry, so a long alarm ring in
|
||
front of it does not eat the timer's whole window.
|
||
- **A lapsed window does not silence the next timer.** A window that has run out
|
||
belongs to the timers the user chose to leave `EXPIRED`; a timer reaching zero
|
||
*after* it is a new event and opens a session of its own. The sweep is where
|
||
that is decided, because the sweep is the only place that knows a timer has
|
||
just come due: a non-empty due set drops a lapsed anchor, and the next pass
|
||
opens a fresh window with its own heads-up. Inheriting stays the rule for a
|
||
timer expiring *inside* a live window.
|
||
|
||
`TimerRing.AUTO_SILENCE_AFTER` is **ten minutes** — its own constant with the
|
||
same value as the alarm's, free to diverge, because coupling the two would make
|
||
one number answer two questions. The load-bearing difference from an alarm:
|
||
**auto-silence stops the noise and leaves the row `EXPIRED`.** An auto-silenced
|
||
alarm is a *missed* alarm and its cycle closes; an auto-silenced timer has still
|
||
finished, and the user coming back must be able to see that and add a minute
|
||
to it. Auto-silence is decided **before** suppression, so an alarm ringing over
|
||
a window that has run out cannot hand it a fresh one.
|
||
|
||
`TimerRing.RAMP_SECONDS` is **0**. The volume ramp exists so an alarm does not
|
||
jolt a sleeping person awake; a timer is set by someone awake who wants to know
|
||
*now*, and a gentle start is just a quiet start. `VolumeRamp.levelAt(_, 0s)`
|
||
already returns `1f`, so "parameterised" means literally one different argument
|
||
to the same player.
|
||
|
||
### The notification: one channel, one id, alert once
|
||
|
||
The subject is §12's; the rest are a count.
|
||
|
||
| Subject reads | Title | Body | Actions |
|
||
|---|---|---|---|
|
||
| `RUNNING` | the label, or "Timer" | the **platform chronometer** counting down | Pause · +1 min |
|
||
| `PAUSED` | the label | "Paused · 4:32" | Resume · Reset |
|
||
| `EXPIRED` | the label | "Finished" | Stop · +1 min |
|
||
|
||
plus "+2 more timers" when other active timers exist. Two actions at most, and
|
||
they are the *same pair* the row offers for that mode — one vocabulary, two
|
||
surfaces. What this gives up knowingly: per-timer controls in the shade for the
|
||
timers that are not the subject. Those are one tap away in the app, and the
|
||
alternative — a notification group with one entry per timer — multiplies ids,
|
||
request codes and orphan-on-kill cases for a rare case.
|
||
|
||
`setUsesChronometer(true)` + `setChronometerCountDown(true)` + `setWhen(base)`
|
||
means the **system** renders the ticking, so the service posts once per state
|
||
change instead of once per second for forty-five minutes. The base is
|
||
`timers.ends_at_wall_clock_millis`, which makes it the single wall-clock value
|
||
in the whole timer path, with one honest consequence: a user changing the system
|
||
clock while a timer runs leaves that stored base — and so the notification's
|
||
readout — pointing at the wrong instant, while the timer itself does not move at
|
||
all. So `SystemEventReceiver`'s `TIME_SET`/`TIMEZONE_CHANGED` branch calls
|
||
`timerEngine.onSystemTimeOrZoneChanged()` — **not to reschedule anything**, but
|
||
to re-anchor `ends_at_wall_clock_millis` from the monotonic anchor (§5) and
|
||
re-post with a base that is true again. A running row with no wall-clock end (a
|
||
corrupt row) falls back to a static readout rather than a chronometer counting
|
||
to a wrong instant.
|
||
|
||
One channel (`timers`, `IMPORTANCE_HIGH`, sound and vibration off — the service
|
||
owns both), one id, and **only the first post of a ring session may alert**. So a
|
||
countdown update never heads-ups and never buzzes, an expiry heads-ups exactly
|
||
once, and there is no channel gymnastics and no second notification. Every
|
||
non-alerting post carries `setSilent(true)` as well as `setOnlyAlertOnce(true)`,
|
||
because `setOnlyAlertOnce` speaks only about *updates* to a notification already
|
||
on screen — on a high-importance channel the first post of a countdown, and the
|
||
placeholder the service goes foreground with, would otherwise pop a banner. The
|
||
rule is a pure function (`TimerNotificationState.alertOnce`); the service owns
|
||
the flag that spends it and scopes it to the session's window, so it resets both
|
||
when the decision goes `Silent` and when a new window opens.
|
||
|
||
Every action is a `PendingIntent.getBroadcast` into `TimerActionReceiver`, in
|
||
the shape `AlarmActionReceiver` already has: `goAsync()`, `@ApplicationScope`,
|
||
one call into the engine, `finish()`. Deliberately **not**
|
||
`PendingIntent.getService` (a background foreground-service start can be
|
||
refused; a broadcast cannot) and not an activity (a control should not have to
|
||
open the app). Nothing is carried across but the timer id, which can be stale —
|
||
so **every engine verb is guarded by the state it makes sense for**, a mismatch
|
||
is a silent no-op, and the notification is re-posted from the truth afterwards.
|
||
Request codes use the `base * 100_000 + (id % 100_000)` stride
|
||
`RingNotifications` already uses, and a test asserts the **union** of alarm and
|
||
timer codes is distinct, because that collision is how one `PendingIntent`
|
||
silently eats another's extras.
|
||
|
||
Tapping the notification opens the **Timers tab**, through the smallest honest
|
||
deep link: the content intent carries `TimerIntents.ACTION_SHOW_TIMERS`,
|
||
`MainActivity` reads it in `onCreate` **and** `onNewIntent` into one
|
||
`mutableStateOf`, and `ClockulaShell` consumes it once by replaying
|
||
`ShellNavigation.onTabSelected` — the same command a tab tap produces.
|
||
`startDestination` stays **Alarms**: it is the `popUpTo` anchor and the root of
|
||
M4's asserted back policy, so changing it for a notification tap would rewrite
|
||
that policy. The pure part is `ShellNavigation.tabForAction(action)`, which M9
|
||
extends with the rest of the `AlarmClock` contract.
|
||
|
||
### Process death and reboot
|
||
|
||
- **Process death** costs nothing. The countdown lives in
|
||
`ends_at_elapsed_realtime` and the AlarmManager registration survives the
|
||
process. `ClockulaApp.onCreate` calls `timerEngine.onBootCompleted()` after
|
||
the alarm engine's pass, which sweeps, re-registers and re-syncs the service.
|
||
If the service itself was killed, the expiry broadcast starts it again.
|
||
- **Reboot** is §5's `RebootRepair`, and M6 gave it a caller that *rings*:
|
||
`TimerEngine.onBootCompleted()` calls `repairIfRebooted()` inside its own
|
||
lock, then sweeps — so a timer whose end passed during the reboot is expired
|
||
by the repair and **sounds when the device comes back**. The boot-id gate
|
||
makes the second call of a boot a no-op, so neither engine depends on the
|
||
other's ordering.
|
||
- **`MY_PACKAGE_REPLACED`** clears AlarmManager, so it calls `resync()`.
|
||
|
||
### The tab
|
||
|
||
A `LazyColumn` in storage order — `sortOrder`, then `id`, which is exactly what
|
||
the repository emits, so the screen re-asserts the order rather than inventing
|
||
one. **An expired timer does not jump to the top**: a row that moves under a
|
||
thumb is worse than a row the user has to look for, which is the answer M5 gave
|
||
for alarms. The pill and the notification pick a subject; the list does not
|
||
reorder for it.
|
||
|
||
Setting a timer is a keypad, a row of preset chips, the readout and one
|
||
**Start** — and the same panel is hosted in a `ModalBottomSheet` over a
|
||
non-empty list and *inline* on an empty one, where it **is** the empty state, so
|
||
there is no "no timers yet" card to look at and then dismiss. Deliberately not
|
||
M5's create-then-navigate: an alarm is live the moment it exists, so creating it
|
||
first is honest, while a timer has a natural commit point and a row created by
|
||
an accidental FAB tap would be litter. There is still no sentinel id, because
|
||
the panel is not bound to a row at all.
|
||
|
||
The typed digits and the sheet's open flag live in the **composable** as
|
||
`rememberSaveable`, not in a ViewModel, so they survive rotation *and* process
|
||
death; the pure `TimerDurationEntry` is derived from the saved digit string, and
|
||
`Start` is guarded by a ViewModel re-entrancy flag. `TimerDurationEntry` shifts
|
||
digits in from the right and reads them as `h*3600 + m*60 + s` **without
|
||
clamping minutes or seconds to 59**, so `0:00:99` is 99 seconds — Google Clock's
|
||
forgiving behaviour, and a pure function with obvious tests. `canStart` is false
|
||
outside 1 s…24 h, which is `ClockPrefs`' own clamp, so nothing the user can
|
||
choose is clamped away on the next read.
|
||
|
||
Presets are **app-wide** quick-start durations in DataStore (§6), not a property
|
||
of one row: a `timers` row already *is* its own preset, because it persists and
|
||
`reset` returns it to its configured duration. The whole management surface is
|
||
two gestures on the setup panel — a Save chip when the typed duration is not
|
||
already one, and each chip's own remove affordance. A new timer's pre-filled
|
||
duration is `ClockDefaults.timerDuration`, the `default_timer_duration_millis`
|
||
preference M2 built and nothing had called until now.
|
||
|
||
A timer has exactly **one** per-setting override, its ringtone, because `timers`
|
||
has exactly one nullable settings column. Vibrate and the (zero) ramp come from
|
||
`ClockDefaults`. The picker is M5's, moved to `ui/common/`, so the row inherits
|
||
all of its behaviour for free: "App default" naming what it resolves to, Silent
|
||
as an explicit sentinel that forces vibration, a SAF-picked file that is still
|
||
stored when its grant cannot be made persistent, and an unreadable sound
|
||
**reported, never rewritten**.
|
||
|
||
The expressive parts are `LinearWavyProgressIndicator` across each card — whose
|
||
**amplitude is the state**: the default wave while running, flat while paused or
|
||
finished — a kit `GroupedSurface` per card with `positionOf(index, count)`, a
|
||
`ButtonGroup` of `FilledTonalButton`s with the press-widening `animateWidth`,
|
||
and `tertiaryContainer` for an expired card, which is *exactly* the token the
|
||
live pill already uses for an expired timer, so the two surfaces agree. The
|
||
readout is `headlineLarge` and the setup panel's entry is `displayMedium` —
|
||
plain scale roles, because `ui/theme/Type.kt` says the big-readout ramp gets
|
||
settled against a working stopwatch in M7, not guessed at in the scaffolding.
|
||
|
||
---
|
||
|
||
## 15. The stopwatch
|
||
|
||
Read this before changing anything stopwatch-shaped.
|
||
|
||
### The shape
|
||
|
||
`StopwatchEngine` is the third engine and a peer of the other two: plain Kotlin,
|
||
**no `android.*` import ever** (a build rule), reaching the platform through
|
||
**one** seam it does not implement, `StopwatchServiceHandle`. Every public entry
|
||
point is serialised on one non-reentrant `Mutex`, because they arrive from
|
||
threads that know nothing about each other — a notification-button broadcast,
|
||
the service's own collector, the tab's ViewModel, the shell's pill,
|
||
`ClockulaApp`'s launch pass.
|
||
|
||
It is deliberately much smaller than `TimerEngine`, because the stopwatch has
|
||
less to own: **no AlarmManager registration at all** (nothing has a deadline),
|
||
**no ring** (nothing expires), **no arbitration** (it makes no sound), and one
|
||
record rather than N rows. What it does have is four verbs — `start`, `pause`,
|
||
`lap`, `reset` — reachable from the shade with no ViewModel in sight, which is
|
||
why they live on the engine and not on a screen. `StopwatchRepository` stays the
|
||
storage face and gained exactly one method, `lapCount()`; nothing above the
|
||
engine calls its lifecycle verbs any more, including `ShellViewModel` (§12).
|
||
|
||
Like the other two it is not driven by a Flow: `resync()` is called explicitly.
|
||
|
||
### The service type, and why not `systemExempted`
|
||
|
||
`specialUse`, with a `stopwatch` subtype, and one new permission. The reasoning
|
||
is in §9 and is the decision in this milestone worth looking at hardest: the
|
||
easy answer would have the app tell the platform it is continuing alarm
|
||
functionality, which it is not.
|
||
|
||
The service is up while the run is `RUNNING` **or** `PAUSED`, and down when it
|
||
is `IDLE` — exactly the live pill's predicate and exactly
|
||
`StopwatchReadings.isActive`, so the shade, the pill and the tab cannot disagree
|
||
about whether there is a stopwatch. `PAUSED` is in it so a user who paused from
|
||
the shade still has a Resume there.
|
||
|
||
One consequence, accepted: after a reboot the repair leaves a running stopwatch
|
||
paused, so the service comes back up with a paused notification nobody asked
|
||
for. It is truthful — the stopwatch *is* paused and its laps are intact — and
|
||
its own Reset clears it in one tap. Resetting automatically would destroy laps
|
||
the user recorded, which is worse.
|
||
|
||
### The chronometer base is derived and never stored
|
||
|
||
The running notification is the **platform chronometer counting up**:
|
||
`setUsesChronometer(true)` + `setChronometerCountDown(false)` + `setWhen(base)`,
|
||
so the system renders the ticking and the service posts once per state change
|
||
rather than sixty times a second. The base is `wallClock.now() - elapsed`,
|
||
computed at post time and **never written anywhere** — which is how the
|
||
notification gets a wall-clock base without breaking §5's "`StopwatchRun`
|
||
carries no wall-clock field whatsoever", a rule a test asserts on the stored
|
||
bytes. The timer's equivalent base *is* a stored column and therefore needs
|
||
re-anchoring on a clock change; the stopwatch's needs nothing but a re-post,
|
||
which is all `onSystemTimeChanged()` does.
|
||
|
||
The shade's readout is therefore whole seconds. A *paused* body is frozen, so it
|
||
shows the full `StopwatchFormat.precise` value — it is stable, and the precision
|
||
is the point of having stopped. The channel is its own, `stopwatch`, at
|
||
`IMPORTANCE_LOW` with sound and vibration off: nothing here ever needs to
|
||
heads-up, because nothing here ever finishes. That is why there is no
|
||
alert-once rule, no `alreadyAlerted` flag and no session window anywhere in this
|
||
path. Notification id 1_003; actions are broadcasts into
|
||
`StopwatchActionReceiver` carrying **no extras at all**, because there is
|
||
exactly one stopwatch.
|
||
|
||
### The readout is hundredths, and it is two strings
|
||
|
||
`StopwatchFormat.readout` returns a `StopwatchReadout(major, hundredths)`, not
|
||
one string: the tab draws `major` at the hero scale and `hundredths` two ranks
|
||
down in `onSurfaceVariant`, baseline-aligned. A readout whose hundredths are the
|
||
same size as its seconds makes the fastest-changing glyphs the loudest ones and
|
||
drags the eye off the number that matters.
|
||
|
||
`major` **is** `ClockFormat.elapsed`, called rather than reimplemented, so the
|
||
stopwatch, the live pill and the timer row can never disagree about what a
|
||
duration reads, and the truncate-never-round rule is inherited rather than
|
||
restated. `hundredths` is `(millis % 1000) / 10`, zero-padded through
|
||
`Locale.ROOT` for the same glyph-column reason. Truncated, never rounded: a
|
||
stopwatch must never read ahead of reality. Hundredths and not tenths (too
|
||
coarse to time anything by hand) and not milliseconds (three digits that are
|
||
noise at 60 Hz).
|
||
|
||
### `StopwatchReadings`: one reading, three callers
|
||
|
||
`StopwatchReadings.of(run, elapsedRealtime)` is the single pure reading, and
|
||
`LivePillSelector`, `StopwatchNotificationPolicy` and `StopwatchRows` all call
|
||
it — the same extraction M6 made for `TimerReadings`, and for the same reason:
|
||
three copies of "what does this read right now" is how three surfaces start
|
||
disagreeing. `LivePillSelectorTest` passing **unmodified** is the proof the
|
||
extraction changed no behaviour. Its two rules are in §5: the mode comes from
|
||
the snapshot, and the value is floored at the last lap's total.
|
||
|
||
### Best and worst
|
||
|
||
Decided in `LapStats`, because the roadmap left them open:
|
||
|
||
- **Fewer than three recorded laps ⇒ no emphasis at all** (`LapStats.MIN_LAPS`,
|
||
asserted as a constant so a change is deliberate). With one lap there is
|
||
nothing to compare; with two, "best" and "worst" mark every row and say the
|
||
same thing twice.
|
||
- **Ties go to the lowest lap index** — "first to achieve it" is the defensible
|
||
reading and it is deterministic.
|
||
- **If the fastest and the slowest split are equal, neither exists.** Every lap
|
||
identical is not a run with a best and a worst in it.
|
||
- **The in-progress lap is never emphasised** and is never in `LapStats`' input:
|
||
it has not finished, so it cannot be the fastest. A lap can therefore never be
|
||
both, which falls out of the rules rather than needing a guard.
|
||
|
||
Best is `colorScheme.primary` and worst is `colorScheme.tertiary` — both scheme
|
||
tokens, and `error` is deliberately not used, because a slow lap is not a
|
||
failure. Colour is not the only channel: each row carries a content description
|
||
naming what it is, since a distinction a screen reader cannot hear is not a
|
||
distinction.
|
||
|
||
### The tab
|
||
|
||
A fixed readout and its controls **above** a lap list — scrolling a long list
|
||
must not put Lap out of reach mid-run — and the list runs **newest first**,
|
||
which is Google Clock's order (`PLAN.md` §11). A lap row is a three-column table
|
||
(index · split · total) and therefore a bespoke `Row` rather than a
|
||
`GroupedRow`: `ListItem` has one trailing slot and a table has two figures.
|
||
|
||
The first row is the **lap in progress**: index `laps.size + 1`, cumulative the
|
||
current reading, split `elapsed - lastLapCumulative` floored at zero — which is
|
||
what `stopwatch_last_lap_cumulative_millis` has existed for since M2 with no
|
||
caller. It appears **only once at least one lap has been recorded**: with none,
|
||
its split *and* its cumulative equal the hero figure, so the tab would print the
|
||
same number three times. It is shown while paused too, frozen, because the user
|
||
stopped mid-lap and that is true.
|
||
|
||
The vocabulary is **Start · Pause/Lap · Resume/Reset**. An idle stopwatch offers
|
||
one button: a Lap with nothing to lap and a Reset with nothing to reset are dead
|
||
controls. Reset is not offered while running — the user pauses first, which is
|
||
Google Clock again; the pill's Stop still resets from any state, which is M4's
|
||
contract and unchanged. The word "Stop" is deliberately absent from this tab: in
|
||
Clockula "Stop" already means "reset", and a Stop that paused would be the one
|
||
inconsistency in the app's verbs.
|
||
|
||
The cadence is `StopwatchDefaults.ReadoutTick` — 16 ms, about 60 Hz, the
|
||
display's own cadence — and the ticker is subscribed **only while the stored
|
||
state is RUNNING**. A paused or idle stopwatch therefore costs nothing, and the
|
||
flow is distinct-until-changed, which absorbs the one case where the record says
|
||
running and the reading does not move. The cadence is a `Ticker` parameter, never
|
||
a `delay` at a call site.
|
||
|
||
`StopwatchLaps.MAX = 999`. Beyond it the Lap button is disabled (carrying "Lap
|
||
limit reached" as its description) and `StopwatchEngine.lap()` returns null. An
|
||
unbounded table behind a button that can be held down is a storage leak the user
|
||
cannot see or clear except by resetting — and 999 keeps the index column three
|
||
digits wide, which is a layout fact as well as a limit. The guard is the
|
||
engine's, under its lock, over `lapCount()`.
|
||
|
||
Deliberately no `MaterialShapes` morph and no wavy progress: a stopwatch has no
|
||
progress — there is nothing to be a fraction of — and a decorative arc would be
|
||
a lie drawn to two decimal places.
|
||
|
||
### The typography, settled here for the whole app
|
||
|
||
`ui/theme/Type.kt` carried this as a written promise from M0, and M7 settles it
|
||
as two things.
|
||
|
||
`ClockulaTypography` applies **tabular figures** (`fontFeatureSettings = "tnum"`)
|
||
to the display, headline and title roles of the M3 default. That is the app-wide
|
||
half: every existing readout — `TimerRow`'s `headlineLarge`, `TimerSetupPanel`'s
|
||
`displayMedium`, the live pill's `titleMedium` — stops jittering as its digits
|
||
change, with **no call-site change anywhere**. Body and label roles keep
|
||
proportional figures, because prose is not a column of numbers.
|
||
|
||
`ClockulaReadoutDefaults` names the three roles a readout needs, in the M3
|
||
`*Defaults` shape (composable getters over `MaterialTheme.typography`, so a
|
||
theme change still flows): `Hero` (`displayLarge`, `FontWeight.Medium`),
|
||
`HeroFraction` (`headlineMedium`, about half the hero's size) and `Figure`
|
||
(`bodyLarge` with tabular figures — a figure inside a table row, the one place
|
||
body-scale text *is* a column of numbers). No new font, no size outside the M3
|
||
scale, and no weight above Medium: `PLAN.md` §8's "Expressive ≠ big or bold"
|
||
applies hardest to the one screen most tempted to break it. A build rule keeps
|
||
`fontFeatureSettings` under `ui/theme/` so it is configured once.
|
||
|
||
---
|
||
|
||
## 16. The world clock
|
||
|
||
The section to read before changing anything zone-shaped.
|
||
|
||
### There is no engine, and there must not be one
|
||
|
||
`AlarmEngine`, `TimerEngine` and `StopwatchEngine` exist because each owns a
|
||
platform registration that has to move with stored state — an AlarmManager slot,
|
||
a foreground service, a ring session. **A world clock owns none of them.** It
|
||
schedules nothing, wakes nothing, rings nothing and posts no notification, so a
|
||
fourth engine would be three empty seams and a `Mutex` guarding nothing.
|
||
|
||
M8's shape is therefore the one §13/§14 describe for a *screen*: a pure state
|
||
builder (`WorldClockRows`), a `Source` that combines the repository, the prefs,
|
||
the directory, the clocks and the ticker into it, and a ViewModel that calls the
|
||
repository and `SettingsPrefs` directly. Every verb the tab has — `add`,
|
||
`remove`, `reorder`, `setHomeZone` — is a plain write with nothing to actuate
|
||
afterwards.
|
||
|
||
### The ICU seam, and which half of tzdata is pure
|
||
|
||
The device's tzdata gives two different things, and they belong on two different
|
||
sides of the boundary.
|
||
|
||
**The ids** are `java.time.ZoneId.getAvailableZoneIds()` — plain JVM, and
|
||
therefore assertable in a unit test with no fake at all. They arrive through the
|
||
existing `ZoneProvider` seam as `available(): Set<String>`, because a static
|
||
`getAvailableZoneIds()` at a call site is the same ambient read `PLAN.md` §5
|
||
bans for `now()`, and a test needs to hand over a small set.
|
||
|
||
**The names** — the exemplar city, the country and the long zone name — are
|
||
`android.icu`, which does not exist on the JVM test classpath. They go behind
|
||
**`ZoneNames`** in `data/zones/`, in exactly the shape `RingtoneCatalog` already
|
||
has. Every method is **total and never throws**: ICU on an unusual OEM build
|
||
must degrade a caption, not crash a tab, so `IcuZoneNames` wraps every body in
|
||
`runCatching` and reads a blank answer back as no answer. A build rule pins it:
|
||
`android.icu` appears only under `data/zones/`.
|
||
|
||
`ZoneDirectory` is the `@Singleton` that joins the two. It runs on
|
||
`@IoDispatcher` (§7), caches the catalog on the **locale tag** under one
|
||
`Mutex`, and builds **lazily** — the tab resolves one entry per stored clock
|
||
through `entryFor`, and only the picker ever calls `entries()`. Those entries
|
||
are memoised here too, on the same locale tag, so a per-app language change
|
||
re-resolves the cities; a cache above this seam would keep the rows in the old
|
||
language while the ICU zone name switched. The long zone name is memoised on
|
||
`(locale, zoneId, DST phase)`, so a per-tick caller costs a map lookup and a tab
|
||
left open across a transition picks up "Summer Time" on the next tick instead of
|
||
going stale until the process restarts. Both memos are asked in **batch**
|
||
(`entriesFor`, `displayNamesOf`), so one tick costs two dispatches rather than
|
||
two per row.
|
||
|
||
### The catalog: canonical, region-based ids, plus UTC
|
||
|
||
`ZoneCatalog.pickableIds` keeps an id iff its first segment is one of the ten
|
||
IANA regions **and** it is its own canonical id. The first rule drops
|
||
`Etc/GMT+5`, `US/Pacific`, `SystemV/AST4` and `EST5EDT` — not city ids, and a
|
||
user picking "GMT+5" is picking a fixed offset that will be wrong in six months.
|
||
The second drops the `backward` aliases, so the picker offers **Asia/Kolkata**
|
||
and not also Asia/Calcutta, and the answer comes from the tzdata's own
|
||
canonicalisation through ICU rather than from a table of ours that would rot —
|
||
`PLAN.md` §0's second commitment, applied. An alias is only dropped when its
|
||
canonical id is itself available: ICU (`com.android.i18n`) and tzdata
|
||
(`com.android.tzdata`) are separately updatable APEX modules, and a device whose
|
||
tzdata still says `Europe/Kiev` while ICU already says `Europe/Kyiv` must not be
|
||
left with no pickable zone for Ukraine at all.
|
||
|
||
**`UTC` is the one special case**, kept by name: it passes neither rule, and a
|
||
clock app that cannot show UTC is missing the one zone every traveller and every
|
||
log file shares. It is kept only when the device actually knows it; nothing is
|
||
invented.
|
||
|
||
Search is **word-prefix over folded text**, not substring: NFD, combining marks
|
||
stripped, lowercased through `Locale.ROOT`, split on anything that is not a
|
||
letter or a digit. So "São Paulo" folds to "sao paulo", "york" and "new yor" and
|
||
"yor new" all find New York, "germany" finds Berlin through the country, and
|
||
**"erl" does not find Berlin** — a three-letter query must not turn into forty
|
||
unrelated hits. Filtering preserves the catalog's own order rather than ranking,
|
||
because the catalog is already sorted by city and an alphabet everybody can
|
||
predict beats a relevance score nobody can.
|
||
|
||
### The offset and the day are read at the instant
|
||
|
||
`ZoneComparisons.compare(home, other, at)` reads **both** zones' offsets at `at`
|
||
and subtracts. Nothing is stored, nothing is cached, and no arithmetic is done
|
||
on a "standard offset":
|
||
|
||
```
|
||
offsetMinutes = other.offset(at) − home.offset(at), in minutes
|
||
dayDifference = DAYS.between(home local date, other local date)
|
||
```
|
||
|
||
That is the discipline `AlarmOccurrences` uses for alarms (§11): let `java.time`
|
||
answer the transition. Berlin↔Sydney is **ten** hours apart in January and
|
||
**eight** in July, and a subtraction of standard offsets would get both wrong.
|
||
The day comes from the two local *dates*, not from the offset — Berlin and
|
||
Honolulu are eleven hours apart and still on the same calendar day.
|
||
|
||
The labels are **data, not strings**, following `NextFireFormat`'s precedent:
|
||
`OffsetLabel.Same | Ahead(h, m) | Behind(h, m)` and
|
||
`DayLabel.YESTERDAY | TODAY | TOMORROW`. The wording lives in `strings.xml`, and
|
||
`h`/`m` are non-negative magnitudes with the sign carried by the variant, so
|
||
Kathmandu is `Ahead(4, 45)` and not a signed 285 the translator has to
|
||
interpret. `dayLabelFor` clamps to ±1 to stay total; the real spread between the
|
||
extreme zones is 26 hours, so ±1 is the only outcome tzdata can reach.
|
||
|
||
### Home
|
||
|
||
`HomeZone.resolve(stored, deviceZone)` is the whole rule: the stored zone when
|
||
the device still knows it, else the device's own. It is one pure function, so
|
||
the fallback is tested rather than implied, and `ClockPrefs.homeZoneId` already
|
||
normalises through `Zones` in both directions (§6), so a zone the tzdata later
|
||
dropped reads back as absent before `resolve` ever sees it.
|
||
|
||
Consequences, all deliberate. **Nothing is seeded** — a fresh install shows the
|
||
device's zone on the face and an empty list, and the face is not a stored row.
|
||
**Home is set from this tab**, through the top-bar action, because a preference
|
||
the user cannot reach is not handling; M10 may surface it a second time. **The
|
||
home zone may also be added as a row**, which is not prevented: `add` is
|
||
idempotent on `zone_id`, and that row simply reads "Same time as home".
|
||
|
||
### The face, and why its morph is information
|
||
|
||
`PLAN.md` §8 sanctions a `MaterialShapes` morph on the analog face and warns it
|
||
must not be "sprinkled everywhere". M7 refused one on the stopwatch with the
|
||
sentence this has to answer: *a stopwatch has no progress, and a decorative arc
|
||
would be a lie drawn to two decimal places.*
|
||
|
||
So the morph here **carries information**. The dial is
|
||
`Morph(MaterialShapes.Circle, MaterialShapes.Sunny)` and its progress is
|
||
`DayNight.fractionAt(localTime)` — a plain circle in the middle of the night
|
||
there, a sun at midday. "Is it the middle of the night where they are" is
|
||
precisely the question a world clock exists to answer, and answering it with
|
||
shape rather than a third line of text is what §8's "refinement comes from
|
||
shape, colour, space and motion" means.
|
||
|
||
`DayNight` is a stated **convention, not an ephemeris**: 0 below 05:00, a linear
|
||
ramp to 1 at 07:00, 1 until 17:00, a linear ramp back to 0 at 19:00. It needs no
|
||
latitude, it has obvious tests, and the app never claims it is sunrise.
|
||
|
||
The rest is ordinary `Canvas` work over `AnalogFace`'s pure angles — twelve hour
|
||
marks with the four quarters longer, three hands, a centre cap — in scheme
|
||
tokens only. The morph is built once and the path re-derived into a reused
|
||
`Path` per frame through `toPath(Morph, Float, Path)`, which is not
|
||
`@Composable` and is therefore callable from a `DrawScope`. The face is the home
|
||
readout and the digital time is **not** repeated under it: an analog face that
|
||
also prints its own time is a face nobody looks at. It carries a content
|
||
description naming the city and the time, and is deliberately **not** a live
|
||
region — at 1 Hz it would recite the clock for as long as the tab was open,
|
||
which is the mistake M4 already refused for the live pill (§12).
|
||
|
||
### The cadence
|
||
|
||
`WorldClockDefaults.Tick = 200 ms`. `RealTicker` is a plain `delay` loop with no
|
||
alignment to the wall second, so a one-second cadence would leave the second
|
||
hand up to a full second behind the device clock — visible, and not something a
|
||
clock app ships. The cost is bounded by making the state **stable**:
|
||
`HomeFaceState.time` is truncated to the second, `WorldClockRowState.time` to
|
||
the minute, and the flow is `distinctUntilChanged`. The upstream emits five
|
||
times a second, a new state is produced about once a second, and the rows' own
|
||
values change once a minute. The period is a `Ticker` parameter, never a `delay`
|
||
at a call site (§12).
|
||
|
||
### Twenty-four cities, and a reorder a screen reader can reach
|
||
|
||
The kit's `ReorderableColumn` is not lazy and pins rows to a 64dp pitch, so the
|
||
list needs a bound: **`WorldClocks.MAX = 24`**, one per hour of the day, a list
|
||
a person can take in at a glance. Past it the FAB carries "Add up to 24 cities"
|
||
as its description, the same sentence is printed below the list where a sighted
|
||
user can read it — M3's `ExtendedFloatingActionButton` has no `enabled`
|
||
parameter to dim it with, and a refusal only a screen reader can hear is a dead
|
||
button to everybody else — and the ViewModel refuses the add: the same shape as
|
||
`StopwatchLaps.MAX = 999` (§15): a constant with a reason, asserted as a
|
||
constant so a change is deliberate.
|
||
|
||
A drag-only reorder is inaccessible. TalkBack cannot press-and-drag and neither
|
||
can a keyboard, and M4 settled the principle when the hold challenge grew an
|
||
accessibility `onClick`: *a challenge TalkBack cannot answer is exactly the
|
||
stranding the requirement forbids.* So every row carries three custom
|
||
accessibility actions — **Move up**, **Move down**, **Remove** — alongside the
|
||
handle and the long-press. The row also merges its descendants under one
|
||
**content description** — the city, the time and the summary as one sentence —
|
||
rather than letting TalkBack read three child texts in child order; that is
|
||
also the only place the "no longer on your device" sentence is spoken. The arithmetic behind the first two is pure
|
||
(`WorldClocks.moveUp`/`moveDown`), a no-op at the end it is already at and for
|
||
an id the list does not hold, and always a permutation of its input; a move that
|
||
changes nothing writes nothing.
|
||
|
||
Remove is a long-press plus a confirmation that names the city, with
|
||
deliberately **no undo chip** — the same trade M5 and M6 made: the cheap moment
|
||
to ask is before. The pending id is `rememberSaveable`, so a rotation
|
||
mid-confirmation does not lose the question.
|
||
|
||
A row whose zone the device's tzdata no longer knows shows **no time, no offset
|
||
and no day** — `WorldClockRowState` carries all three as `null`, non-null
|
||
exactly when `known` is true — and says "This time zone is no longer on your
|
||
device" instead. The row is kept, never rewritten: §4 already decided that, and
|
||
inventing a time for a zone nobody can resolve would be worse still.
|