ARCHITECTURE gains §12 for the shell and loses the two rows M4 paid off; the permissions row now says what M4 actually asks for and leaves the rest to M10's self-check. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wmy1BpCKi8KeSjaWhYuCPV
892 lines
50 KiB
Markdown
892 lines
50 KiB
Markdown
# Clockula — architecture
|
||
|
||
How Clockula is built **today**, after M3. Where something does not exist yet,
|
||
this document says so and names the milestone that builds it, rather than
|
||
describing a plan as though it were code. `PLAN.md` is the "why"; this is the
|
||
"what, right now".
|
||
|
||
---
|
||
|
||
## 1. The thesis
|
||
|
||
**Clockula owns its storage, and pays for that privilege with a hard seam.**
|
||
|
||
Its siblings read a platform provider — Calendula the calendar, Agendula
|
||
tasks — so their data is open by construction. There is no open provider behind
|
||
a clock (`PLAN.md` §0), so Clockula keeps its own SQLite database. The honest
|
||
asterisk is that "open data" here has to be *earned* rather than inherited: by a
|
||
committed, reviewable schema; by a JSON export the user can actually take with
|
||
them (M10); and by a boundary strict enough that the storage engine stays an
|
||
implementation detail rather than becoming the app's shape.
|
||
|
||
The discipline that keeps that honest is one rule: **only `data/` knows Room
|
||
exists**. Everything above it talks to four repository interfaces and to
|
||
plain-Kotlin models. `ArchitectureRulesTest` fails the build if anyone reaches
|
||
through.
|
||
|
||
---
|
||
|
||
## 2. Layers
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────┐
|
||
│ UI — Compose (M4+) │
|
||
│ today: MainActivity + ui/theme only │
|
||
└───────────────────────────┬─────────────────────────────┘
|
||
│ plain-Kotlin models, Flows
|
||
┌───────────────────────────┴─────────────────────────────┐
|
||
│ domain/ — Alarm, Timer, WorldClock, Stopwatch, │
|
||
│ ClockDefaults, WallClock, ElapsedRealtimeClock │
|
||
│ no android.*, no androidx.*, no Room │
|
||
└───────────────────────────┬─────────────────────────────┘
|
||
│
|
||
┌───────────────────────────┴─────────────────────────────┐
|
||
│ data/…/…Repository — four interfaces │
|
||
│ AlarmRepository · TimerRepository · │
|
||
│ WorldClockRepository · StopwatchRepository │
|
||
└──────────────┬──────────────────────────┬───────────────┘
|
||
│ entities │ typed prefs
|
||
┌──────────────┴─────────────┐ ┌─────────┴───────────────┐
|
||
│ DAOs + mappers (Room) │ │ PrefStore (DataStore) │
|
||
│ ClockulaDatabase v1 │ │ clockula_prefs │
|
||
└──────────────┬─────────────┘ └─────────┬───────────────┘
|
||
│ │
|
||
SQLite preferences_pb
|
||
```
|
||
|
||
The seam is the repository interface list. Above it there is no `AlarmEntity`,
|
||
no `@Query`, no `androidx.room` import and no `ClockulaDatabase` reference —
|
||
`ArchitectureRulesTest` greps the whole main source set for exactly those names
|
||
and for `import android.`/`import androidx.` inside `domain/`, and fails with
|
||
the offending paths listed. A grep is a blunter tool than a compiler, but it is
|
||
the one that runs in the gate.
|
||
|
||
There is deliberately **no `internal`** on anything the data layer adds. Room's
|
||
KSP processor generates Java against the DAO types and Kotlin mangles
|
||
`internal` member names; Clockula is a single Gradle module, so `internal` would
|
||
buy no encapsulation the architecture test does not already buy, and would buy a
|
||
class of build failures.
|
||
|
||
---
|
||
|
||
## 3. Modules and packages
|
||
|
||
One app module, `:app`, plus floret-kit as a git submodule wired in as a Gradle
|
||
composite build (`includeBuild("floret-kit")`, consumed as
|
||
`de.jeanlucmakiola.floret:<module>`). M3 added no Gradle module and touched no
|
||
kit module — the kit's roadmap keeps schedulers app-local.
|
||
|
||
| Package | Holds |
|
||
|---|---|
|
||
| `domain/` | `Alarm.kt`, `Timer.kt`, `WorldClock.kt`, `Stopwatch.kt`, `ClockDefaults.kt` — plain Kotlin, no Android |
|
||
| `domain/alarm/` | the alarm engine's pure half: occurrences, the resolver, the ring state, the volume ramp, the ring policies |
|
||
| `domain/time/` | `WallClock`, `ElapsedRealtimeClock`, `ZoneProvider`, `BootId`, `Ticker` |
|
||
| `domain/live/` | `LivePillSelector` and its state — which running thing the live pill is about, as a pure function |
|
||
| `domain/format/` | `ClockFormat` — `M:SS` / `H:MM:SS`, countdowns rounded up, elapsed truncated |
|
||
| `data/db/` | `ClockulaDatabase` — `@Database` v2, `exportSchema = true` — and `Migrations` |
|
||
| `data/alarms/` | the alarm and ring-state entities, DAOs, mappers and repositories |
|
||
| `data/timers/` | `TimerEntity`, `TimerDao`, `TimerMapper`, `TimerRepository(+Impl)` |
|
||
| `data/worldclocks/` | `WorldClockEntity`, `WorldClockDao`, `WorldClockMapper`, `WorldClockRepository(+Impl)` |
|
||
| `data/stopwatch/` | `LapEntity`, `LapDao`, `LapMapper`, `StopwatchStateStore`, `StopwatchRepository(+Impl)` |
|
||
| `data/prefs/` | `SettingsPrefs`, `ClockPrefs`, `StopwatchPrefs`, `UiPrefs` |
|
||
| `data/time/` | `SystemWallClock`, `SystemElapsedRealtimeClock`, `SystemZoneProvider`, `AndroidBootIdProvider`, `RealTicker` |
|
||
| `data/di/` | `DataModule`, `DatabaseModule`, `RepositoryModule`, `TimeModule` |
|
||
| `alarm/` | `AlarmEngine` and the four seams it talks to — `AlarmScheduler`, `AlarmCapabilities`, `RingCoordinator`, `AlarmNotifier` — plus `AlarmIntents` |
|
||
| `alarm/android/` | the seams' Android implementations: AlarmManager, the capability reads, the service handle, the snoozed notification |
|
||
| `alarm/receiver/` | `AlarmFireReceiver`, `AlarmActionReceiver`, `SystemEventReceiver` |
|
||
| `alarm/ring/` | the ringing foreground service, its audio player, its vibrator, its notifications |
|
||
| `alarm/di/` | `AlarmModule` — `@Binds` for the four seams |
|
||
| `system/` | `RebootRepair` — the boot-id gate |
|
||
| `ui/theme/`, `ui/crash/` | the M0/M1 theme and the crash-report surface |
|
||
| `ui/shell/` | the navigation policy (`ShellNavigation`), the adaptive shell, the live pill and its source/ViewModel, the notification-permission ask |
|
||
| `ui/alarms/`, `ui/timers/`, `ui/stopwatch/`, `ui/worldclock/` | one top-level tab each — a title bar and an empty state until M5–M8 fill them |
|
||
| `ui/ring/` | `AlarmRingActivity`, its ViewModel, `RingScreen` and the dismiss-challenge controls |
|
||
|
||
---
|
||
|
||
## 4. The data model
|
||
|
||
`ClockulaDatabase` is at **version 2** with five tables. Every column is a
|
||
primitive: `Long`, `Int`, `String` or `Boolean`. There are **no Room
|
||
`TypeConverter`s** — enums are stored as `Enum.name` in a `TEXT` column,
|
||
instants and durations as milliseconds, the repeat set as an `INTEGER` bitmask.
|
||
That puts the whole entity↔domain translation inside the mappers, which are
|
||
plain JVM objects that a unit test can feed a corrupt row. A converter would
|
||
move the same translation into generated code, where an unknown enum name throws
|
||
*inside a cursor read* — which is a crashed list, not a degraded row.
|
||
|
||
Column names are snake_case via `@ColumnInfo`, so the SQL, the exported schema
|
||
JSON and M10's JSON backup all read the same vocabulary in a diff.
|
||
|
||
### `alarms`
|
||
|
||
| Column | Type | Notes |
|
||
|---|---|---|
|
||
| `id` | INTEGER pk | autogenerated |
|
||
| `hour`, `minute` | INTEGER | read back through `TimeOfDay.clamped` |
|
||
| `label` | TEXT | |
|
||
| `enabled` | INTEGER | indexed — `enabled()` filters on it |
|
||
| `repeat_days` | INTEGER | 7-bit mask, see below |
|
||
| `skip_next_occurrence` | INTEGER | |
|
||
| `ringtone_uri`, `vibrate`, `snooze_minutes`, `snooze_limit`, `volume_ramp_seconds`, `dismiss_challenge` | nullable | **overrides**; `NULL` = inherit `ClockDefaults` |
|
||
| `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.
|
||
|
||
### `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 |
|
||
|
||
### `world_clocks`
|
||
|
||
| Column | Type | Notes |
|
||
|---|---|---|
|
||
| `id` | INTEGER pk | |
|
||
| `zone_id` | TEXT | **unique index**; IANA zone id |
|
||
| `label` | TEXT? | `NULL` = show the ICU-resolved city name (M8) |
|
||
| `sort_order` | INTEGER | indexed |
|
||
|
||
`zone_id` being unique is what makes `WorldClockRepository.add` idempotent: it
|
||
trims the id, rejects a blank or non-IANA one with `IllegalArgumentException`,
|
||
and returns the *existing* row's id when the zone is already there. The lookup,
|
||
the sort-order read and the insert happen in one transaction
|
||
(`WorldClockDao.addIfAbsent`), so no other writer can slip a row in — or delete
|
||
the row that won — between them, and the id handed back is always a row that
|
||
exists rather than the insert's `-1` sentinel. Validity
|
||
means membership of `java.time.ZoneId.getAvailableZoneIds()`, so `UTC` is
|
||
accepted and fixed offsets like `+02:00` are not — IANA zone ids, not a bespoke
|
||
city table (`PLAN.md` §5). A zone the device's tzdata later drops is **kept**,
|
||
not blanked; `WorldClock.isKnownZone` reports it and the UI can show it as
|
||
broken. Losing the user's row silently would be worse.
|
||
|
||
### `stopwatch_laps`
|
||
|
||
| Column | Type | Notes |
|
||
|---|---|---|
|
||
| `id` | INTEGER pk | not exposed to the domain |
|
||
| `lap_index` | INTEGER | **unique index**; 1-based, and the lap's identity |
|
||
| `split_millis`, `cumulative_millis` | INTEGER | |
|
||
|
||
`LapDao.appendLap` is `@Transaction`: it reads the previous lap, derives the
|
||
index and the split, and inserts — so two concurrent laps cannot collide on the
|
||
unique index.
|
||
|
||
### The repeat mask
|
||
|
||
Bit `n` is ISO day `n + 1`: **Monday is bit 0, Sunday bit 6**, so the conversion
|
||
is `1 shl (day.value - 1)` against `java.time.DayOfWeek.value` with no lookup
|
||
table. `RepeatDays`'s constructor is private and every entry point sanitises, so
|
||
a corrupt stored mask (high bits, negative) can only ever *narrow* to the seven
|
||
valid bits.
|
||
|
||
Note for M9: the platform `AlarmClock.EXTRA_DAYS` contract speaks
|
||
`java.util.Calendar` constants (Sunday = 1 … Saturday = 7). **That translation
|
||
happens at the intent boundary in M9, never in storage.**
|
||
|
||
### Reading is forgiving
|
||
|
||
Every mapper read degrades rather than throws: out-of-range hours clamp, unknown
|
||
enum names fall back through `core-prefs`' `toEnum`, blank strings read back as
|
||
`null`, negative millis clamp to zero. An absent `dismiss_challenge` stays
|
||
`null` (it is an unset override); a *present but unknown* one degrades to `NONE`.
|
||
|
||
### The schema is the contract
|
||
|
||
The exported JSON lives at
|
||
`app/schemas/de.jeanlucmakiola.clockula.data.db.ClockulaDatabase/`, one file per
|
||
version, all committed, and the directory is also wired in as an `androidTest`
|
||
asset directory so `MigrationTestHelper` can open a v1 database on device.
|
||
`SchemaExportTest` fails the JVM test run if a version goes missing — the
|
||
previous one is never regenerated away. There is **no
|
||
`fallbackToDestructiveMigration`**: `PLAN.md` §12 requires tested migrations
|
||
from v1, and a destructive fallback would quietly eat a user's alarms.
|
||
|
||
`Migrations.ALL` is the single list the database builder is handed and the list
|
||
the tests assert on. `MIGRATION_1_2` is one `CREATE TABLE alarm_states`, copied
|
||
verbatim from the exported schema's `createSql`, and the instrumentation test
|
||
runs it against a real v1 database and *validates* the result against `2.json` —
|
||
a migration that fails eats the user's alarms, so it is proved rather than
|
||
eyeballed.
|
||
|
||
---
|
||
|
||
## 5. The two clocks
|
||
|
||
`PLAN.md` §5 calls the wall-clock / elapsed-realtime distinction "the single
|
||
easiest thing to get wrong in a clock app". This is the section to read before
|
||
touching anything time-shaped.
|
||
|
||
```kotlin
|
||
interface WallClock { fun now(): Instant }
|
||
interface ElapsedRealtimeClock { fun elapsedRealtime(): Duration }
|
||
```
|
||
|
||
Both are **injected everywhere and never called statically** — that is the only
|
||
reason the distinction can be tested from both sides. Their Android
|
||
implementations are two one-line classes in `data/time/`
|
||
(`System.currentTimeMillis()` and `SystemClock.elapsedRealtime()`). Any new API
|
||
here must name which clock it takes in its parameter list; a bare `now()` is
|
||
forbidden.
|
||
|
||
| Uses the wall clock | Uses elapsed realtime |
|
||
|---|---|
|
||
| alarms (they are calendar facts) | a running timer's countdown |
|
||
| `created_at` / `updated_at` on every row | the stopwatch |
|
||
| a running timer's post-reboot fallback, and nothing else | |
|
||
|
||
### A running timer's three anchors
|
||
|
||
`started_at_elapsed_realtime_millis` is the monotonic instant of the last
|
||
start/resume; `ends_at_elapsed_realtime_millis` is when it expires and is
|
||
**authoritative**; `ends_at_wall_clock_millis` is the same instant on the wall
|
||
clock and is a **fallback only**.
|
||
|
||
`Timer.snapshotAt(elapsedRealtime, wallClock)` decides staleness
|
||
**monotonically, without touching the wall clock at all**:
|
||
|
||
> `elapsedRealtime() < startedAtElapsedRealtime` ⇒ this is a different boot,
|
||
> because `elapsedRealtime()` never decreases within one boot.
|
||
|
||
So a user moving the system clock cannot make the anchor look stale and cannot
|
||
warp a running timer — forwards or backwards. The wall-clock value is read
|
||
*only* when that monotonic test says "different boot", and then only to answer
|
||
"approximately how much is left", with `TimerSnapshot.anchorIsStale = true` so
|
||
the UI can say so. Without it, a reboot would strand every running timer.
|
||
|
||
**Known blind spot, accepted:** the monotonic test only catches a reboot while
|
||
the *new* boot's uptime is still below the old `startedAtElapsedRealtime`. Once
|
||
the device has been up longer than that, the same comparison says "same boot"
|
||
and the row is resolved against a dead pre-reboot anchor, reading as **live and
|
||
not stale** — `endsAtWallClock` is never consulted.
|
||
|
||
This window opens sooner than "after it would have expired". A timer started two
|
||
minutes into a boot re-enters it about two minutes into the *next* boot: a
|
||
30-minute timer started at uptime 2 min, with the device rebooted ten minutes
|
||
later, reads as live and non-stale from uptime 2 min of the new boot onwards, and
|
||
counts down from a number that means nothing. So: **a stale anchor can read as
|
||
live, well before expiry.** The cost is accepted here because the alternative —
|
||
consulting the wall clock to decide staleness — would let a user moving the
|
||
system clock warp a running timer, which `PLAN.md` §5 forbids outright.
|
||
|
||
Closing it properly needs a persisted boot identifier, and **M3 added one**.
|
||
`RebootRepair` runs at the top of every boot — from `SystemEventReceiver`'s
|
||
`BOOT_COMPLETED` and again from `ClockulaApp.onCreate`, so a broadcast the
|
||
system never delivered is still caught — and rewrites every running timer from
|
||
`endsAtWallClock` before the new uptime can climb past any stored anchor. A
|
||
timer already past its wall-clock end becomes `EXPIRED`; one with no wall-clock
|
||
fallback is paused at what it banked.
|
||
|
||
The gate is the boot id, not a flag in memory: `BootId` is
|
||
`Settings.Global.BOOT_COUNT` (API 24+, no permission), and two ids that both
|
||
have a count are the same boot exactly when the counts match. When either side
|
||
has no count — a device that will not give `BOOT_COUNT` up — it falls back to
|
||
comparing `wallClock.now() - elapsedRealtime()`, the instant the device booted,
|
||
within one minute. That fallback is **best-effort and documented as such**: the
|
||
derived instant drifts under NTP correction, which is what the tolerance
|
||
absorbs. It costs nine lines and means an unreadable `BOOT_COUNT` degrades to
|
||
approximately-right rather than to never noticing a reboot. The id is stored as
|
||
one DataStore string, `last_boot_id`, and decoding is strict — anything
|
||
malformed reads as "no known previous boot", i.e. "repair".
|
||
|
||
Every timer write is a read-modify-write inside **one transaction**
|
||
(`TimerDao.updateWithin`): the row is read, the domain rule applied and the
|
||
result written back before another writer can interleave. The ringing service
|
||
marking a timer expired and the user adding a minute to it therefore cannot
|
||
swallow each other's edit. `addTime` on a running timer rebases on the clamped
|
||
remaining and rewrites all three anchors from a single reading of both clocks,
|
||
so "+1 min" always grants a whole minute even when the end anchor has already
|
||
gone by.
|
||
|
||
A `RUNNING` row missing either elapsed anchor is treated as corrupt, not as
|
||
stale-by-reboot: it reads back whatever it last banked, flagged stale. It never
|
||
throws — a throw inside a list read is a crashed screen.
|
||
|
||
### The stopwatch has no fallback, on purpose
|
||
|
||
`StopwatchRun` carries **no wall-clock field whatsoever**, and `StopwatchPrefs`
|
||
stores no wall-clock value (a test asserts that on the stored bytes). A
|
||
stopwatch that spans a reboot has lost information nobody can reconstruct, and
|
||
unlike a timer there is nothing to ring. A stale run therefore resolves to
|
||
**paused at the accumulated time**, discarding the lost segment, with
|
||
`anchorIsStale = true`. Discarding is the honest answer; guessing would be a lie
|
||
displayed to two decimal places.
|
||
|
||
`StopwatchRun.snapshotAt` decides staleness with the same monotonic test as the
|
||
timer, and had the same blind spot: once the new boot's uptime passes the stored
|
||
`startedAtElapsedRealtime`, the reboot goes unnoticed and the run reports
|
||
`accumulated + (elapsedRealtime - startedAtElapsedRealtime)` as **live and not
|
||
stale** — a segment it never actually ran.
|
||
|
||
**M3 closes it.** The same boot gate that repairs timers also calls
|
||
`StopwatchRepository.pauseAfterReboot()`: a `RUNNING` run is written back
|
||
`PAUSED` at exactly its banked `accumulated`, with the start anchor dropped and
|
||
nothing invented in its place. A paused or idle run is not written at all. That
|
||
is three lines on top of machinery the alarm engine needs anyway, and leaving a
|
||
knowingly-fabricated elapsed reading on screen for two more milestones was not
|
||
defensible. **M3 owns the stopwatch's post-reboot repair; M7 owns its
|
||
presentation.**
|
||
|
||
---
|
||
|
||
## 6. Preferences
|
||
|
||
One DataStore file, `clockula_prefs`, behind floret-kit's typed `PrefStore`.
|
||
Three slices:
|
||
|
||
| Slice | Keys | Owner |
|
||
|---|---|---|
|
||
| Appearance (M1) | `theme_mode`, `dynamic_color` | `AppearancePrefs` in `core-prefs` — the family's shared key names |
|
||
| Clock defaults (M2) | `default_snooze_minutes`, `default_snooze_limit`, `default_vibrate`, `default_volume_ramp_seconds`, `default_alarm_ringtone_uri`, `default_timer_ringtone_uri`, `default_dismiss_challenge`, `default_timer_duration_millis`, `home_zone_id` | `ClockPrefs`, surfaced as `SettingsPrefs.defaults: Flow<ClockDefaults>` |
|
||
| Stopwatch run record (M2) | `stopwatch_state`, `stopwatch_started_elapsed_millis`, `stopwatch_accumulated_millis`, `stopwatch_last_lap_cumulative_millis` | `StopwatchPrefs`, behind `StopwatchStateStore` |
|
||
| System facts (M3) | `last_boot_id` | `SystemPrefs`, behind `BootStateStore` — the persisted half of the reboot gate |
|
||
|
||
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`.
|
||
|
||
`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. Five 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` |
|
||
|
||
From floret-kit: `core-di`'s `CoroutinesModule` supplies `@IoDispatcher` and the
|
||
process-lifetime `@ApplicationScope` the receivers launch on, and `core-prefs`
|
||
supplies `PrefStore`, `Pref` and the appearance keys.
|
||
|
||
`BroadcastReceiver.onReceive` is abstract, so a Kotlin subclass cannot call
|
||
`super.onReceive(...)` — which is exactly where Hilt injects. The three
|
||
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`.
|
||
|
||
---
|
||
|
||
## 8. Testing
|
||
|
||
JVM-first. 474 unit tests run in the gate; twenty 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/`.
|
||
|
||
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 eight 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.
|
||
|
||
Stack: JUnit 5 + Truth + Turbine + `kotlinx-coroutines-test`, with
|
||
`useJUnitPlatform()` and `isReturnDefaultValues = true`.
|
||
|
||
---
|
||
|
||
## 9. Build and tooling
|
||
|
||
AGP 9.2.1 · KSP 2.3.9 · Hilt 2.59.2 · Room 2.8.3 · DataStore 1.2.1, all pinned
|
||
in `gradle/libs.versions.toml`. `compileSdk 37`, `targetSdk 36`, **`minSdk 29`**,
|
||
Java/Kotlin target 17.
|
||
|
||
Room's schema export is switched on with
|
||
`ksp { arg("room.schemaLocation", "$projectDir/schemas") }`, and the same
|
||
directory is added as an `androidTest` assets source dir. It is written during
|
||
`compileDebugKotlin`, so `assembleDebug` must run before the test that reads it.
|
||
|
||
Reproducibility rules, checked by `scripts/check_reproducible_release.sh`:
|
||
`vcsInfo { include = false }` on release, `dependenciesInfo` out of the APK and
|
||
bundle, and **no foojay toolchain resolver anywhere** — not in this repo, not in
|
||
floret-kit, not in a comment.
|
||
|
||
floret-kit is a git submodule consumed as a composite build; it needs a
|
||
gitignored `floret-kit/local.properties` pointing at the SDK locally, and
|
||
`ANDROID_HOME` in CI.
|
||
|
||
Time types: the domain speaks `kotlin.time.Instant`, `kotlin.time.Duration`,
|
||
`java.time.DayOfWeek` and `java.time.ZoneId`. `java.time` is native at
|
||
`minSdk 29`, so there is no desugaring. `kotlinx-datetime` is on the classpath
|
||
for M3's DST work; the DST arithmetic itself is `java.time`'s, wrapped in one
|
||
function (§11).
|
||
|
||
### Manifest
|
||
|
||
Clockula's permission set is the alarm engine's, and nothing more. Each one is
|
||
declared because a specific path would otherwise fail — silently, at 07:00.
|
||
|
||
| Permission | Why |
|
||
|---|---|
|
||
| `USE_EXACT_ALARM` | install-granted from API 33 for an app whose core function is alarms; no dialog, nothing to revoke |
|
||
| `SCHEDULE_EXACT_ALARM` `android:maxSdkVersion="32"` | covers 31–32, where it is pre-granted. Bounded at 32 so it never overlaps the one above — that is the documented pattern and it keeps the store listing's story clean. Below 31 an exact alarm needs no permission at all |
|
||
| `RECEIVE_BOOT_COMPLETED` | AlarmManager keeps nothing across a reboot |
|
||
| `USE_FULL_SCREEN_INTENT` | the ring screen over the lock screen |
|
||
| `POST_NOTIFICATIONS` | the ring and snoozed notifications |
|
||
| `FOREGROUND_SERVICE`, `FOREGROUND_SERVICE_SYSTEM_EXEMPTED` | the ringing service |
|
||
| `WAKE_LOCK` | keep the CPU up for the length of a ring |
|
||
| `VIBRATE` | the alarm vibrates, and vibrates alone when no audio source opens |
|
||
|
||
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.
|
||
|
||
The ringing service's `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.
|
||
|
||
The three receivers are `android:exported="false"`: a protected system broadcast
|
||
is delivered to an unexported receiver anyway (this is how `androidx.work`
|
||
declares its own `RescheduleReceiver`), and a `PendingIntent` the app created is
|
||
delivered regardless — so nothing else can fake a fire.
|
||
`ACTION_LOCKED_BOOT_COMPLETED` is deliberately not handled: it needs
|
||
`directBootAware`, and the database and DataStore live in credential-encrypted
|
||
storage that is unreadable before the first unlock.
|
||
|
||
Components: `MainActivity`, the non-exported `CrashReportActivity` and
|
||
`AlarmRingActivity`, the `AlarmRingService`, the three receivers, and AppCompat's
|
||
locale metadata holder service.
|
||
|
||
---
|
||
|
||
## 10. What is not built yet
|
||
|
||
| Not here | Milestone |
|
||
|---|---|
|
||
| Alarm list, edit surface, time picker, ringtone picker, repeat-day selector, per-alarm override UI | M5 |
|
||
| The exact-alarm and full-screen-intent grant deep links, and any explanation of a denial. M4 asks for `POST_NOTIFICATIONS` once on first launch; the rest waits for the self-check screen, because a bare jump into system settings with no reason given is hostile | M10 |
|
||
| Every tab's real content — the alarm list and editor (M5), creating and running a timer (M6), starting and lapping the stopwatch (M7), the zone list and analog face (M8). M4 ships the four title bars and their empty states | M5–M8 |
|
||
| Timers ringing — M6 reuses this milestone's audio path; M3 wires no timer to it | M6 |
|
||
| Stopwatch presentation, best/worst lap analysis (its post-reboot repair shipped in M3) | M7 |
|
||
| ICU city and zone display names, offsets, day differences | M8 |
|
||
| The `android.provider.AlarmClock` intent surface and its hostile-extra validation (`TimeOfDay.clamped` and `Zones.normalise` exist so M9 has something to call) | M9 |
|
||
| The self-check screen ("why might my alarm not ring?"), settings screen, JSON backup / SAF export | M10 |
|
||
| A user-configurable auto-silence duration, an unlimited-snooze option, per-alarm auto-silence | M10 at the earliest |
|
||
| Screenshots and store listing polish | M11 |
|
||
|
||
Also absent by design: any seeded default data (no starter alarm, no home world
|
||
clock on first run), and a "silent" ringtone sentinel — `ringtoneUri == null`
|
||
means *inherit*, and silent arrives with M5's picker, where the user can
|
||
actually choose it.
|
||
|
||
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 M5's edit surface. `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.
|
||
|
||
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:
|
||
|
||
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.
|
||
|
||
---
|
||
|
||
## 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.
|
||
|
||
The mode and value come from `Timer.snapshotAt` / `StopwatchRun.snapshotAt`, not
|
||
from the stored row — so §5's whole elapsed-realtime-versus-wall-clock story,
|
||
stale anchor and all, is honoured once. The consequence that matters: a running
|
||
timer that reaches zero flips the pill to `EXPIRED` immediately, without waiting
|
||
for M6's expiry service to write `markExpired`. The pill never counts into
|
||
negative time, and never reads `0:00` while claiming to run.
|
||
|
||
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.
|