diff --git a/CHANGELOG.md b/CHANGELOG.md index 54caf5f..0aaeee7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -111,3 +111,30 @@ Tag sections feed the release notes — see [`docs/RELEASING.md`](docs/RELEASING is kept rather than quietly replaced with the default: an unmounted card comes back. If a file you picked cannot be held onto permanently, that is disclosed too instead of being hidden. +- The timers tab, for real: several timers at once, each with its own label, + length and sound, and a keypad that sets one in a couple of taps — with saved + presets you can add to and remove from as you go. A timer starts the moment + you press Start, so an accidental tap leaves nothing behind to delete. +- Pause, resume, reset and "+1 min" on every timer, from the row, from the live + pill and from the notification — and "+1 min" on a timer that has just rung + starts it again with a whole minute, rather than handing back a paused timer + you have to press Start on. +- A notification that counts the timer down without waking the device every + second: the system draws the ticking, so Clockula posts once when something + actually changes. It carries the same two buttons the row does, and they work + with the app closed. +- A timer keeps counting across a reboot and rings when you come back — the + countdown is anchored on the device's uptime, so changing the system clock + cannot move it in either direction. Change the clock while a timer runs and + the notification's countdown follows the change instead of going wrong. +- Two timers finishing together share one sound and one ten-minute window, and + each is stopped on its own, so you can still tell which one you have dealt + with. A timer left ringing goes quiet after ten minutes but stays finished, so + you can come back and add a minute to it — and the next timer to finish rings + for itself rather than inheriting that silence. +- A timer that finishes while an alarm is ringing waits its turn instead of + talking over it: it is still marked finished, its notification says so, and it + starts sounding the moment the alarm is done. +- Progress on each timer card is drawn by the Expressive wavy indicator, and the + wave itself is the state — it flattens when you pause and when the timer is + done. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 21cda50..345421e 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -79,30 +79,39 @@ 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 volume ramp, the ring policies | +| `domain/alarm/` | the alarm engine's pure half: occurrences, the resolver, the ring state, the presentation and scheduling policies, the challenge gate. The *shared* ring vocabulary moved out to `domain/ring/` in M6 | +| `domain/ring/` | what both ring paths share: `RingAudio` (the ramp tick, the volume floor), `VolumeRamp`, `AudioSourcePolicy`/`RingtoneSourceKind`, `RingFallbackPolicy`, `VibrationPattern` | +| `domain/timer/` | the timer path's pure half: `TimerReadings` (the app's one timer precedence), `TimerExpiry` (the sweep and the slot's value), `TimerRingPolicy`, `TimerNotificationPolicy`, `TimerPresets`, `TimerDurationEntry`, `TimerSettings`, `TimerRing` | | `domain/time/` | `WallClock`, `ElapsedRealtimeClock`, `ZoneProvider`, `BootId`, `Ticker` | | `domain/live/` | `LivePillSelector` and its state — which running thing the live pill is about, as a pure function | | `domain/format/` | `ClockFormat` — `M:SS` / `H:MM:SS`, countdowns rounded up, elapsed truncated — and `NextFire` (`NextFireLabel` + `NextFireFormat`), the alarm row's "in 9h 12m" as data | | `data/db/` | `ClockulaDatabase` — `@Database` v2, `exportSchema = true` — and `Migrations` | | `data/alarms/` | the alarm and ring-state entities, DAOs, mappers and repositories | -| `data/timers/` | `TimerEntity`, `TimerDao`, `TimerMapper`, `TimerRepository(+Impl)` | +| `data/timers/` | `TimerEntity`, `TimerDao`, `TimerMapper`, `TimerRepository(+Impl)`, `TimerRingStateStore` (the ring session's one DataStore record) | | `data/worldclocks/` | `WorldClockEntity`, `WorldClockDao`, `WorldClockMapper`, `WorldClockRepository(+Impl)` | | `data/stopwatch/` | `LapEntity`, `LapDao`, `LapMapper`, `StopwatchStateStore`, `StopwatchRepository(+Impl)` | -| `data/prefs/` | `SettingsPrefs`, `ClockPrefs`, `StopwatchPrefs`, `UiPrefs` | +| `data/prefs/` | `SettingsPrefs`, `ClockPrefs`, `StopwatchPrefs`, `TimerPrefs`, `UiPrefs` | | `data/ringtones/` | the two ringtone seams — `RingtoneCatalog` (the device's alarm sounds, titles, playability, a SAF grant) and `RingtonePreviewer` — plus their `System*` implementations | | `data/time/` | `SystemWallClock`, `SystemElapsedRealtimeClock`, `SystemZoneProvider`, `AndroidBootIdProvider`, `RealTicker` | | `data/di/` | `DataModule`, `DatabaseModule`, `RepositoryModule`, `TimeModule`, `RingtoneModule` | | `alarm/` | `AlarmEngine` and the four seams it talks to — `AlarmScheduler`, `AlarmCapabilities`, `RingCoordinator`, `AlarmNotifier` — plus `AlarmIntents` | | `alarm/android/` | the seams' Android implementations: AlarmManager, the capability reads, the service handle, the snoozed notification | | `alarm/receiver/` | `AlarmFireReceiver`, `AlarmActionReceiver`, `SystemEventReceiver` | -| `alarm/ring/` | the ringing foreground service, its audio player, its vibrator, its notifications | +| `alarm/ring/` | the alarm's ringing foreground service and its notifications. Its audio player and vibrator moved to `ring/` in M6, shared with the timer's ring | | `alarm/di/` | `AlarmModule` — `@Binds` for the four seams | +| `ring/` | `RingAudioPlayer` and `RingVibrator` — one `MediaPlayer` wrapper and one vibrator for both ring paths. A build rule fails on a second one | +| `timer/` | `TimerEngine` and the three seams it talks to — `TimerScheduler`, `TimerServiceHandle`, `AlarmRingStatus` (+ `AlarmStateRingStatus`) — plus `TimerIntents` | +| `timer/android/` | the seams' Android implementations: the one elapsed-realtime AlarmManager slot, the service handle | +| `timer/receiver/` | `TimerExpiryReceiver` (the slot arriving), `TimerActionReceiver` (the notification's buttons) | +| `timer/service/` | `TimerService` — one foreground service for the countdown and the ring — and `TimerNotifications` | +| `timer/di/` | `TimerModule` — `@Binds` for the three seams | | `system/` | `RebootRepair` — the boot-id gate | | `ui/theme/`, `ui/crash/` | the M0/M1 theme and the crash-report surface | | `ui/shell/` | the navigation policy (`ShellNavigation`), the adaptive shell, the live pill and its source/ViewModel, the notification-permission ask | -| `ui/common/` | `rememberAlarmTimeFormatter` — the one 12/24-hour formatter the list, the editor and the ring screen share | -| `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 and ringtone pickers | -| `ui/timers/`, `ui/stopwatch/`, `ui/worldclock/` | one top-level tab each — a title bar and an empty state until M6–M8 fill them | +| `ui/common/` | `rememberAlarmTimeFormatter` — the one 12/24-hour formatter the list, the editor and the ring screen share — and, since M6, the ringtone picker (`RingtonePickerState`, `RingtonePickerScreen`), which both editors need | +| `ui/alarms/` | the real Alarms tab (M5): the routes, the list's pure row builder and its source, the two ViewModels, the list, the editor, the M3 time-picker host, the repeat-day selector and the override pickers (the ringtone picker moved to `ui/common/` in M6) | +| `ui/timers/` | the real Timers tab (M6): the routes, the pure row builder and its source, the two ViewModels, the list, the row, the setup panel and its keypad, the editor | +| `ui/stopwatch/`, `ui/worldclock/` | one top-level tab each — a title bar and an empty state until M7–M8 fill them | | `ui/ring/` | `AlarmRingActivity`, its ViewModel, `RingScreen` and the dismiss-challenge controls | --- @@ -238,6 +247,17 @@ occurrence, so skipping it is dismissing it in advance. | `sort_order` | INTEGER | indexed | | `created_at`, `updated_at` | INTEGER | wall-clock epoch millis | +**M6 changes this table by not one column.** No migration, no version bump: the +database stays at v2. The only new persistent fact the Timers tab needs is +*when the current ring session began sounding*, and that is a **single record**, +which §5 of `docs/PLAN.md` sends to DataStore rather than to Room. It is not a +`timers` column for the three reasons this section already gives: volatile ring +state must not appear in M10's JSON backup of a timer, it must not survive the +timer's deletion, and the timer mapper's tests should stay about timers. It is +not a `timer_states` table either, because at most one timer ring sounds at a +time — so it is `timer_ring_sounding_since_millis` behind `TimerRingStateStore` +(§6). + ### `world_clocks` | Column | Type | Notes | @@ -389,14 +409,47 @@ 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` on a running timer rebases on the clamped -remaining and rewrites all three anchors from a single reading of both clocks, -so "+1 min" always grants a whole minute even when the end anchor has already -gone by. +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 @@ -432,7 +485,7 @@ presentation.** ## 6. Preferences One DataStore file, `clockula_prefs`, behind floret-kit's typed `PrefStore`. -Three slices: +Four slices: | Slice | Keys | Owner | |---|---|---| @@ -440,6 +493,7 @@ Three slices: | 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` | | Stopwatch run record (M2) | `stopwatch_state`, `stopwatch_started_elapsed_millis`, `stopwatch_accumulated_millis`, `stopwatch_last_lap_cumulative_millis` | `StopwatchPrefs`, behind `StopwatchStateStore` | | System facts (M3) | `last_boot_id` | `SystemPrefs`, behind `BootStateStore` — the persisted half of the reboot gate | +| Timers (M6) | `timer_presets`, `timer_ring_sounding_since_millis` | `TimerPrefs` — the presets surfaced as `SettingsPrefs.timerPresets`, the ring session's anchor behind `TimerRingStateStore` | The stopwatch's *run* is a single record, so it lives in DataStore rather than as a one-row table (`PLAN.md` §5); its *laps* are a list, so they live in Room. @@ -457,6 +511,21 @@ value the user could never have chosen. Unknown enum names degrade to their default; a `home_zone_id` the device's tzdata no longer knows reads back as absent; blank strings read back as `null`. +`timer_presets` is the same discipline over a *list*: one comma-joined string of +millis, sanitised both ways through `TimerPresets.sanitise` — non-numeric +entries dropped, anything outside 1 s…24 h **dropped rather than clamped** +(a clamp would silently turn one preset into another, possibly a duplicate), +de-duplicated, sorted ascending, capped at twelve. An **absent** key means the +built-in set (1, 2, 3, 5, 10, 15, 30 min, 1 h); an explicitly **empty** value +means the user removed them all, and is honoured. Adds and removes go through +`PrefStore.update`, so two concurrent edits cannot swallow each other. + +`timer_ring_sounding_since_millis` is wall clock, deliberately: "how long has +this been making noise" is a question a reboot must not reset, which is why +`alarm_states.ringing_since` is wall clock too. §5's elapsed-realtime rule +governs the *countdown*, not the ring's age. Null — the key absent — means no +session, and the engine writes it only when the value changed. + `SettingsPrefs.defaults` and `StopwatchStateStore.run` are each built **once** as a property, not per access, and are distinct-until-changed. A collector keyed on the flow instance (as `collectAsStateWithLifecycle` is) would otherwise tear @@ -472,7 +541,7 @@ before the first frame, and the alternative is an app only "clear data" can fix. ## 7. Dependency injection -Hilt, `SingletonComponent` throughout. Five app modules plus the kit's: +Hilt, `SingletonComponent` throughout. Seven app modules plus the kit's: | Module | Provides | |---|---| @@ -482,13 +551,14 @@ Hilt, `SingletonComponent` throughout. Five app modules plus the kit's: | `TimeModule` | `@Binds` for `WallClock`, `ElapsedRealtimeClock`, `ZoneProvider` and `BootIdProvider` | | `AlarmModule` | `@Binds` for the alarm engine's four seams: `AlarmScheduler`, `AlarmCapabilities`, `RingCoordinator`, `AlarmNotifier` | | `RingtoneModule` | `@Binds` for the two ringtone seams: `RingtoneCatalog`, `RingtonePreviewer` | +| `TimerModule` | `@Binds` for the timer engine's three seams: `TimerScheduler` (the one elapsed-realtime slot), `TimerServiceHandle` (the foreground service), `AlarmRingStatus` (read-only: "is an alarm ringing") | From floret-kit: `core-di`'s `CoroutinesModule` supplies `@IoDispatcher` and the process-lifetime `@ApplicationScope` the receivers launch on, and `core-prefs` supplies `PrefStore`, `Pref` and the appearance keys. `BroadcastReceiver.onReceive` is abstract, so a Kotlin subclass cannot call -`super.onReceive(...)` — which is exactly where Hilt injects. The three +`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. @@ -508,10 +578,10 @@ block whichever thread asked. ## 8. Testing -JVM-first. 624 unit tests run in the gate; 28 instrumentation tests compile in +JVM-first. 843 unit tests run in the gate; 32 instrumentation tests compile in it and run only on a device. There is **no Robolectric** and no plan for it: everything that would have needed a shadow is behind one of the injected seams, -or is a pure function in `domain/alarm/`. +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 @@ -595,6 +665,46 @@ removing the row. **They have not been run**: no device is attached to the machine this milestone was built on, so the gate compiles them and this paragraph says so. +**M6 kept it for a second screen — and for a foreground service**, which is the +other place a project usually gives in. Every decision left the composables and +the service: `TimerReadings` (the app's one timer precedence, extracted from +M4's pill so the pill, the notification and the ring cannot disagree), +`TimerExpiry` (what is due, and what the single slot should hold), +`TimerRingPolicy` (the whole audio arbitration), `TimerNotificationPolicy` +(the subject, the count, the two actions, the alert-once rule), `TimerPresets`, +`TimerDurationEntry` (the keypad, as a state machine over a digit string) and +`TimerListRows`. `TimerService` holds **no policy and no state** beyond one +`alreadyAlerted` flag: it asks `TimerEngine.serviceState(...)` and actuates the +answer, and even the two `delay` amounts come from the engine — so the timings +are asserted in a JVM test. + +Three new fakes complete the seam set in M3's shape: `FakeTimerScheduler` holds +the expiry slot **as the value it currently holds**, `FakeTimerServiceHandle` +records the up/down transitions **in order**, and `FakeAlarmRingStatus` is a +settable boolean behind a `MutableStateFlow`, so an arbitration test hands the +engine a `true` instead of building alarm state. +`testing/TimerEngineHarness.kt` is `AlarmEngineHarness`'s arrangement for the +other engine: the **real** `TimerEngine` over the real `TimerRepositoryImpl`, +the fake DAO, the three fake seams, the real `RebootRepair` and a real DataStore +under a `@TempDir` — with a counting `DataStore` wrapper, so "the session anchor +is persisted **once** across three reads" is assertable without reaching into +the store. + +Three more `ArchitectureRulesTest` rules: `timer/TimerEngine.kt` joins the +Android-free list; no file under `ui/` *uses* `TimerScheduler`, `TimerService`, +`TimerRingPolicy` or `AlarmManager` (the mirror of the alarm rule — the screen +schedules nothing and decides no ring); and `MediaPlayer` appears only under +`ring/` and `data/ringtones/`, which makes "reuses M3's audio path" mechanical: +a third audio path cannot be added without the build failing. + +What was left to the device is four more instrumentation tests: the keypad and +the bottom sheet really composing and really writing, the typed digits +surviving a real activity recreation (the only thing that proves +`rememberSaveable`), one row's Pause leaving a second running row alone, and a +real back press from the timer editor landing on a still-selected Timers tab. +**They have not been run**, for the same reason M5's have not: no device is +attached to the machine this milestone was built on. + Stack: JUnit 5 + Truth + Turbine + `kotlinx-coroutines-test`, with `useJUnitPlatform()` and `isReturnDefaultValues = true`. @@ -638,9 +748,16 @@ declared because a specific path would otherwise fail — silently, at 07:00. | `RECEIVE_BOOT_COMPLETED` | AlarmManager keeps nothing across a reboot | | `USE_FULL_SCREEN_INTENT` | the ring screen over the lock screen | | `POST_NOTIFICATIONS` | the ring and snoozed notifications | -| `FOREGROUND_SERVICE`, `FOREGROUND_SERVICE_SYSTEM_EXEMPTED` | the ringing service | +| `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` | the alarm vibrates, and vibrates alone when no audio source opens | +| `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 @@ -649,7 +766,7 @@ degrades to a `PRIORITY_MAX`, `CATEGORY_ALARM` heads-up notification on the same HIGH-importance channel, which still rings. M3 declares them; M4 asks for them and M10's self-check screen explains them. -The ringing service's `android:foregroundServiceType` is **`systemExempted`**, +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 @@ -657,7 +774,7 @@ not the user's media session, and it would owe the store a media justification and emphatically not `shortService`, which caps at about three minutes against a ten-minute ring window. -The three receivers are `android:exported="false"`: a protected system broadcast +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. @@ -666,8 +783,10 @@ delivered regardless — so nothing else can fake a fire. storage that is unreadable before the first unlock. Components: `MainActivity`, the non-exported `CrashReportActivity` and -`AlarmRingActivity`, the `AlarmRingService`, the three receivers, and AppCompat's -locale metadata holder service. +`AlarmRingActivity`, two foreground services (`AlarmRingService` and M6's +`TimerService`), five receivers — `AlarmFireReceiver`, `AlarmActionReceiver`, +`SystemEventReceiver`, `TimerExpiryReceiver`, `TimerActionReceiver` — and +AppCompat's locale metadata holder service. --- @@ -676,15 +795,20 @@ locale metadata holder service. | Not here | Milestone | |---|---| | The exact-alarm and full-screen-intent grant deep links, and any explanation of a denial. M4 asks for `POST_NOTIFICATIONS` once on first launch; the rest waits for the self-check screen, because a bare jump into system settings with no reason given is hostile | M10 | -| Every tab's real content beyond Alarms — creating and running a timer (M6), starting and lapping the stopwatch (M7), the zone list and analog face (M8). Those three keep M4's title bars and empty states | M6–M8 | +| Every tab's real content beyond Alarms and Timers — starting and lapping the stopwatch (M7), the zone list and analog face (M8). Those two keep M4's title bars and empty states | M7–M8 | | Reordering alarms by hand. The list is ordered by time of day (§13) and `alarms` has no `sort_order` column, so this would be a schema change for a preference the time order already answers | not planned for v1 | | A per-alarm auto-silence duration. Every other per-alarm setting became an override picker in M5; this one is still global and still `AlarmRing.AUTO_SILENCE_AFTER` | M10 at the earliest | -| Timers ringing — M6 reuses this milestone's audio path; M3 wires no timer to it | M6 | | Stopwatch presentation, best/worst lap analysis (its post-reboot repair shipped in M3) | M7 | | ICU city and zone display names, offsets, day differences | M8 | | The `android.provider.AlarmClock` intent surface and its hostile-extra validation (`TimeOfDay.clamped` and `Zones.normalise` exist so M9 has something to call) | M9 | | The self-check screen ("why might my alarm not ring?"), settings screen, JSON backup / SAF export | M10 | | A user-configurable auto-silence duration, an unlimited-snooze option, per-alarm auto-silence | M10 at the earliest | +| Reordering timers by hand. `sort_order` and `TimerRepository.reorder` exist and stay caller-less: the list is in storage order, M5 gave the same answer for alarms, and a drag surface would also mean abandoning the `LazyColumn` | not planned for v1 | +| Per-timer vibrate, volume-ramp or dismiss-challenge overrides. `timers` has exactly one nullable settings column, its ringtone; four more would be a schema change for settings the roadmap does not ask for | M10 at the earliest | +| A "stop all" for several expired timers, and undo after a timer delete. Each expired timer is acknowledged on its own, and the delete confirmation asks before rather than after | not planned for v1 | +| A presets management screen, and editing `default_timer_duration_millis` or `default_timer_ringtone_uri`. M6 reads both and offers the two-gesture preset surface on the setup panel | M10 | +| A timer that ramps its volume, a dismiss challenge or a snooze for a timer | not planned for v1 | +| Surfacing `TimerSnapshot.anchorIsStale` in the UI. The boot repair makes it vanishingly rare and the value is already honest; a row does not say "estimated" in v1 | not planned for v1 | | Screenshots and store listing polish | M11 | Also absent by design: any seeded default data — no starter alarm and no home @@ -852,6 +976,18 @@ would edit the user's own alarm volume and leave it edited. `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 @@ -913,13 +1049,35 @@ wins" rule the alarm engine's precedence already uses, so the app has one rule. Timers outrank the stopwatch because a timer has a deadline: a missed timer costs something, a missed stopwatch tick costs nothing. +**M6 moved rows 1–3 into `TimerReadings` and rewrote `LivePillSelector` to call +it.** The notification and the ring need the same answer — "which of several +running timers is this about" — and three copies of one comparator is how three +surfaces start disagreeing. `LivePillSelectorTest` passing **unmodified** is the +proof the extraction changed no behaviour; `LivePillSelectorPrecedenceTest` +cross-checks the two callers against each other. The pill keeps its own +three-valued `LivePillMode`, because it has a stopwatch to describe too, mapped +from `TimerMode` in one total `when`. + +**M6 also re-pointed the pill's timer actions at `TimerEngine`.** M4 called +`TimerRepository.pause/start/reset` directly; after M6 there is an AlarmManager +registration and a foreground service that have to move with the state, so +pausing from the pill would otherwise leave the expiry slot pointing at a dead +deadline and the service posting a stale notification. Self-healing — the next +sweep would find nothing due — but wasteful and wrong. The stopwatch branches +keep calling `StopwatchRepository` until M7. + The mode and value come from `Timer.snapshotAt` / `StopwatchRun.snapshotAt`, not from the stored row — so §5's whole elapsed-realtime-versus-wall-clock story, stale anchor and all, is honoured once. The consequence that matters: a running timer that reaches zero flips the pill to `EXPIRED` immediately, without waiting -for M6's expiry service to write `markExpired`. The pill never counts into +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. @@ -1117,3 +1275,312 @@ Delete lives in the editor behind a confirmation that names the alarm, and there is deliberately no undo chip: the delete happens on the editor, so an undo chip would have to live on the list, and an accidentally deleted alarm is discovered at 07:00 rather than now — the cheap moment to ask is before. + +--- + +## 14. Timers + +The section to read before changing anything timer-shaped. `AlarmEngine` and +`TimerEngine` are **peers**: neither drives the other, and the only thing they +share is one read-only question (below). + +### The shape + +`TimerEngine` is the timers' analogue of §11's engine: plain Kotlin, no +`android.*` import ever, reaching the platform through three seams it does not +implement — `TimerScheduler` (one AlarmManager slot), `TimerServiceHandle` (the +foreground service) and `AlarmRingStatus` (read-only: "is an alarm ringing"). +It owns every write the pure policies ask for, and every public entry point is +serialised on one non-reentrant `Mutex`, because they arrive from threads that +know nothing about each other: a broadcast on `Dispatchers.Default`, the +service's collector, a ViewModel, `ClockulaApp`'s launch pass. + +It is **not** driven by a Flow, for the same reason the alarm engine is not: +resolution writes state, so an engine that resynced on every repository emission +would re-trigger itself on its own writes. `resync()` is called explicitly. + +The engine owns the **lifecycle** verbs — `start`, `pause`, `reset`, `addTime`, +`delete`, plus `onExpiryDue`/`resync`/`onBootCompleted` — because each of them +can move the slot, the service or the ring session, and every one of them is +reachable from a notification button with no ViewModel in sight. `delete` is on +the engine and not on a ViewModel-plus-`resync` path deliberately: deleting a +*ringing* thing and leaving the global ring slot sounding is a bug class M5 +already met once, and the cheapest way not to have it again is to make the wrong +call impossible to write. **Configuration** edits (`rename`, `setDuration`, +`setRingtoneUri`) stay on `TimerRepository` and the editor calls them directly. + +`TimerRepository` deliberately has **no generic `edit(id, transform)`** the way +`AlarmRepository` does: a timer row carries three derived anchors whose +consistency is the whole of §5, and a caller handed a `(Timer) -> Timer` can +break them. `setDuration` is refused unless the timer is `IDLE`. + +### Expiry: one slot, two triggers, an idempotent sweep + +There is **one** AlarmManager registration for every timer, holding the earliest +running deadline, and it carries **no timer id**. The fire is +`TimerEngine.onExpiryDue()`: mark *every* timer whose snapshot has reached zero +as `EXPIRED`, then re-register the next earliest. Three properties fall out, and +each is a test: + +- a fire delivered **late** still expires everything that came due while it was + delayed; +- a fire delivered **early**, or for a timer the user has since stopped, finds + nothing due and writes nothing — so the trigger needs no grace window and no + watermark of its own: the anchors *are* the watermark; +- two timers expiring in the same second are **one pass**, not a race. + +The base is **`ELAPSED_REALTIME_WAKEUP`**, mirroring the clock the domain +anchors on: **alarms are `RTC_WAKEUP`, timers are `ELAPSED_REALTIME_WAKEUP`.** +That is not tidiness, it is the requirement — a wall-clock registration moves +when the user changes the system time, so a `TIME_SET` would warp a running +timer's expiry, which §5 forbids. With this base a `TIME_SET` needs no +re-registration at all, and a test asserts the slot's value and every stored row +are unchanged across a three-hour clock jump in each direction. + +`setExactAndAllowWhileIdle` is tried first and `setAndAllowWhileIdle` is the +fallback. It does **not** go through `SchedulingPolicy`, whose +`EXACT_ALARM_CLOCK` branch names `setAlarmClock` — and **a timer must never +populate `getNextAlarmClock()`**, because that slot draws the status-bar alarm +icon and the lockscreen line and belongs to the user's next *alarm*. Late is +survivable; silent is not, so there is no third branch. + +*Known limitation, recorded rather than glossed:* `*AllowWhileIdle` alarms are +rate-limited per app while the device is idle. Clockula holds `USE_EXACT_ALARM`, +which exempts it, but a device that refuses the grant could deliver a +back-to-back sequence of short timers late. The idempotent sweep is one +mitigation; the second is that **the service, while alive, `delay`s to the same +deadline and calls the same `onExpiryDue()`**. Neither trigger has to be +reliable alone. + +Both `delay` amounts come **from the engine** (`TimerServiceState.expiresIn` and +`silenceIn`), so the timings are asserted in a JVM test and the service only +ever does `delay(d)`. Both are floored at `TimerRing.WAKE_FLOOR` (100 ms) so a +rounding error cannot spin the loop. + +### One foreground service, alive exactly while something is active + +`TimerService` is a single `systemExempted` foreground service covering both +phases — the countdown and the ring. Its lifetime predicate is the live pill's: +**active, not running.** It is up while any timer is `RUNNING`, `PAUSED` or +`EXPIRED` and down when every timer is `IDLE` or gone. `PAUSED` is in the +predicate for §12's reason: dropping the notification on pause would leave a +user who paused from the shade with no way to resume without opening the app. +One "active" predicate in the whole app. + +One service and not two, because a timer expiring while another runs must not +need a second one, and because the ring phase needs nothing the countdown phase +does not already have. + +**The countdown holds no wake lock.** A forty-five-minute timer keeping the CPU +awake is a battery bug; the AlarmManager slot is what wakes the device. The +*ring* holds one, with a fifteen-minute timeout, exactly as `AlarmRingService` +does. + +The service is a **readout and a control surface, never the timekeeper** — the +stored anchors and the AlarmManager slot are, and both survive its absence. So +`TimerServiceHandle.sync` wraps both the start and the stop in `runCatching`, +and the service holds no policy and no state of its own beyond one +`alreadyAlerted` flag. Its trigger flow is +`combine(timers, alarmIsRinging, defaults)` and deliberately **excludes** the +ring session's anchor, so the engine's own anchor write cannot re-trigger the +collector that caused it. + +### The audio arbitration: an alarm wins, and the timer's ring is deferred, not lost + +Two `USAGE_ALARM` streams at once is not a feature, and §11's ring service and +auto-silence backstop are single global slots. So a timer that expires while an +alarm is ringing **is still marked `EXPIRED`** — its data is truthful and the +user must learn the pasta is done — and its notification says so, but it **does +not open audio**. When the alarm's cycle closes, the timer starts sounding, and +*nothing re-arms it*: the decision is a pure function of current state rather +than an event. + +``` +TimerRingPolicy.decide(expired, defaults, alarmIsRinging, soundingSince, now) +``` + +`alarmIsRinging` arrives through one read-only seam, `AlarmRingStatus`, backed +by `AlarmStateRepository.states()`. The timer engine therefore never reaches +into `alarm_states` itself, never calls `AlarmEngine`, and never touches the +alarm's ring slot or its auto-silence registration — asserted. A test hands the +engine a boolean instead of building alarm state. + +The reverse case is free and symmetric: an alarm firing over a sounding timer +flips the flag, the flow re-emits, and the timer's audio stops. `AlarmEngine` +writes `ringingSince` *before* it starts its own audio, so the flip leads the +sound. + +### One ring session for however many timers, and one window + +- **Expiry is a set.** Every due timer is marked `EXPIRED`. +- **The ring is one.** The subject is the first expired timer in §12's order, + and its ringtone is the one that plays. +- **Stop acknowledges one timer.** Stopping the subject resets *that* timer; if + another expired timer remains, the ring continues for the next subject. Each + timer is a separate thing the user set, and silently resetting all of them + because one was acknowledged destroys the information "which ones finished". + Two timers cost two taps, which is correct — there is no "stop all". +- **A session is one window.** It opens when the audio *actually starts + sounding* and closes when the expired set returns to empty. A second timer + expiring mid-session inherits the session's remaining window rather than + buying a fresh ten minutes — the conservative direction, and the same rule as + §11's "a reboot does not buy the alarm another ten minutes". The anchor is the + instant the audio *opened*, not the instant of expiry, so a long alarm ring in + front of it does not eat the timer's whole window. +- **A lapsed window does not silence the next timer.** A window that has run out + belongs to the timers the user chose to leave `EXPIRED`; a timer reaching zero + *after* it is a new event and opens a session of its own. The sweep is where + that is decided, because the sweep is the only place that knows a timer has + just come due: a non-empty due set drops a lapsed anchor, and the next pass + opens a fresh window with its own heads-up. Inheriting stays the rule for a + timer expiring *inside* a live window. + +`TimerRing.AUTO_SILENCE_AFTER` is **ten minutes** — its own constant with the +same value as the alarm's, free to diverge, because coupling the two would make +one number answer two questions. The load-bearing difference from an alarm: +**auto-silence stops the noise and leaves the row `EXPIRED`.** An auto-silenced +alarm is a *missed* alarm and its cycle closes; an auto-silenced timer has still +finished, and the user coming back must be able to see that and add a minute +to it. Auto-silence is decided **before** suppression, so an alarm ringing over +a window that has run out cannot hand it a fresh one. + +`TimerRing.RAMP_SECONDS` is **0**. The volume ramp exists so an alarm does not +jolt a sleeping person awake; a timer is set by someone awake who wants to know +*now*, and a gentle start is just a quiet start. `VolumeRamp.levelAt(_, 0s)` +already returns `1f`, so "parameterised" means literally one different argument +to the same player. + +### The notification: one channel, one id, alert once + +The subject is §12's; the rest are a count. + +| Subject reads | Title | Body | Actions | +|---|---|---|---| +| `RUNNING` | the label, or "Timer" | the **platform chronometer** counting down | Pause · +1 min | +| `PAUSED` | the label | "Paused · 4:32" | Resume · Reset | +| `EXPIRED` | the label | "Finished" | Stop · +1 min | + +plus "+2 more timers" when other active timers exist. Two actions at most, and +they are the *same pair* the row offers for that mode — one vocabulary, two +surfaces. What this gives up knowingly: per-timer controls in the shade for the +timers that are not the subject. Those are one tap away in the app, and the +alternative — a notification group with one entry per timer — multiplies ids, +request codes and orphan-on-kill cases for a rare case. + +`setUsesChronometer(true)` + `setChronometerCountDown(true)` + `setWhen(base)` +means the **system** renders the ticking, so the service posts once per state +change instead of once per second for forty-five minutes. The base is +`timers.ends_at_wall_clock_millis`, which makes it the single wall-clock value +in the whole timer path, with one honest consequence: a user changing the system +clock while a timer runs leaves that stored base — and so the notification's +readout — pointing at the wrong instant, while the timer itself does not move at +all. So `SystemEventReceiver`'s `TIME_SET`/`TIMEZONE_CHANGED` branch calls +`timerEngine.onSystemTimeOrZoneChanged()` — **not to reschedule anything**, but +to re-anchor `ends_at_wall_clock_millis` from the monotonic anchor (§5) and +re-post with a base that is true again. A running row with no wall-clock end (a +corrupt row) falls back to a static readout rather than a chronometer counting +to a wrong instant. + +One channel (`timers`, `IMPORTANCE_HIGH`, sound and vibration off — the service +owns both), one id, and **only the first post of a ring session may alert**. So a +countdown update never heads-ups and never buzzes, an expiry heads-ups exactly +once, and there is no channel gymnastics and no second notification. Every +non-alerting post carries `setSilent(true)` as well as `setOnlyAlertOnce(true)`, +because `setOnlyAlertOnce` speaks only about *updates* to a notification already +on screen — on a high-importance channel the first post of a countdown, and the +placeholder the service goes foreground with, would otherwise pop a banner. The +rule is a pure function (`TimerNotificationState.alertOnce`); the service owns +the flag that spends it and scopes it to the session's window, so it resets both +when the decision goes `Silent` and when a new window opens. + +Every action is a `PendingIntent.getBroadcast` into `TimerActionReceiver`, in +the shape `AlarmActionReceiver` already has: `goAsync()`, `@ApplicationScope`, +one call into the engine, `finish()`. Deliberately **not** +`PendingIntent.getService` (a background foreground-service start can be +refused; a broadcast cannot) and not an activity (a control should not have to +open the app). Nothing is carried across but the timer id, which can be stale — +so **every engine verb is guarded by the state it makes sense for**, a mismatch +is a silent no-op, and the notification is re-posted from the truth afterwards. +Request codes use the `base * 100_000 + (id % 100_000)` stride +`RingNotifications` already uses, and a test asserts the **union** of alarm and +timer codes is distinct, because that collision is how one `PendingIntent` +silently eats another's extras. + +Tapping the notification opens the **Timers tab**, through the smallest honest +deep link: the content intent carries `TimerIntents.ACTION_SHOW_TIMERS`, +`MainActivity` reads it in `onCreate` **and** `onNewIntent` into one +`mutableStateOf`, and `ClockulaShell` consumes it once by replaying +`ShellNavigation.onTabSelected` — the same command a tab tap produces. +`startDestination` stays **Alarms**: it is the `popUpTo` anchor and the root of +M4's asserted back policy, so changing it for a notification tap would rewrite +that policy. The pure part is `ShellNavigation.tabForAction(action)`, which M9 +extends with the rest of the `AlarmClock` contract. + +### Process death and reboot + +- **Process death** costs nothing. The countdown lives in + `ends_at_elapsed_realtime` and the AlarmManager registration survives the + process. `ClockulaApp.onCreate` calls `timerEngine.onBootCompleted()` after + the alarm engine's pass, which sweeps, re-registers and re-syncs the service. + If the service itself was killed, the expiry broadcast starts it again. +- **Reboot** is §5's `RebootRepair`, and M6 gave it a caller that *rings*: + `TimerEngine.onBootCompleted()` calls `repairIfRebooted()` inside its own + lock, then sweeps — so a timer whose end passed during the reboot is expired + by the repair and **sounds when the device comes back**. The boot-id gate + makes the second call of a boot a no-op, so neither engine depends on the + other's ordering. +- **`MY_PACKAGE_REPLACED`** clears AlarmManager, so it calls `resync()`. + +### The tab + +A `LazyColumn` in storage order — `sortOrder`, then `id`, which is exactly what +the repository emits, so the screen re-asserts the order rather than inventing +one. **An expired timer does not jump to the top**: a row that moves under a +thumb is worse than a row the user has to look for, which is the answer M5 gave +for alarms. The pill and the notification pick a subject; the list does not +reorder for it. + +Setting a timer is a keypad, a row of preset chips, the readout and one +**Start** — and the same panel is hosted in a `ModalBottomSheet` over a +non-empty list and *inline* on an empty one, where it **is** the empty state, so +there is no "no timers yet" card to look at and then dismiss. Deliberately not +M5's create-then-navigate: an alarm is live the moment it exists, so creating it +first is honest, while a timer has a natural commit point and a row created by +an accidental FAB tap would be litter. There is still no sentinel id, because +the panel is not bound to a row at all. + +The typed digits and the sheet's open flag live in the **composable** as +`rememberSaveable`, not in a ViewModel, so they survive rotation *and* process +death; the pure `TimerDurationEntry` is derived from the saved digit string, and +`Start` is guarded by a ViewModel re-entrancy flag. `TimerDurationEntry` shifts +digits in from the right and reads them as `h*3600 + m*60 + s` **without +clamping minutes or seconds to 59**, so `0:00:99` is 99 seconds — Google Clock's +forgiving behaviour, and a pure function with obvious tests. `canStart` is false +outside 1 s…24 h, which is `ClockPrefs`' own clamp, so nothing the user can +choose is clamped away on the next read. + +Presets are **app-wide** quick-start durations in DataStore (§6), not a property +of one row: a `timers` row already *is* its own preset, because it persists and +`reset` returns it to its configured duration. The whole management surface is +two gestures on the setup panel — a Save chip when the typed duration is not +already one, and each chip's own remove affordance. A new timer's pre-filled +duration is `ClockDefaults.timerDuration`, the `default_timer_duration_millis` +preference M2 built and nothing had called until now. + +A timer has exactly **one** per-setting override, its ringtone, because `timers` +has exactly one nullable settings column. Vibrate and the (zero) ramp come from +`ClockDefaults`. The picker is M5's, moved to `ui/common/`, so the row inherits +all of its behaviour for free: "App default" naming what it resolves to, Silent +as an explicit sentinel that forces vibration, a SAF-picked file that is still +stored when its grant cannot be made persistent, and an unreadable sound +**reported, never rewritten**. + +The expressive parts are `LinearWavyProgressIndicator` across each card — whose +**amplitude is the state**: the default wave while running, flat while paused or +finished — a kit `GroupedSurface` per card with `positionOf(index, count)`, a +`ButtonGroup` of `FilledTonalButton`s with the press-widening `animateWidth`, +and `tertiaryContainer` for an expired card, which is *exactly* the token the +live pill already uses for an expired timer, so the two surfaces agree. The +readout is `headlineLarge` and the setup panel's entry is `displayMedium` — +plain scale roles, because `ui/theme/Type.kt` says the big-readout ramp gets +settled against a working stopwatch in M7, not guessed at in the scaffolding.