From a522d68098036f490dfd6e67445a73dc800bbca7 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Fri, 11 Sep 2026 16:05:31 +0200 Subject: [PATCH] docs: the alarm engine, and the blind spot it closes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ARCHITECTURE gains a section 11 on the engine itself — the state machine, the two AlarmManager slots, the DST table, and the chain that keeps a denied permission from turning into a silent morning. Section 5's known blind spot is amended rather than deleted: the boot id exists now, and the stopwatch repair moved from M7 to here. Section 9's "no permissions are declared yet, deliberately" is finally untrue, so it is replaced with the real set and why each one is there. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 17 +- docs/ARCHITECTURE.md | 421 +++++++++++++++++++++++++++++++++++++------ 2 files changed, 381 insertions(+), 57 deletions(-) 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.