diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c0f313..248df96 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,8 +30,8 @@ Tag sections feed the release notes — see [`docs/RELEASING.md`](docs/RELEASING - floret-kit's `core-di` supplies the `@IoDispatcher` the preference store runs on, so Clockula no longer declares its own dispatcher qualifier. - Clockula's own storage, headless: a Room database with `alarms`, `timers`, - `world_clocks` and `stopwatch_laps`, its version-1 schema exported and - committed so every future migration is reviewable and testable. + `world_clocks` and `stopwatch_laps`, its schema exported and committed at + every version so each migration is reviewable and testable. - Plain-Kotlin alarms, timers, world clocks and stopwatch runs behind four repository interfaces exposing Flows — nothing above the data layer knows Room exists, and a test fails the build if anyone reaches through. A corrupt row @@ -49,3 +49,16 @@ Tag sections feed the release notes — see [`docs/RELEASING.md`](docs/RELEASING since boot. Changing the device's time — forwards or backwards — cannot warp either, and a timer that survives a reboot falls back to its wall-clock estimate and says so instead of vanishing. +- Alarms that ring. A repeat schedule that is re-resolved against the device's + zone every time anything moves, so the clock going forwards, backwards or + through a daylight-saving transition cannot lose one: a 02:30 alarm on a + spring-forward night rings at 03:30 rather than vanishing, and on a fall-back + night rings once rather than twice. +- Skip-next-occurrence that skips exactly one occurrence, snooze with a + per-alarm interval and limit, and a snooze that is still kept after a reboot. +- An alarm that keeps ringing through a reboot or a process kill — the ring is + rebuilt from storage, not from memory — and one that still rings when the + full-screen-intent or notification permission is denied. The chain of + fallbacks ends in vibration, never in silence. +- The post-reboot repair: a running timer no longer counts down from an anchor + the reboot killed, and the stopwatch no longer invents a segment it never ran. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 8640799..725f03d 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,6 +1,6 @@ # Clockula — architecture -How Clockula is built **today**, after M2. Where something does not exist yet, +How Clockula is built **today**, after M3. Where something does not exist yet, this document says so and names the milestone that builds it, rather than describing a plan as though it were code. `PLAN.md` is the "why"; this is the "what, right now". @@ -73,28 +73,36 @@ class of build failures. 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:`). M2 added no Gradle module and touched no -kit module. +`de.jeanlucmakiola.floret:`). M3 added no Gradle module and touched no +kit module — the kit's roadmap keeps schedulers app-local. | Package | Holds | |---|---| | `domain/` | `Alarm.kt`, `Timer.kt`, `WorldClock.kt`, `Stopwatch.kt`, `ClockDefaults.kt` — plain Kotlin, no Android | -| `domain/time/` | `WallClock`, `ElapsedRealtimeClock` | -| `data/db/` | `ClockulaDatabase` — `@Database` v1, `exportSchema = true` | -| `data/alarms/` | `AlarmEntity`, `AlarmDao`, `AlarmMapper`, `AlarmRepository(+Impl)` | +| `domain/alarm/` | the alarm engine's pure half: occurrences, the resolver, the ring state, the volume ramp, the ring policies | +| `domain/time/` | `WallClock`, `ElapsedRealtimeClock`, `ZoneProvider`, `BootId` | +| `data/db/` | `ClockulaDatabase` — `@Database` v2, `exportSchema = true` — and `Migrations` | +| `data/alarms/` | the alarm and ring-state entities, DAOs, mappers and repositories | | `data/timers/` | `TimerEntity`, `TimerDao`, `TimerMapper`, `TimerRepository(+Impl)` | | `data/worldclocks/` | `WorldClockEntity`, `WorldClockDao`, `WorldClockMapper`, `WorldClockRepository(+Impl)` | | `data/stopwatch/` | `LapEntity`, `LapDao`, `LapMapper`, `StopwatchStateStore`, `StopwatchRepository(+Impl)` | | `data/prefs/` | `SettingsPrefs`, `ClockPrefs`, `StopwatchPrefs` | -| `data/time/` | `SystemWallClock`, `SystemElapsedRealtimeClock` | +| `data/time/` | `SystemWallClock`, `SystemElapsedRealtimeClock`, `SystemZoneProvider`, `AndroidBootIdProvider` | | `data/di/` | `DataModule`, `DatabaseModule`, `RepositoryModule`, `TimeModule` | +| `alarm/` | `AlarmEngine` and the four seams it talks to — `AlarmScheduler`, `AlarmCapabilities`, `RingCoordinator`, `AlarmNotifier` — plus `AlarmIntents` | +| `alarm/android/` | the seams' Android implementations: AlarmManager, the capability reads, the service handle, the snoozed notification | +| `alarm/receiver/` | `AlarmFireReceiver`, `AlarmActionReceiver`, `SystemEventReceiver` | +| `alarm/ring/` | the ringing foreground service, its audio player, its vibrator, its notifications | +| `alarm/di/` | `AlarmModule` — `@Binds` for the four seams | +| `system/` | `RebootRepair` — the boot-id gate | | `ui/theme/`, `ui/crash/` | the M0/M1 theme and the crash-report surface | +| `ui/ring/` | `AlarmRingActivity` — M3 ships its window flags and a placeholder; M4 ships the screen | --- ## 4. The data model -`ClockulaDatabase` is at **version 1** with four tables. Every column is a +`ClockulaDatabase` is at **version 2** with five tables. Every column is a primitive: `Long`, `Int`, `String` or `Boolean`. There are **no Room `TypeConverter`s** — enums are stored as `Enum.name` in a `TEXT` column, instants and durations as milliseconds, the repeat set as an `INTEGER` bitmask. @@ -126,6 +134,75 @@ silently failed to reach alarms the user never customised — the opposite of wh collapses the two, and it uses `?:` throughout: a stored `false` for `vibrate` is a choice, not an absent value. +### `alarm_states` + +Volatile ring state, one row per alarm, added at **v2** (M3). + +| Column | Type | Notes | +|---|---|---| +| `alarm_id` | INTEGER pk | also a `FOREIGN KEY … ON DELETE CASCADE` to `alarms(id)` | +| `snoozed_until` | INTEGER? | the absolute instant a snooze is due | +| `snooze_count` | INTEGER | snoozes taken in the current ring cycle | +| `ringing_since` | INTEGER? | non-null exactly while the alarm is ringing | +| `handled_occurrence` | INTEGER? | the occurrence of the most recent ring cycle | +| `skipped_occurrence` | INTEGER? | the occurrence `skip_next_occurrence` is armed on | + +It is a table and not five more columns on `alarms` for three reasons: volatile +ring state must not appear in M10's JSON backup of an alarm, deleting an alarm +must take its ring state with it (hence the cascade), and the alarm mapper's +tests stay about alarms. + +**A missing row is not an error.** `AlarmStateRepository.state(id)` returns +`AlarmRingState.initial(id)` — every instant null, count 0 — and nothing is +written until there is something to write. `upcoming()` therefore resolves every +alarm without creating a single row. + +### The two rules that keep re-resolution honest + +An alarm's next fire time is never stored; it is re-resolved from the local +time-of-day and the current zone on every fire, boot, `TIME_SET`, zone change +and edit (`PLAN.md` §4). Two failure modes fall out of that, and each has +exactly one rule. They are the app's least obvious invariants, and the ones a +future reader will otherwise "simplify" away. + +**The fire-grace window.** Candidates are generated strictly after +`now - AlarmRing.FIRE_GRACE` (2 minutes). A fire delayed by doze, a slow boot or +a `TIME_SET` nudge still counts, and a device powered on at 07:01 still rings +its 07:00 alarm. An alarm missed by hours does *not* ring hours later. + +**The `handled_occurrence` watermark.** Written the moment an alarm fires. A +candidate is suppressed **iff** `candidate <= handledOccurrence` **and** +`candidate <= now`. The second half is load-bearing: without it, a user who set +the clock forward, let an alarm fire, then set it back would have every future +occurrence suppressed forever. With it, only a past-or-present candidate can be +suppressed — the watermark can silence a re-fire, but it can never silence the +future. + +One column, three bugs: "do not ring the same occurrence twice", "a backwards +`TIME_SET` must not re-ring a dismissed alarm" and "a snoozed alarm's natural +occurrence must stay quiet" are the same rule. + +### The skip watermark + +`alarms.skip_next_occurrence` is the user-facing boolean; +`alarm_states.skipped_occurrence` is the concrete instant the skip is armed on. +The flag alone is unusable: resolve at 06:00 on Monday for a daily 07:00 alarm +and you correctly get Tuesday, but resolve again at 08:00 — after the skipped +occurrence has gone by — and a naive "drop the first candidate" gives Wednesday, +so the skip eats a second alarm. With the watermark: + +- flag set, no watermark ⇒ arm it on the first eligible candidate and fire the + one after; +- watermark still in the future ⇒ fire the first candidate after it; write + nothing; +- watermark at or before now ⇒ consumed: clear the flag and the watermark, and + *still* return the first candidate after it, so the skipped occurrence cannot + ring on its way out through the grace window. + +Arming is deferred while a snooze is pending, and a **non-repeating** alarm with +the flag set is *disabled* rather than skipped: a one-shot has exactly one +occurrence, so skipping it is dismissing it in advance. + ### `timers` | Column | Type | Notes | @@ -198,13 +275,20 @@ enum names fall back through `core-prefs`' `toEnum`, blank strings read back as ### The schema is the contract The exported JSON lives at -`app/schemas/de.jeanlucmakiola.clockula.data.db.ClockulaDatabase/1.json`, is -committed, and is also wired in as an `androidTest` asset directory so M3's -`MigrationTestHelper` can open a v1 database on device. `SchemaExportTest` -fails the JVM test run if it goes missing. There is **no +`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 on the -first schema change. There are no migrations yet — v1 is the first version. +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. --- @@ -266,10 +350,25 @@ 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, which is not in this -schema. **M3's `BOOT_COMPLETED` receiver is the authoritative repair:** it runs -at the top of every boot, before the new uptime can climb past any stored -anchor, and rewrites every running timer from `endsAtWallClock`. +Closing it properly needs a persisted boot identifier, and **M3 added one**. +`RebootRepair` runs at the top of every boot — from `SystemEventReceiver`'s +`BOOT_COMPLETED` and again from `ClockulaApp.onCreate`, so a broadcast the +system never delivered is still caught — and rewrites every running timer from +`endsAtWallClock` before the new uptime can climb past any stored anchor. A +timer already past its wall-clock end becomes `EXPIRED`; one with no wall-clock +fallback is paused at what it banked. + +The gate is the boot id, not a flag in memory: `BootId` is +`Settings.Global.BOOT_COUNT` (API 24+, no permission), and two ids that both +have a count are the same boot exactly when the counts match. When either side +has no count — a device that will not give `BOOT_COUNT` up — it falls back to +comparing `wallClock.now() - elapsedRealtime()`, the instant the device booted, +within one minute. That fallback is **best-effort and documented as such**: the +derived instant drifts under NTP correction, which is what the tolerance +absorbs. It costs nine lines and means an unreadable `BOOT_COUNT` degrades to +approximately-right rather than to never noticing a reboot. The id is stored as +one DataStore string, `last_boot_id`, and decoding is strict — anything +malformed reads as "no known previous boot", i.e. "repair". Every timer write is a read-modify-write inside **one transaction** (`TimerDao.updateWithin`): the row is read, the domain rule applied and the @@ -295,14 +394,19 @@ unlike a timer there is nothing to ring. A stale run therefore resolves to displayed to two decimal places. `StopwatchRun.snapshotAt` decides staleness with the same monotonic test as the -timer, and inherits the same blind spot: once the new boot's uptime passes the -stored `startedAtElapsedRealtime`, the reboot goes unnoticed and the run reports +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. So the discarding above is what -happens when the reboot *is* detected, not a guarantee; until a -`BOOT_COMPLETED` receiver pauses the run at boot — the receiver itself arrives -in M3, the stopwatch's own repair in M7 — a stopwatch that spanned a reboot can -show a fabricated segment. +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.** --- @@ -316,6 +420,7 @@ Three slices: | 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` | | Stopwatch run record (M2) | `stopwatch_state`, `stopwatch_started_elapsed_millis`, `stopwatch_accumulated_millis`, `stopwatch_last_lap_cumulative_millis` | `StopwatchPrefs`, behind `StopwatchStateStore` | +| System facts (M3) | `last_boot_id` | `SystemPrefs`, behind `BootStateStore` — the persisted half of the reboot gate | The stopwatch's *run* is a single record, so it lives in DataStore rather than as a one-row table (`PLAN.md` §5); its *laps* are a list, so they live in Room. @@ -348,17 +453,25 @@ before the first frame, and the alternative is an app only "clear data" can fix. ## 7. Dependency injection -Hilt, `SingletonComponent` throughout. Four app modules plus the kit's: +Hilt, `SingletonComponent` throughout. Five app modules plus the kit's: | Module | Provides | |---|---| | `DataModule` | the `DataStore` (built explicitly so its scope runs on the kit's `@IoDispatcher`, with the corruption handler) and `PrefStore` | -| `DatabaseModule` | `ClockulaDatabase` (`@Singleton`) and the four DAOs | -| `RepositoryModule` | `@Binds` for the four repository interfaces | -| `TimeModule` | `@Binds` for `WallClock` and `ElapsedRealtimeClock` | +| `DatabaseModule` | `ClockulaDatabase` (`@Singleton`, built with `.addMigrations(*Migrations.ALL)`) and the five DAOs | +| `RepositoryModule` | `@Binds` for the five repository interfaces | +| `TimeModule` | `@Binds` for `WallClock`, `ElapsedRealtimeClock`, `ZoneProvider` and `BootIdProvider` | +| `AlarmModule` | `@Binds` for the alarm engine's four seams: `AlarmScheduler`, `AlarmCapabilities`, `RingCoordinator`, `AlarmNotifier` | -From floret-kit: `core-di`'s `CoroutinesModule` supplies `@IoDispatcher`, and -`core-prefs` supplies `PrefStore`, `Pref` and the appearance keys. +From floret-kit: `core-di`'s `CoroutinesModule` supplies `@IoDispatcher` and the +process-lifetime `@ApplicationScope` the receivers launch on, and `core-prefs` +supplies `PrefStore`, `Pref` and the appearance keys. + +`BroadcastReceiver.onReceive` is abstract, so a Kotlin subclass cannot call +`super.onReceive(...)` — which is exactly where Hilt injects. The three +receivers therefore extend one concrete no-op base, `HiltBroadcastReceiver`; the +Hilt plugin rewrites each receiver's superclass to its generated `Hilt_…` class +and the `super` call lands on the injecting one. Repositories take **no `CoroutineDispatcher`**. Room already dispatches suspend queries onto its own executor and runs Flow queries off the main thread, so a @@ -369,16 +482,26 @@ half already runs on `@IoDispatcher`, wired once in `DataModule`. ## 8. Testing -JVM-first. 147 unit tests run in the gate; six instrumentation tests compile in -it and run only on a device. +JVM-first. 324 unit tests run in the gate; twelve 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/`. -- **Fakes over `MutableStateFlow`.** The four fake DAOs are backed by a +- **Fakes over `MutableStateFlow`.** The five fake DAOs are backed by a `MutableStateFlow>`, 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. + 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 @@ -388,12 +511,19 @@ it and run only on a device. 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 and the database opening at - version 1. Those six tests are short and obvious by design. + unique indices, real `@Transaction` behaviour, the foreign key cascading, the + v1 → v2 migration validated against the exported schema, and the database + opening at version 2. - **`ArchitectureRulesTest`** greps the main source set for Room leakage above - `data/` and Android imports inside `domain/`. It is the boundary of §2, made - mechanical. + `data/` and Android imports inside `domain/`, `alarm/AlarmEngine.kt` and + `system/`. It is the boundary of §2 made mechanical — and the engine's + testability is a build-gate fact rather than a habit. Stack: JUnit 5 + Truth + Turbine + `kotlinx-coroutines-test`, with `useJUnitPlatform()` and `isReturnDefaultValues = true`. @@ -423,17 +553,51 @@ gitignored `floret-kit/local.properties` pointing at the SDK locally, and 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 but is not used yet. +for M3's DST work; the DST arithmetic itself is `java.time`'s, wrapped in one +function (§11). ### Manifest -No permissions are declared yet, deliberately. Clockula's permission set is the -alarm engine's (`USE_EXACT_ALARM`, `POST_NOTIFICATIONS`, -`RECEIVE_BOOT_COMPLETED`, `USE_FULL_SCREEN_INTENT`, `FOREGROUND_SERVICE*`) and it -arrives in M3 alongside the receivers and the ringing service that need it, so -the manifest never claims a capability the code cannot honour. Components today: -`MainActivity`, the non-exported `CrashReportActivity`, and AppCompat's locale -metadata holder service. +Clockula's permission set is the alarm engine's, and nothing more. Each one is +declared because a specific path would otherwise fail — silently, at 07:00. + +| Permission | Why | +|---|---| +| `USE_EXACT_ALARM` | install-granted from API 33 for an app whose core function is alarms; no dialog, nothing to revoke | +| `SCHEDULE_EXACT_ALARM` `android:maxSdkVersion="32"` | covers 31–32, where it is pre-granted. Bounded at 32 so it never overlaps the one above — that is the documented pattern and it keeps the store listing's story clean. Below 31 an exact alarm needs no permission at all | +| `RECEIVE_BOOT_COMPLETED` | AlarmManager keeps nothing across a reboot | +| `USE_FULL_SCREEN_INTENT` | the ring screen over the lock screen | +| `POST_NOTIFICATIONS` | the ring and snoozed notifications | +| `FOREGROUND_SERVICE`, `FOREGROUND_SERVICE_SYSTEM_EXEMPTED` | the ringing service | +| `WAKE_LOCK` | keep the CPU up for the length of a ring | +| `VIBRATE` | the alarm vibrates, and vibrates alone when no audio source opens | + +Neither of the two revocable ones can silence an alarm. A denied +`POST_NOTIFICATIONS` costs the user the notification and nothing else — the +audio belongs to the foreground service. A denied `USE_FULL_SCREEN_INTENT` +degrades to a `PRIORITY_MAX`, `CATEGORY_ALARM` heads-up notification on the same +HIGH-importance channel, which still rings. M3 declares them; M4 asks for them +and M10's self-check screen explains them. + +The ringing service's `android:foregroundServiceType` is **`systemExempted`**, +which is the case Android's own `fgs-types-required` guidance names: an app +holding `SCHEDULE_EXACT_ALARM` or `USE_EXACT_ALARM` and using a foreground +service to continue alarms in the background. Not `mediaPlayback` — an alarm is +not the user's media session, and it would owe the store a media justification — +and emphatically not `shortService`, which caps at about three minutes against a +ten-minute ring window. + +The three receivers are `android:exported="false"`: a protected system broadcast +is delivered to an unexported receiver anyway (this is how `androidx.work` +declares its own `RescheduleReceiver`), and a `PendingIntent` the app created is +delivered regardless — so nothing else can fake a fire. +`ACTION_LOCKED_BOOT_COMPLETED` is deliberately not handled: it needs +`directBootAware`, and the database and DataStore live in credential-encrypted +storage that is unreadable before the first unlock. + +Components: `MainActivity`, the non-exported `CrashReportActivity` and +`AlarmRingActivity`, the `AlarmRingService`, the three receivers, and AppCompat's +locale metadata holder service. --- @@ -441,19 +605,166 @@ metadata holder service. | Not here | Milestone | |---|---| -| Next-fire resolution, DST arithmetic, `Alarm.nextFireTime` | M3 | -| `AlarmScheduler`, the exact-alarm plumbing, snooze state (schema **v2**, with a tested migration) | M3 | -| Any UI, ViewModel or navigation beyond the theme | M4+ | -| Alarm edit surface, ringtone picker, per-alarm override UI | M5 | -| `BOOT_COMPLETED` / `TIME_SET` receivers — the authoritative repair after a reboot | M3 | -| The ringing foreground service, full-screen intent, notifications | M3 (timers reuse its audio path in M6) | -| Stopwatch presentation, best/worst lap analysis | M7 | +| The ring screen itself — its layout, its motion, its dismiss challenge, its ViewModel. M3 ships the window flags and a plain placeholder | M4 | +| Any other UI, ViewModel or navigation beyond the theme | M4+ | +| Alarm list, edit surface, time picker, ringtone picker, repeat-day selector, per-alarm override UI | M5 | +| The runtime permission *requests* and the exact-alarm / full-screen-intent deep links. `AlarmCapabilities.snapshot()` exists; asking is M4's and explaining is M10's | M4 / M10 | +| 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 | -| Settings screen, JSON backup / SAF export | M10 | +| The self-check screen ("why might my alarm not ring?"), settings screen, JSON backup / SAF export | M10 | +| A user-configurable auto-silence duration, an unlimited-snooze option, per-alarm auto-silence | M10 at the earliest | | Screenshots and store listing polish | M11 | Also absent by design: any seeded default data (no starter alarm, no home world clock on first run), and a "silent" ringtone sentinel — `ringtoneUri == null` means *inherit*, and silent arrives with M5's picker, where the user can actually choose it. + +Four more deliberate gaps the alarm engine leaves open: + +- **No missed-alarm notification or history.** An auto-silenced alarm is missed + silently. Not on the roadmap for v1. +- **No upcoming-alarm notification.** `getNextAlarmClock()` already draws the + status-bar icon, which is the platform's answer. +- **No direct-boot awareness**, so an alarm cannot ring in the window between a + reboot and the first unlock — see §9. +- **No bundled fallback ringtone.** The audio chain ends in forced vibration + (§11), which makes a shipped asset a path a real device cannot reach. + +--- + +## 11. The alarm engine + +The milestone's real deliverable, and the section to read before changing +anything alarm-shaped. + +### The shape + +`AlarmResolver.resolve(alarm, state, now, zone)` is **pure**. It returns the next +fire instant, where it came from, *the ring state as it should now be persisted*, +and two instructions that live outside the state table (`clearSkipFlag`, +`disableAlarm`). It never touches a repository, a clock or +`ZoneId.systemDefault()`, which is why the hard half of this milestone is +callable from a plain JUnit test with hand-built inputs and no fakes at all. + +`AlarmEngine` owns every write the resolver asks for and every call into the +four seams. It is **never driven by a Flow**: resolution writes state, so an +engine that rescheduled on each repository emission would re-trigger itself on +its own writes. `reschedule()` is called explicitly — from `ClockulaApp`, from +the receivers, and from M5's edit surface. `upcoming()` runs the same resolver in +preview mode and *discards* the state it returns; a read must not write, and a +test asserts the store recorded nothing across a full collection. + +Every entry point is a read-modify-write over `alarm_states`, and they arrive +from threads that know nothing about each other — a broadcast on +`Dispatchers.Default`, `ClockulaApp`'s launch-time re-sync, the ring screen — so +they are **serialised on one `Mutex`**. Without it, a cold start *caused by* the +fire broadcast can read the pre-ring state and write it back over `ringingSince` +or the handled-occurrence watermark, which either silences the alarm or lets it +ring twice. The lock is not reentrant, so exactly one layer takes it: the public +entry points. `ClockulaApp` goes through `onBootCompleted()` rather than calling +repair, resume and reschedule one by one, so its whole pass is inside the lock. + +### Precedence, fixed and total + +1. **Disabled** ⇒ clear the snooze, the ring and the skip watermark; keep + `handled_occurrence`, because re-enabling must not un-suppress a dismissal. +2. **Ringing** within `AUTO_SILENCE_AFTER` ⇒ `RESUMED_RING`, nothing scheduled: + it is not an alarm to come, it is one that is happening. A clock moved + *backwards* under a ringing alarm reads as a negative age, which is inside the + window — so it keeps ringing. Beyond the window it is auto-dismissed as + missed and the resolution falls through. +3. **Snoozed** in the future ⇒ the snooze is the next fire, unless the natural + occurrence is earlier (defined so the function is total). Due-but-past by less + than the ring window ⇒ still the snooze, at its own past instant, which + AlarmManager fires at once: a snooze promised before a reboot is kept. Older + than that ⇒ abandoned. +4. **The natural occurrence**, with §4's grace window, watermark and skip rules. + +### The ring cycle + +A cycle opens when the alarm fires and closes on dismiss, on the snooze limit +being reached, or on auto-silence after `AUTO_SILENCE_AFTER` = **10 minutes** +(AOSP DeskClock's default; not user-configurable in v1). Snoozing keeps it open. + +A **non-repeating alarm is disabled when its cycle closes, never when it opens** — +disabling it at the start would clear its own snooze and the alarm would vanish +mid-snooze. + +Closing a cycle only touches the ring service and the auto-silence registration +**when that alarm is the one actually ringing**. Both are single, global slots +(there is one ring service, and stopping it stops whatever it is playing), so +dismissing a merely *snoozed* alarm from its notification must leave a different, +genuinely ringing alarm sounding with its backstop intact. + +**At most one alarm rings, and a newly-firing alarm takes over.** Two ringtones +at once is not a feature, and queueing invents a state machine nobody can debug +at 07:00. The alarm that is displaced keeps its watermark, so it does not come +back the moment the slot is re-resolved. + +`snoozeLimit == 0` means **snoozing is disabled**, not unlimited — v1 offers no +unlimited, and "0 means infinite" is a trap. A refused snooze *dismisses*; it +never leaves the user with a ringing alarm they cannot silence. + +### Two AlarmManager slots, deliberately separate + +| Slot | Registered with | Purpose | +|---|---|---| +| next alarm | `setAlarmClock(AlarmClockInfo(fireAt, showIntent), …)` | the user's next alarm. Doze-exempt, and the only variant that populates `getNextAlarmClock()` | +| auto-silence backstop | `setExactAndAllowWhileIdle` | stops a ring nobody dismissed | + +They are separate because `getNextAlarmClock()` is what draws the status-bar icon +and the lockscreen line: a backstop sharing that slot would overwrite the user's +07:00 with an internal 07:10. Two `PendingIntent`s, two request codes, two +independent cancels — and a test asserts no two request codes collide, because +that collision is exactly how a backstop eats a user's alarm. A **snooze does** +go through the next-alarm slot: a snoozed alarm *is* the next alarm. + +If exact alarms are unavailable the alarm is still registered, with +`setAndAllowWhileIdle`. There is no third branch: late is survivable, silent is +not. + +### DST + +`AlarmOccurrences` walks *local dates* forward and maps each to an instant with +one `ZonedDateTime.of(date, time, zone)` call — the only DST-aware call in the +app. Adding 24 hours to yesterday's fire time is the bug this milestone exists +not to have. + +| Transition | java.time's default resolver | What the user sees | +|---|---|---| +| **Gap** (spring forward) — the local time does not exist | shifts forward by the gap's own length | a 02:30 alarm on a Berlin spring-forward night rings at **03:30** local. It rings; it is never skipped. A 30-minute gap moves it by 30 minutes, not an hour | +| **Overlap** (fall back) — the local time happens twice | takes the **earlier** offset | a 02:30 alarm on a fall-back night rings **once**, at the first 02:30. Never late, never twice | + +These are AOSP DeskClock's behaviours, they are the two answers a user would +defend ("I still got woken", "I only got woken once"), and they come free from +the platform rather than from hand-rolled offset maths. They are locked by tests +that assert exact instants in Europe/Berlin, America/New_York, +Australia/Lord_Howe (a 30-minute gap) and Pacific/Apia (a calendar day that never +existed), not by a comment. + +A snooze, by contrast, is an **absolute instant**: no transition can move it. + +### Never to silence + +The one non-negotiable, and it is a chain of fallbacks rather than a hope: + +1. the alarm's own ringtone URI, else +2. the device's default alarm URI, else +3. `RingtoneManager.getDefaultUri(TYPE_ALARM)`, else +4. **forced vibration** — `RingFallbackPolicy.vibrationRequired` turns vibration + on even for an alarm the user set not to vibrate, because with no audio source + the alternative is silence. + +The audio is the foreground service's, played over `USAGE_ALARM` / +`CONTENT_TYPE_SONIFICATION` so Do Not Disturb's alarm exemption applies, with +`AUDIOFOCUS_GAIN_TRANSIENT` — an alarm interrupts; it does not duck. The volume +ramp is a pure function, `MIN + (1 - MIN) · progress²`, stepped every 200 ms into +`MediaPlayer.setVolume` and **never** into `AudioManager.setStreamVolume`, which +would edit the user's own alarm volume and leave it edited. + +`RingPresentationPolicy` decides how the ring is *presented*, and its +`soundsAnyway` is true in all eight capability combinations — asserted +exhaustively, because that is the invariant the app lives on.