"Choose from a music app…" in the sound picker browses any app that shares its library through Android's MediaBrowserService and stores the choice as a clockula://media URI in the existing ringtone field — no schema change, no new permission, no internet. The music app streams with its own. At ring time the app is asked to play through its media session. Every wait is bounded and any failure falls back to the ordinary alarm sound: the app won't connect or start, stalls or buffers too long, or the media stream might not be heard (headphones or a classic Bluetooth device connected, Do Not Disturb muting media). The ramp drives the media stream and the user's media volume is restored when the last media ring ends. RingAudioPlayer.start no longer suspends: vibration starts up front, the timer collector is not held up, and a generation token keeps late work from reviving a stopped ring.
2654 lines
165 KiB
Markdown
2654 lines
165 KiB
Markdown
# Clockula — architecture
|
||
|
||
How Clockula is built **today**, after M9. 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 v3 │ │ 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/interop/` | the `android.provider.AlarmClock` boundary's pure half (M9): `AlarmClockContract` (the platform's vocabulary as literals — the **only** file in `src/main` that may hold them), `IntentExtras` (a Bundle modelled honestly), `InteropLimits`, `InteropText` (the label and ringtone sanitisers), `InteropDeepLinks`, `AlarmClockRequest`/`AlarmSpec`/`AlarmSearch`/`InteropOutcome`, `AlarmClockRequests` (the validator) and `AlarmMatching` (which alarms a search names) |
|
||
| `domain/text/` | `TextFolding` (M9) — NFD-fold, strip combining marks, lowercase through `Locale.ROOT`, `containsFolded`. One copy, shared by the zone search and the label search |
|
||
| `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 |
|
||
| `domain/backup/` | `BackupContent` (M10) — the plain Kotlin model between `data/backup`'s JSON codec and the repository/engine; decoupled from the wire DTOs the same way the data layer is decoupled from Room |
|
||
| `domain/selfcheck/` | `SelfCheck` (M10) — the "why might my alarm not ring?" rule table: pure functions over a `DeviceState` snapshot, each producing a `CheckResult` with a status and an optional deep-link `SelfCheckFix` |
|
||
| `domain/widget/` | the three widget presenters (M10) — `NextAlarmWidgetModel`/`TimerWidgetModel`/`ClockWidgetModel`: selection and formatting only, so a widget's content is unit-tested without Glance |
|
||
| `data/db/` | `ClockulaDatabase` — `@Database` v3, `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, and `MediaLibrary`/`SystemMediaLibrary`, the picker's view of other apps' `MediaBrowserService` libraries |
|
||
| `data/time/` | `SystemWallClock`, `SystemElapsedRealtimeClock`, `SystemZoneProvider`, `AndroidBootIdProvider`, `RealTicker` |
|
||
| `data/di/` | `DataModule`, `DatabaseModule`, `RepositoryModule`, `TimeModule`, `RingtoneModule`, `ZoneModule` (M8 — one `@Binds`, `ZoneNames → IcuZoneNames`), `BackupModule` (M10) |
|
||
| `data/backup/` | the backup's data-layer half (M10): `BackupDto`/`BackupCodec` (the pure JSON wire format, `ignoreUnknownKeys`, strict on known fields), `BackupDao` (one `@Transaction` replace-all over alarms/timers/world clocks — no schema or version change), `BackupRepository(+Impl)` — mapping through the existing `AlarmMapper`/`TimerMapper`/`WorldClockMapper`, never a second copy |
|
||
| `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 |
|
||
| `interop/` | the `AlarmClock` boundary's Android half (M9): `AlarmClockActivity` (the one exported, permission-guarded, window-less door), `IntentExtrasReader` (`Bundle` → `IntentExtras`), `AlarmClockHandler` (the orchestrator — **no `android.*` import, ever**), `InteropIntents` (the internal transport to `MainActivity`) and `InteropLaunch` |
|
||
| `system/` | `RebootRepair` — the boot-id gate |
|
||
| `backup/` | `BackupEngine` (M10) — the orchestrator: a ringing alarm dismissed and running timers deleted before a replace, `AlarmEngine.reschedule()`/`TimerEngine.resync()` after it — plus `BackupFiles`, the SAF seam, with its Android implementation in `backup/android/` |
|
||
| `widget/` | the three `GlanceAppWidget`/`GlanceAppWidgetReceiver` pairs (M10) and `WidgetSync` — the event-driven collector, started from `ClockulaApp.onCreate`, that redraws all three on any change to the state they mirror. `widget/di/` holds the `@EntryPoint` Glance needs, since a `GlanceAppWidgetReceiver` is never `@AndroidEntryPoint`-constructed |
|
||
| `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), and M9's `DismissChooser`/`DismissAlarmDialog` — the "which alarm should I dismiss?" question |
|
||
| `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 |
|
||
| `ui/settings/` | the settings hub (M10): `SettingsScreen`/`SettingsViewModel` (appearance, alarm/timer/clock defaults, reliability, data, help), `SelfCheckScreen`/`SelfCheckViewModel`, `BackupScreen`/`BackupViewModel` — all reachable from a gear icon on every tab, with no owning tab of their own |
|
||
| `ui/widget/` | the three widgets' `@Composable` Glance content (M10) — the only Compose this milestone adds outside a tab; the `GlanceAppWidget`/`GlanceAppWidgetReceiver` classes themselves live in `widget/`, since they are manifest components, not UI |
|
||
|
||
---
|
||
|
||
## 4. The data model
|
||
|
||
`ClockulaDatabase` is at **version 3** 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 |
|
||
| `delete_after_use` | INTEGER | v3 (M9): a transient alarm the `AlarmClock` contract asked for. Deleted rather than disabled when its cycle closes (§17) |
|
||
| `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 |
|
||
| `delete_after_use` | INTEGER | v3 (M9): a transient timer the contract asked for. Deleted rather than returned to IDLE when it is dismissed (§17) |
|
||
| `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.
|
||
|
||
The platform `AlarmClock.EXTRA_DAYS` contract speaks `java.util.Calendar`
|
||
constants (Sunday = 1 … Saturday = 7). **That translation happens at the intent
|
||
boundary and never in storage** — M9 put it in one private function,
|
||
`AlarmClockRequests.dayOfWeek`, which hands its `Set<DayOfWeek>` to
|
||
`RepeatDays.of` so the 7-bit mask is never built by hand.
|
||
|
||
### 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.
|
||
|
||
**v2 → v3 (M9).** `MIGRATION_2_3` is two `ALTER TABLE … ADD COLUMN
|
||
delete_after_use INTEGER NOT NULL DEFAULT 0`, one on `alarms` and one on
|
||
`timers`. The column exists because the `AlarmClock` contract says twice that a
|
||
`SKIP_UI` alarm or timer "should be removed after it has been dismissed";
|
||
without it, a user who talks to their assistant every morning accumulates a list
|
||
of dead 06:30 rows (§17). Both entity columns declare
|
||
`defaultValue = "0"` — load-bearing, because without it the exported schema
|
||
records no default, `runMigrationsAndValidate` compares it against a migrated
|
||
table that *has* one, and the migration test fails for a reason nobody enjoys
|
||
finding. Every row written before M9 therefore reads as the permanent alarm or
|
||
timer it was.
|
||
|
||
---
|
||
|
||
## 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` and `RingtonePreviewer`, and for `MediaLibrary` |
|
||
| `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 and v2 → v3 migrations validated against the exported schema, and the
|
||
database opening at its current version.
|
||
- **`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.
|
||
|
||
**M9 kept it for a boundary**, which is the last place a project usually gives
|
||
in, because "it is an `Intent`" sounds like it needs a device. It does not. The
|
||
door is split so that everything which *decides* is pure Kotlin under
|
||
`domain/interop/` — `AlarmClockRequests` (every range, type and sentinel rule),
|
||
`AlarmMatching` (which alarms a search names), `IntentExtras`, `InteropText`,
|
||
`InteropDeepLinks` — and the Android half is two files that carry no policy:
|
||
one turns a `Bundle` into a `Map<String, Any?>`, one turns an outcome into an
|
||
`Intent`. `AlarmClockHandler` is the orchestrator and, like the three engines,
|
||
contains no `android.*` import, so the whole contract is asserted from a plain
|
||
JUnit test with hand-built inputs — including an intent hostile in every extra
|
||
at once.
|
||
|
||
One new harness, and it is deliberately **not** a composition of the existing
|
||
ones. `AlarmEngineHarness`, `TimerEngineHarness`, `StopwatchEngineHarness` and
|
||
`WorldClockHarness` each build a DataStore over the *same* file name under the
|
||
test's `@TempDir`, and DataStore refuses a second instance over a live file — so
|
||
composing two of them throws. `InteropHarness` builds **one** `PrefStore`, one
|
||
`SettingsPrefs`, the fake DAOs, the fake seams, the **real** `AlarmEngine`, the
|
||
**real** `TimerEngine` and the real repositories over them, and exposes the
|
||
handler built from those. `FakeAlarmDao` also grew an `onDeleted` hook the
|
||
harnesses wire to `alarm_states`, because a fake cannot infer `ON DELETE
|
||
CASCADE` from a schema it does not have — and M9 is the first milestone that
|
||
deletes an alarm from inside the engine.
|
||
|
||
Four more `ArchitectureRulesTest` rules: `interop/AlarmClockHandler.kt` joins
|
||
the Android-free list; the platform's `AlarmClock` literals appear only in
|
||
`domain/interop/AlarmClockContract.kt`, so a second file cannot hardcode
|
||
`"android.intent.action.SET_ALARM"` and drift; `alarm/android/AndroidAlarmScheduler.kt`
|
||
never names `AlarmRingActivity` in code, which pins the show-intent fix (§11);
|
||
and no file under `ui/` uses `AlarmClockHandler`, `AlarmClockRequests` or
|
||
`IntentExtras` — the screen parses no intent from another app.
|
||
|
||
And one new **`ManifestRulesTest`**, which reads `app/src/main/AndroidManifest.xml`
|
||
as a file the way `ArchitectureRulesTest` reads sources: exactly two exported
|
||
components, the permission on the door and **not** on `MainActivity`, all seven
|
||
actions present, both intent-filters present (one without a `<data>` element and
|
||
one with the `clockula` scheme), the door's recents/history/affinity attributes,
|
||
every receiver still unexported, and the requested-permission set unchanged. The
|
||
manifest is where this milestone could be silently wrong — none of it fails a
|
||
compiler — so it fails here.
|
||
|
||
Five more instrumentation tests: our literals really being the platform's own
|
||
`android.provider.AlarmClock` constants (the one comparison only a device can
|
||
make), each of the seven actions really resolving to the door, a deeplinked
|
||
`DISMISS_ALARM` really matching the second filter, the chooser dialog really
|
||
composing and a tap really dismissing, and the door really reaching `DESTROYED`
|
||
without showing a window — plus two migration cases for v2 → v3. **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`. The JVM suite is
|
||
**1 436 tests**; 47 instrumentation tests compile in the gate.
|
||
|
||
---
|
||
|
||
## 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.
|
||
|
||
**M9 adds no `<uses-permission>` either.** Its one new permission string is
|
||
`android:permission` **on the new activity** — a requirement the *caller* must
|
||
hold, never one Clockula requests. `com.android.alarm.permission.SET_ALARM` is
|
||
what `AlarmClock`'s own documentation asks an implementation to demand; it is
|
||
protection-level `normal`, so any app that declares it is granted it at install,
|
||
which costs a legitimate caller nothing and keeps a drive-by `startActivity`
|
||
from an app that never declared an interest in alarms out.
|
||
|
||
`interop/AlarmClockActivity` carries the contract's filters rather than
|
||
`MainActivity`, because `android:permission` on an activity is checked against
|
||
**every** caller, the Launcher included: putting the filters on `MainActivity`
|
||
would mean either dropping the permission or making the app unlaunchable.
|
||
`MainActivity`'s filter set is still exactly `MAIN`/`LAUNCHER`.
|
||
|
||
The door declares **two** `<intent-filter>` blocks, and this is the detail that
|
||
is silently wrong otherwise: a filter with no `<data>` element matches only
|
||
intents whose data is null, and a filter that declares a scheme matches only
|
||
intents that *have* data. `DISMISS_ALARM` and `DISMISS_TIMER` may arrive either
|
||
way, so filter A carries all seven actions with no data element, and filter B
|
||
carries those two plus `<data android:scheme="clockula"/>`.
|
||
`android.intent.category.VOICE` is deliberately **not** declared: it advertises
|
||
the `VoiceInteractor` follow-on flows, which v1 does not implement (§10). The
|
||
activity is `excludeFromRecents`, `noHistory`, `taskAffinity=""` and themed
|
||
`Theme.Clockula.Invisible` (translucent, no title) — it has no content view, so
|
||
nothing flashes on the way through.
|
||
|
||
Components: `MainActivity`, the exported and permission-guarded
|
||
`interop.AlarmClockActivity`, the non-exported `CrashReportActivity` and
|
||
`AlarmRingActivity`, three foreground services (`AlarmRingService`, M6's
|
||
`TimerService`, M7's `StopwatchService`), five non-widget receivers —
|
||
`AlarmFireReceiver`, `AlarmActionReceiver`, `SystemEventReceiver`,
|
||
`TimerExpiryReceiver`, `TimerActionReceiver` — AppCompat's locale metadata
|
||
holder service, and, since M10, three `GlanceAppWidgetReceiver`s
|
||
(`NextAlarmWidgetReceiver`, `TimerWidgetReceiver`, `ClockWidgetReceiver`).
|
||
`ManifestRulesTest` pins the exported set exactly: the two above, plus the
|
||
three widget receivers — each with **only** the platform-required
|
||
`APPWIDGET_UPDATE` filter and no permission, since `APPWIDGET_UPDATE` is a
|
||
platform-delivered broadcast no other app can trigger by naming the action.
|
||
M10 adds no `<uses-permission>`.
|
||
|
||
---
|
||
|
||
## 10. What is not built yet
|
||
|
||
| Not here | Milestone |
|
||
|---|---|
|
||
| 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` | not planned for v1 |
|
||
| Voice-interaction follow-on flows: `Activity.isVoiceInteraction`, `VoiceInteractor.CompleteVoiceRequest` (publishing a deeplink to the alarm just created) and `PickOptionRequest` (disambiguating by voice). `android.intent.category.VOICE` is therefore not declared — advertising it while answering with a touch dialog would be a promise the app does not keep. Clockula *accepts* a `clockula://` deeplink and publishes none | not planned for v1 |
|
||
| A "most closely matched" time search for `DISMISS_ALARM`. `ALARM_SEARCH_MODE_TIME` matches **exactly**: dismissing an alarm the caller did not name is a missed alarm, and there is no distance at which that becomes a good trade | not planned for v1 |
|
||
| A toast or any other confirmation on a `SKIP_UI` write. `SKIP_UI` means "bypass any intermediate UI", a voice assistant speaks its own confirmation, and the status-bar alarm icon `setAlarmClock` lights is the platform's own receipt (§17) | not planned for v1 |
|
||
| Surfacing `delete_after_use` in either editor. It is a property the contract sets, not a setting; an alarm the user edits keeps it, and that consequence is documented (§17) rather than papered over with a checkbox | not planned for v1 |
|
||
| Carrying `EXTRA_MESSAGE` into the timer setup panel when no length was given. There is no row to carry it on and the panel has no label field | not planned for v1 |
|
||
| Verifying a ringtone URI at the intent boundary. M5 locked "an unreadable sound is reported, never rewritten"; an unmounted card comes back, and the boundary opens no `ContentResolver` | not planned for v1 |
|
||
| A user-configurable auto-silence duration, an unlimited-snooze option, per-alarm auto-silence | not planned for v1 |
|
||
| 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 | not planned for v1 |
|
||
| 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 (add/remove the quick-start durations themselves). M10's settings screen edits `default_timer_duration_millis` and `default_timer_ringtone_uri`, but the preset list is still only editable from the two-gesture surface on the setup panel | not planned for v1 |
|
||
| 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 — and M10's backup now carries it on export/import, but there is still no way to *write* a label in-app | not planned for v1 |
|
||
| 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. Since M9 there is one branch on top of that: a **transient** alarm
|
||
(`delete_after_use`, set only on the contract's `SKIP_UI` create path) is
|
||
**deleted** rather than disabled, wherever a permanent one would be disabled —
|
||
`persist`'s `disableAlarm` and `closeCycleLocked` both route through one private
|
||
`disableOrDeleteLocked`. The `alarm_states` foreign key cascades, so the ring
|
||
state goes with the row, and the existing `!isRepeating` guard means an alarm the
|
||
user later makes repeating is safe by construction (§17).
|
||
|
||
The engine's verbs, for a reader looking for the one to call: `reschedule`,
|
||
`onFire`, `onAutoSilence`, `snooze(id, minutesOverride = null)`, `dismiss`,
|
||
`dismissUpcoming`, `onBootCompleted`, `resumeRingingIfAny`, `ringSession` and
|
||
the read-only `upcoming`. M9 added the last of the dismissals and the snooze's
|
||
parameter: `dismissUpcoming(id)` is the `AlarmClock` contract's dismissal *and*
|
||
the chooser dialog's, so the user's dismissal and an assistant's are the same
|
||
code. It closes a ring, or arms a repeating alarm's skip, or disables (or
|
||
deletes) a one-shot, and it takes the lock **once** — a caller doing `find` →
|
||
`setEnabled` → `reschedule` would race a fire broadcast between the read and the
|
||
write, which is the exact bug the lock exists for. `minutesOverride` is a
|
||
one-off, clamped to 1..60 and written **nowhere**: the contract says setting
|
||
`EXTRA_ALARM_SNOOZE_DURATION` does not change the default snooze duration, and a
|
||
test asserts both the stored row and the preferences file are untouched. It buys
|
||
no extra snooze either — a refused snooze still dismisses.
|
||
|
||
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.
|
||
|
||
**The `showIntent` was wrong until M9.** It is what the system opens when the
|
||
user taps the status-bar alarm icon or the lockscreen's next-alarm line, and it
|
||
pointed at the ring screen with `FLAG_ACTIVITY_CLEAR_TASK` — so tapping "my next
|
||
alarm" opened a ring screen for an alarm that was **not ringing**, which resolved
|
||
to `Finished` and closed itself. It now opens `MainActivity` with
|
||
`AlarmIntents.ACTION_SHOW_ALARMS` and `NEW_TASK` only: `MainActivity` is
|
||
`singleTop`, so an existing task is brought forward and `onNewIntent` selects the
|
||
Alarms tab, which is what "show me my next alarm" means. A build rule fails the
|
||
build if `AndroidAlarmScheduler` ever names `AlarmRingActivity` again (§8).
|
||
|
||
### 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".
|
||
|
||
That decision is what settles a wording in `PLAN.md` §6 that would otherwise be
|
||
read too literally. §6 says a malformed `SET_ALARM` intent "opens the editor
|
||
pre-filled **rather than writing anything**". An editor that opens on nothing
|
||
does not exist in this app, and inventing one for M9 would overturn the rule
|
||
above. The sentence's force is **"`SKIP_UI` never means skip validation" — the
|
||
user must see the alarm rather than have one written behind their back** — and
|
||
that is honoured exactly: an incomplete or malformed `SET_ALARM` creates the row
|
||
from the extras that *were* valid, at `AlarmDefaults.nextWholeHour`, enabled,
|
||
never transient, and lands the user in its editor with a time picker, a day
|
||
selector and a **Delete** under their thumb. Nothing is ever written and hidden,
|
||
which is what §6 forbids (§17).
|
||
|
||
### "Which alarm?" — the chooser dialog
|
||
|
||
The `AlarmClock` contract says a `DISMISS_ALARM` search returning two or more
|
||
matches should show the results and let the user pick. M3's canonical component
|
||
for "pick one of a short list, or cancel" is a **basic dialog containing a
|
||
list**: `AlertDialog` at its defaults — `surfaceContainerHigh`, the 28dp corner,
|
||
`headlineSmall` title, tonal elevation 3 — not a full-screen dialog (that is for
|
||
a whole task) and not a bottom sheet (this is a question, not a surface).
|
||
|
||
Rows are canonical M3 `ListItem`s in a `Column` with `verticalScroll`, each
|
||
carrying the same three facts the Alarms row shows — the formatted time through
|
||
`rememberAlarmTimeFormatter` (the app's one formatter), the label as supporting
|
||
content and the repeat summary as the overline — so the dialog and the list
|
||
behind it agree. Each row sets
|
||
`ListItemDefaults.colors(containerColor = Color.Transparent)`, because a
|
||
`ListItem`'s default container is `surface`, which sits *lower* than the
|
||
dialog's own `surfaceContainerHigh` and would draw a visible lighter block per
|
||
row. There is **one** action button, Cancel, in `confirmButton`, where M3 puts a
|
||
lone action: tapping a row *is* the answer, the same rule M4 applied to the
|
||
dismiss challenge. Each row merges its descendants under one content description
|
||
naming the time, the label and what tapping does, and the 48dp target comes free
|
||
with `ListItem`.
|
||
|
||
`DismissChooser.rowsFor(rows, ids)` is pure and filters `AlarmsViewModel`'s
|
||
existing `AlarmRowState` list, in **its** order — so there is no second source
|
||
and no second ticker — and the answer goes to
|
||
`AlarmsViewModel.onDismissUpcoming(id)`, which is `AlarmEngine.dismissUpcoming`
|
||
(§11).
|
||
|
||
### 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.
|
||
|
||
### A sound another app plays
|
||
|
||
"Choose from a music app…" is a second action row. It opens
|
||
`MediaAppPickerScreen` over the picker, with its own `MediaAppPickerViewModel`
|
||
over the `MediaLibrary` seam, so none of the three hosts (alarm editor, timer
|
||
editor, settings) gains a line beyond the URI it receives through `onChoose`.
|
||
The picker lists the apps that export Android's standard `MediaBrowserService`
|
||
(the manifest's one `<queries>` entry makes them visible; no permission is
|
||
involved), walks the chosen app's library, and offers "Resume where you left
|
||
off" at its root.
|
||
|
||
**Storage is a URI, not a schema change.** `MediaSources` encodes the choice as
|
||
`clockula://media?pkg=…&svc=…&app=…&id=…&title=…`, under the same private
|
||
scheme as the silent sentinel. It therefore lives in `ringtone_uri`, the
|
||
default-sound preferences and the backup unchanged. The interop door accepts
|
||
only `content://` and `android.resource://` (`InteropLimits.RINGTONE_SCHEMES`),
|
||
so no other app can make Clockula bind to a service of its choosing. The
|
||
encoded title is what the editor shows, so rendering never connects to the app.
|
||
|
||
**Ring time.** `AudioSourcePolicy` puts `MEDIA_APP` in front of the usual
|
||
defaults. `RingAudioPlayer.start` does not wait for the sound: it launches the
|
||
attempt and returns, so the ring services post their notification and start
|
||
vibration meanwhile. A **generation** counter, bumped by every `start` and
|
||
`stop`, lets work left in flight (a `prepare`, an app that answers late) see
|
||
that it is no longer wanted. `onSilenced`, called only for the live
|
||
generation, replaces the old boolean result as the trigger for forced
|
||
vibration.
|
||
|
||
Before connecting, `MediaRouteCheck` asks whether media would be **heard**.
|
||
The other app plays as media, not as an alarm, so it gets neither the alarm's
|
||
Do Not Disturb exemption nor its forced speaker route, and its session reports
|
||
"playing" either way. `MediaRoutePolicy` refuses when Do Not Disturb does not
|
||
let media through, or when the media route includes headphones or an external
|
||
device whose type does not say what it is. A classic Bluetooth (A2DP) device
|
||
is a speaker or headphones alike, and telling them apart would need
|
||
`BLUETOOTH_CONNECT`. LE Audio speakers report their own type and pass. A
|
||
refusal rings the ordinary chain.
|
||
|
||
`MediaAppRing` connects through `connectMediaBrowser`, the one wrapper shared
|
||
with the picker. If the app is already playing, it pauses it first, so that
|
||
"playing" afterwards means the request was honoured. It then calls
|
||
`playFromMediaId` (or `play` for resume) and waits for `STATE_PLAYING`. Every
|
||
wait is bounded (`MediaRingPolicy`: 5 s to connect, 2 s for the pause, 15 s to
|
||
start). Once playing, a watchdog checks once per second on two clocks:
|
||
stopped for 5 s, or *not playing* (buffering included) for 15 s. Either one,
|
||
or a route check that now fails, stops the media ring and starts the ordinary
|
||
chain unramped.
|
||
|
||
**The one place Clockula moves a stream volume.** The other app plays on the
|
||
media stream, which the user may have at zero. `MediaAppRing` therefore steps
|
||
the ramp into `STREAM_MUSIC`, aiming at the alarm stream's share of the media
|
||
range (`MediaRingPolicy.mediaVolumeTarget`, never below 1, fixed at start), and
|
||
writes only when the index changes. `MediaRingRegistry` is process-wide: it
|
||
saves the user's level when the first media ring starts and restores it when
|
||
the last one ends, so an alarm and a timer ringing through music apps at once
|
||
cannot save each other's raised volume. It also pauses an app only when no
|
||
other live ring still uses it. No audio focus is requested, because the app
|
||
requests its own. Dismissal *pauses* rather than stops, so the user's queue
|
||
stays where the alarm found it. A process death mid-ring leaves the media
|
||
volume where the ramp had it.
|
||
|
||
There is no preview for these rows. Auditioning another app's track at alarm
|
||
volume from a settings screen would be rude, and the previewer ignores such a
|
||
URI.
|
||
|
||
### 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`, M9's `dismissAllExpired`/`dismissExpired`, 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 still no "stop all"
|
||
**in the UI**, and M9 added no button. What M9 did add is
|
||
`TimerEngine.dismissAllExpired()`, reachable only from
|
||
`ACTION_DISMISS_TIMER` with no data URI: the rule above is about a *thumb* on
|
||
a *ring*, where silently resetting both destroys the information "which one
|
||
finished", while an app saying "dismiss all expired timers" has stated exactly
|
||
which information it is discarding. Its sibling `dismissExpired(id)` answers a
|
||
`clockula://timer/{id}` deeplink and resets the timer **only if it reads
|
||
EXPIRED**, a silent `false` otherwise — dismissing a running countdown because
|
||
a caller referenced it would destroy a measurement. Both route through the
|
||
same private reset, so both honour `delete_after_use` (§17), and both take the
|
||
engine's lock so the expiry slot, the service and the ring session move with
|
||
the state.
|
||
- **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.
|
||
|
||
M9 finished that surface and widened it by one step. `tabForAction` gained
|
||
exactly **one** action, `AlarmIntents.ACTION_SHOW_ALARMS`: the platform's own
|
||
`AlarmClock` strings never reach `MainActivity` — the exported door translates
|
||
them into internal ones — so mapping them here would be dead code that looks
|
||
like a contract. And because three of the contract's outcomes are not "select a
|
||
tab", the shell's intent parameter grew from `openTab: ClockulaDestination?` to
|
||
`openRequest: ShellRequest?`, built by the pure
|
||
`ShellNavigation.requestFor(action, alarmId, alarmIds)`:
|
||
|
||
| `ShellRequest` | What the shell does |
|
||
|---|---|
|
||
| `OpenTab(destination)` | the tab command a tab tap produces — unchanged |
|
||
| `OpenAlarmEditor(id)` | the Alarms tab command, then `navigate(AlarmRoutes.editor(id))` — the same two steps the FAB takes, so the back stack is the one M5 asserted |
|
||
| `ComposeTimer` | the Timers tab command, then the setup panel: the `ModalBottomSheet` over a non-empty list, and nothing extra over an empty one, where the panel is already the empty state |
|
||
| `ChooseAlarmToDismiss(ids)` | the Alarms tab command, then the chooser dialog (§13) |
|
||
|
||
The launching intent is read **once**: `onCreate` derives a request only when
|
||
`savedInstanceState` is null. `setIntent` keeps the intent, so re-deriving it
|
||
on every configuration change would replay *stateful* navigation — a rotation
|
||
would force the user back into the editor, re-open the timer setup sheet, or
|
||
re-ask the dismiss question they had just answered. A genuinely new intent
|
||
arrives through `onNewIntent` instead. Every extra is read inside a
|
||
`runCatching`, for the same reason `IntentExtrasReader` does: `MainActivity` is
|
||
exported, reading any extra unparcels the whole `Bundle`, and a Parcelable whose
|
||
class is not in our classloader would otherwise throw inside `onCreate` — a
|
||
launch crash that also feeds `CrashReporter.isCrashLoop`. The action itself
|
||
never unparcels, so an intent we cannot unpack still selects its tab.
|
||
|
||
`requestFor` **degrades rather than throws**: a missing or non-positive alarm id,
|
||
or an empty id list, falls back to `OpenTab(ALARMS)`, because an intent that
|
||
reaches `MainActivity` malformed must still put the user somewhere sensible. An
|
||
unknown action is `null` and is never rescued by its extras. The chooser's
|
||
candidate ids live in `ClockulaShell` as a `rememberSaveable { LongArray }`, so
|
||
a rotation mid-question does not lose it; a process death does, which is
|
||
correct — the intent that asked has been consumed.
|
||
|
||
### 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.
|
||
|
||
Since M9 the folding itself lives in `domain/text/TextFolding`, and `ZoneSearch`
|
||
delegates to it: `ALARM_SEARCH_MODE_LABEL` has to fold the same way, and a
|
||
second copy of NFD-fold-and-lowercase is how "café" starts matching on one
|
||
surface and missing on the other. The *matching* is still different on purpose —
|
||
the picker is word-prefix, the label search is substring (§17) —
|
||
`ZoneSearchTest` passes unmodified, which is the proof the extraction changed no
|
||
behaviour.
|
||
|
||
### 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.
|
||
|
||
---
|
||
|
||
## 17. System interop
|
||
|
||
`android.provider.AlarmClock` is the contract every other app on the device
|
||
drives a clock app through: an assistant, an automation app, a watch companion,
|
||
a shell script. M9 answers **all seven** of its actions — `SET_ALARM`,
|
||
`SET_TIMER`, `SHOW_ALARMS`, `SHOW_TIMERS`, `DISMISS_ALARM`, `SNOOZE_ALARM` and
|
||
`DISMISS_TIMER` — and the section to read before touching anything
|
||
intent-shaped.
|
||
|
||
### The shape: one pure parser, one Android adapter, one exported door
|
||
|
||
```
|
||
another app / Assistant
|
||
│ android.intent.action.SET_ALARM + extras + maybe a data URI
|
||
▼
|
||
interop/AlarmClockActivity ← exported, permission-guarded, no window
|
||
│ (1) IntentExtrasReader.read(intent) Android → plain Map
|
||
│ (2) AlarmClockRequests.parse(...) PURE. the validator.
|
||
│ (3) AlarmClockHandler.handle(request) suspend, @ApplicationScope
|
||
│ (4) InteropLaunch.intentFor(outcome) PURE. what to show, if anything
|
||
▼
|
||
MainActivity (internal actions only) → ShellNavigation.requestFor → shell
|
||
```
|
||
|
||
Everything that *decides* is pure Kotlin under `domain/interop/`. The Android
|
||
half is two files that carry no policy. `AlarmClockHandler` is the orchestrator
|
||
and contains **no `android.*` import ever**, exactly as the three engines do.
|
||
|
||
The **write** never happens in the shell and never happens twice: the door
|
||
handles the intent, then hands `MainActivity` an *internal* action that is
|
||
idempotent navigation and nothing else. That is why a process death recreating
|
||
`MainActivity` with its original intent cannot re-run a `SKIP_UI` write, which
|
||
it would if `MainActivity` parsed `SET_ALARM` itself. The handler runs on the
|
||
injected `@ApplicationScope` with `async`, and the door `await`s it in
|
||
`lifecycleScope` — so the write outlives a task swiped away mid-flight, while
|
||
the `startActivity` that follows only happens if the door is still alive. The
|
||
`await` is wrapped in `runCatching`: a caller's broken intent must not take the
|
||
app down mid-alarm.
|
||
|
||
### A `Bundle` modelled honestly
|
||
|
||
Hostile input is not "an out-of-range int". It is an `Int` where a `String` was
|
||
documented, a `CharSequence` that is not a `String`, a list with a `null` in it,
|
||
a key that is present and null. The platform's typed getters hide all of that
|
||
behind a default and cannot tell "absent" from "wrong type" — which is precisely
|
||
the distinction this milestone is about. So the parser's input is
|
||
`IntentExtras`, a value class over `Map<String, Any?>` whose four accessors are
|
||
**total**: absent *or* wrong type reads back as `null`. The adapter lifts
|
||
exactly the contract's eleven keys out of the `Bundle` with one documented
|
||
`@Suppress("DEPRECATION")` on `Bundle.get` — the only API that returns a
|
||
heterogeneous value with its type intact — so there is **one** copy of the type
|
||
rules, and it is the copy the tests run. `string()` accepts any `CharSequence`
|
||
(a `SpannableString` from another app is a legitimate message); `int()` accepts
|
||
an `Int` and nothing else, because a caller sending `7L` for `EXTRA_HOUR` is
|
||
broken and guessing on its behalf is how an alarm ends up at the wrong hour.
|
||
|
||
### The range rule
|
||
|
||
`PLAN.md` §6 says extras are "validated and clamped"; M6 locked the opposite for
|
||
timer presets, "dropped, never clamped". Both are right about different things,
|
||
and M9 needs one rule:
|
||
|
||
> **A value that decides when something will ring in the future is dropped when
|
||
> it is out of range. A value that modifies an action the user is taking right
|
||
> now is clamped.**
|
||
|
||
| Extra | Range | Out of range |
|
||
|---|---|---|
|
||
| `EXTRA_HOUR` | 0..23 | **dropped** ⇒ the spec is incomplete ⇒ the editor opens |
|
||
| `EXTRA_MINUTES` | 0..59 | **dropped** ⇒ incomplete. Absent ⇒ **0**, the contract's own default |
|
||
| `EXTRA_LENGTH` | 1..86 400 s | **dropped** ⇒ the setup panel opens |
|
||
| `EXTRA_DAYS` entry | Calendar 1..7 | **dropped**, per entry — but see below |
|
||
| `EXTRA_RINGTONE` | a scheme we accept | **dropped** ⇒ inherit the app default |
|
||
| `EXTRA_ALARM_SNOOZE_DURATION` | 1..60 min | **clamped** |
|
||
|
||
Clamping the hour would set an alarm for 23:00 that the caller asked to set for
|
||
25:00 — enabled, and ringing at an hour nobody chose. Clamping the snooze is the
|
||
opposite case: the alarm is sounding *now*, the user asked for quiet, and
|
||
refusing a 1 000-minute snooze by leaving it ringing is strictly worse than
|
||
granting a 60-minute one. 1..60 is `ClockPrefs`' own clamp, so a snooze an intent
|
||
can ask for is always one the user could have chosen by hand.
|
||
|
||
`EXTRA_DAYS` has one more rule, and it is the same rule read from the other
|
||
side. Entries are dropped one by one, but a list the caller **sent non-empty**
|
||
and from which **nothing** survived makes the spec incomplete, so the editor
|
||
opens and the user sees it. What counts is what was *sent*, not what parsed:
|
||
`[0, 99]` and `["mon", "tue"]` — the second is what
|
||
`putStringArrayListExtra` produces — are the same mistake, and treating either
|
||
as "the caller asked for a one-shot" turns a repeating alarm into an enabled
|
||
single alarm, which is a missed alarm next week. Only an **explicitly empty**
|
||
list, an absent one, or a value that is not a list at all is a one-shot.
|
||
`IntentExtras.listSize` exists for exactly this: `intList` filters by type, so
|
||
it cannot tell an empty list from a list of nothing but rubbish, and here the
|
||
two must not share an answer. A list sent as an `IntArray` counts as a list,
|
||
because `putExtra(key, intArrayOf(...))` is what a caller writes when it does
|
||
not reach for `putIntegerArrayListExtra`.
|
||
|
||
Two more sanitisers. `EXTRA_MESSAGE` becomes a label: trimmed, stripped of every
|
||
character below a space (a newline in a one-line `ListItem` is a layout bug and
|
||
a `\u0000` in a `TEXT` column is worse) and **truncated** to 256 characters —
|
||
truncated, not dropped, because half a label still names the alarm while half an
|
||
hour does not. `EXTRA_RINGTONE` is accepted only if, after trimming, it is at
|
||
most 2 048 characters, carries no control character and begins with `content://`
|
||
or `android.resource://`; `"silent"` (exact, case-sensitive) maps to
|
||
`Ringtones.SILENT_URI`, which forces vibration, so a silent alarm set by another
|
||
app is still an alarm. Everything else — `file://`, `http://`, a bare path —
|
||
reads back as `null`, which in Clockula means *inherit*, which is exactly the
|
||
contract's own documented fallback. It is a drop, not a rejection: an unusable
|
||
sound must never stop an alarm from being set.
|
||
|
||
### What each action does
|
||
|
||
| Action | Behaviour |
|
||
|---|---|
|
||
| `SET_ALARM`, valid hour, `SKIP_UI` | create-or-reuse the alarm, enabled, transient; no window |
|
||
| `SET_ALARM`, valid hour | the same, not transient, then the Alarms tab |
|
||
| `SET_ALARM`, no valid hour | create at `nextWholeHour` carrying every extra that *was* valid, enabled, never transient, then **its editor** (§13) |
|
||
| `SET_TIMER`, valid length | create-or-reuse a timer and **start** it — "this action always starts the timer" — with or without the Timers tab |
|
||
| `SET_TIMER`, no valid length | write **nothing**; the setup panel |
|
||
| `SHOW_ALARMS` / `SHOW_TIMERS` | the tab; no extra is read |
|
||
| `DISMISS_ALARM` | see below |
|
||
| `SNOOZE_ALARM` | the ringing alarm only, for the clamped minutes; nothing written if nothing is ringing |
|
||
| `DISMISS_TIMER` | with no data URI, every expired timer; with one, the timer `clockula://timer/{id}` names — and a URI naming no timer of ours dismisses **nothing** and opens the Timers tab, because a mistyped id must not widen to "all of them", which for a transient timer means deleting them (§14) |
|
||
| anything else, or null | `Unsupported`: no write, no window — opening the app because somebody aimed garbage at an exported activity would be a free way to take over the screen |
|
||
|
||
**Reuse** ("an identical alarm may be re-used") means time, repeat days, label,
|
||
ringtone and vibrate all equal the sanitised request — the five fields the
|
||
extras can set, so a snooze override the user set by hand does not prevent it.
|
||
Ties go to the **lower id**, the same rule the alarm engine's precedence
|
||
already uses. A reused alarm is enabled *and* has any pending skip cleared,
|
||
because leaving one armed would silently eat the occurrence just asked for, and
|
||
it is never made transient, as the contract's own parenthesis says. A timer is
|
||
identical only if it is **IDLE** with the same duration and label: restarting
|
||
somebody's running countdown is destructive.
|
||
|
||
### `DISMISS_ALARM`: which alarm, and when to ask
|
||
|
||
| Source | Candidates |
|
||
|---|---|
|
||
| data URI `clockula://alarm/{id}` | that alarm, enabled or not |
|
||
| `ALARM_SEARCH_MODE_NEXT` | the ringing alarm if there is one, else the next to fire |
|
||
| `ALARM_SEARCH_MODE_ALL` | every **enabled** alarm |
|
||
| `ALARM_SEARCH_MODE_TIME` | every enabled alarm at exactly that time of day |
|
||
| `ALARM_SEARCH_MODE_LABEL` | every enabled alarm whose folded label **contains** the folded phrase |
|
||
| neither | every **enabled** alarm |
|
||
| a mode whose parameters are unusable | none |
|
||
| a data URI that names no alarm of ours | none |
|
||
|
||
Three deliberate narrowings, each because the alternative is worse. **A data
|
||
URI that is present and unreadable is not the same as no URI**: it names none,
|
||
rather than falling through to the search mode — or, with no mode given, to the
|
||
unspecified search's *every enabled alarm*. Otherwise a caller that named one
|
||
alarm and mistyped its id would dismiss alarms it never named, and with exactly
|
||
one enabled alarm that happens silently, below the two-match dialog. **Time
|
||
matches exactly, never "nearest"**: the contract says "most closely matched", and
|
||
dismissing an alarm the caller did not name is a missed alarm, at any distance.
|
||
**`EXTRA_IS_PM` is consulted only when the hour is ambiguous**: 13..23 is a
|
||
24-hour reading and wins over a contradictory flag, 12 + AM is midnight and 12 +
|
||
PM is noon, and a 0..12 hour with the flag *absent* — the case the contract calls
|
||
ambiguous and says to ask about — carries **both** readings as candidates, so the
|
||
ambiguity only reaches the user when the data cannot settle it. Label search is
|
||
substring after folding rather than the zone picker's word-prefix, because "my
|
||
gym alarm" must find "Morning gym"; a **blank** phrase matches nothing, since a
|
||
needle that matched everything would dismiss every alarm in the app.
|
||
|
||
When to ask: `ALL` means "all of them", so it dismisses every match and never
|
||
asks — taken literally, the contract's "show the results" would make that mode
|
||
useless. Every other search with **two or more** matches returns
|
||
`ChooseAlarmToDismiss` and writes nothing; with one it dismisses; with none it
|
||
opens the Alarms tab and writes nothing. "Dismiss" is
|
||
`AlarmEngine.dismissUpcoming` (§11), which is also what the dialog calls.
|
||
|
||
### `delete_after_use`
|
||
|
||
The contract, twice: a `SKIP_UI` alarm that is not repeating, and a `SKIP_UI`
|
||
timer, "should be removed after it has been dismissed". Without it, "wake me at
|
||
6:30" every morning leaves a disabled 06:30 row behind every single time. The
|
||
flag is set **only** on the `SKIP_UI` create path — never on reuse, never on the
|
||
incomplete/editor path — so only the voice/automation route can produce a
|
||
transient row, and it is honoured in two places, both inside an engine and both
|
||
under its lock (§11, §14). `upcoming()` stays read-only: collecting it over a
|
||
transient alarm deletes nothing.
|
||
|
||
One consequence, accepted rather than papered over: a user who edits an
|
||
assistant-set alarm's label still has a transient alarm, and it still vanishes
|
||
after it rings. That is Google Clock's behaviour too, the alarm is still "the one
|
||
the assistant set", and the alternative — clearing the flag on any edit — means
|
||
the editor has to know about a contract it otherwise never touches.
|
||
|
||
### Deeplinks
|
||
|
||
Clockula **accepts** `clockula://alarm/{id}` and `clockula://timer/{id}`, parsed
|
||
strictly: exact lowercase scheme and host, one path segment of nothing but
|
||
digits, a positive `Long`, no query and no fragment, trimmed first because an
|
||
`Intent`'s data may arrive from a shell command line. Anything else is `null`
|
||
and falls through to the search mode rather than being guessed at. It
|
||
**publishes** none in v1: publishing one requires the
|
||
`VoiceInteractor.CompleteVoiceRequest` flow that §10 puts out of scope. Reading
|
||
an id from an untrusted caller is safe — dismissing an alarm is reversible, the
|
||
caller already holds `SET_ALARM`, and every engine verb is guarded by the state
|
||
it makes sense for.
|
||
|
||
### Next-alarm publishing, and no toast
|
||
|
||
`setAlarmClock` has drawn the status-bar alarm icon and the lockscreen line since
|
||
M3; M9 fixed what tapping them opens (§11). That icon is also why there is **no
|
||
toast** on a `SKIP_UI` write, where AOSP DeskClock has one: `SKIP_UI` means
|
||
"bypass any intermediate UI", a voice assistant speaks its own confirmation, the
|
||
app has shipped nine milestones with no toast anywhere, and formatting "Alarm set
|
||
for 7:00" would need a second, non-composable 12/24-hour formatter — the thing
|
||
M8 spent a milestone eliminating. The platform's own receipt is better than a
|
||
toast and costs nothing.
|