docs: the system boundary, and what it refuses

`ARCHITECTURE.md` gains §17: the door, the split between the Android-free
validator and the reader that guards the unparcel, the range rule (clamp what
is happening now, drop what would define a future ring), the candidate table
for "which alarm did you mean", and the three deliberate narrowings — including
why a link we cannot read dismisses nothing rather than everything.

§4 records schema v3 and its migration; §13 records the reading of `PLAN.md` §6
this milestone builds, since a malformed intent lands the user in an editor
over a row created from the valid extras, there being no "new alarm" sentinel.
This commit is contained in:
2026-09-23 11:02:20 +02:00
parent 46f625b6b2
commit 9ab3927efe
2 changed files with 487 additions and 19 deletions
+21
View File
@@ -168,3 +168,24 @@ Tag sections feed the release notes — see [`docs/RELEASING.md`](docs/RELEASING
some people cannot rearrange at all. some people cannot rearrange at all.
- A home time zone you can set by hand or leave following the device, and which - A home time zone you can set by hand or leave following the device, and which
every city's offset and day difference are measured against. every city's offset and day difference are measured against.
- Other apps can now drive Clockula. The whole `android.provider.AlarmClock`
contract is answered — setting an alarm, setting a timer, showing the alarms
or the timers, dismissing an alarm, snoozing one and dismissing a timer — so
an assistant, an automation app or a watch companion can ask for any of it.
- Nothing another app sends can make Clockula write something you did not ask
for: a nonsense hour opens the alarm in the editor instead of setting one at
some other time, an unusable sound falls back to your default instead of
blocking the alarm, and a request that matches two of your alarms asks which
one you meant rather than guessing. A link to an alarm or a timer that cannot
be read dismisses nothing at all, rather than falling back to "all of them".
- An alarm or timer set by voice without showing you a screen removes itself
once it has been dismissed, so talking to your assistant every morning no
longer leaves a list of dead 06:30 alarms behind.
### Changed
- Tapping the alarm icon in the status bar, or the next-alarm line on the lock
screen, now opens your alarms. It used to open the ringing screen for an alarm
that was not ringing, which closed itself again straight away.
- The database moved to version 3, adding one column to the alarms and timers
tables for the alarms and timers another app asked to be temporary. Existing
alarms and timers are unaffected and stay exactly as they were.
+466 -19
View File
@@ -1,6 +1,6 @@
# Clockula — architecture # Clockula — architecture
How Clockula is built **today**, after M8. Where something does not exist yet, How Clockula is built **today**, after M9. Where something does not exist yet,
this document says so and names the milestone that builds it, rather than 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 describing a plan as though it were code. `PLAN.md` is the "why"; this is the
"what, right now". "what, right now".
@@ -48,7 +48,7 @@ through.
│ entities │ typed prefs │ entities │ typed prefs
┌──────────────┴─────────────┐ ┌─────────┴───────────────┐ ┌──────────────┴─────────────┐ ┌─────────┴───────────────┐
│ DAOs + mappers (Room) │ │ PrefStore (DataStore) │ │ DAOs + mappers (Room) │ │ PrefStore (DataStore) │
│ ClockulaDatabase v2 │ │ clockula_prefs │ │ ClockulaDatabase v3 │ │ clockula_prefs │
└──────────────┬─────────────┘ └─────────┬───────────────┘ └──────────────┬─────────────┘ └─────────┬───────────────┘
│ │ │ │
SQLite preferences_pb SQLite preferences_pb
@@ -92,9 +92,11 @@ kit module — the kit's roadmap keeps schedulers app-local.
| `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/timer/` | the timer path's pure half: `TimerReadings` (the app's one timer precedence), `TimerExpiry` (the sweep and the slot's value), `TimerRingPolicy`, `TimerNotificationPolicy`, `TimerPresets`, `TimerDurationEntry`, `TimerSettings`, `TimerRing` |
| `domain/time/` | `WallClock`, `ElapsedRealtimeClock`, `ZoneProvider` (`current()` and, since M8, `available()` — the device's tzdata), `BootId`, `Ticker` | | `domain/time/` | `WallClock`, `ElapsedRealtimeClock`, `ZoneProvider` (`current()` and, since M8, `available()` — the device's tzdata), `BootId`, `Ticker` |
| `domain/worldclock/` | the world clock's pure half (M8): `ZoneCatalog` (`ZoneEntry`, the pickable-id filter, the city fallback, the sort), `ZoneSearch` (folding and word-prefix matching), `ZoneComparison` (the offset and day arithmetic, `OffsetLabel`/`DayLabel`, `ZoneOffsetFormat`), `WorldClocks` (`MAX`, the move arithmetic) + `HomeZone`, `AnalogFace` (hand and tick angles) + `DayNight` (the day fraction) | | `domain/worldclock/` | the world clock's pure half (M8): `ZoneCatalog` (`ZoneEntry`, the pickable-id filter, the city fallback, the sort), `ZoneSearch` (folding and word-prefix matching), `ZoneComparison` (the offset and day arithmetic, `OffsetLabel`/`DayLabel`, `ZoneOffsetFormat`), `WorldClocks` (`MAX`, the move arithmetic) + `HomeZone`, `AnalogFace` (hand and tick angles) + `DayNight` (the day fraction) |
| `domain/interop/` | the `android.provider.AlarmClock` boundary's pure half (M9): `AlarmClockContract` (the platform's vocabulary as literals — the **only** file in `src/main` that may hold them), `IntentExtras` (a Bundle modelled honestly), `InteropLimits`, `InteropText` (the label and ringtone sanitisers), `InteropDeepLinks`, `AlarmClockRequest`/`AlarmSpec`/`AlarmSearch`/`InteropOutcome`, `AlarmClockRequests` (the validator) and `AlarmMatching` (which alarms a search names) |
| `domain/text/` | `TextFolding` (M9) — NFD-fold, strip combining marks, lowercase through `Locale.ROOT`, `containsFolded`. One copy, shared by the zone search and the label search |
| `domain/live/` | `LivePillSelector` and its state — which running thing the live pill is about, as a pure function | | `domain/live/` | `LivePillSelector` and its state — which running thing the live pill is about, as a pure function |
| `domain/format/` | `ClockFormat` — `M:SS` / `H:MM:SS`, countdowns rounded up, elapsed truncated — `NextFire` (`NextFireLabel` + `NextFireFormat`), the alarm row's "in 9h 12m" as data, and `StopwatchFormat` — the hundredths readout, split into a major field and two digits so the fraction can be drawn smaller | | `domain/format/` | `ClockFormat` — `M:SS` / `H:MM:SS`, countdowns rounded up, elapsed truncated — `NextFire` (`NextFireLabel` + `NextFireFormat`), the alarm row's "in 9h 12m" as data, and `StopwatchFormat` — the hundredths readout, split into a major field and two digits so the fraction can be drawn smaller |
| `data/db/` | `ClockulaDatabase` — `@Database` v2, `exportSchema = true` — and `Migrations` | | `data/db/` | `ClockulaDatabase` — `@Database` v3, `exportSchema = true` — and `Migrations` |
| `data/alarms/` | the alarm and ring-state entities, DAOs, mappers and repositories | | `data/alarms/` | the alarm and ring-state entities, DAOs, mappers and repositories |
| `data/timers/` | `TimerEntity`, `TimerDao`, `TimerMapper`, `TimerRepository(+Impl)`, `TimerRingStateStore` (the ring session's one DataStore record) | | `data/timers/` | `TimerEntity`, `TimerDao`, `TimerMapper`, `TimerRepository(+Impl)`, `TimerRingStateStore` (the ring session's one DataStore record) |
| `data/worldclocks/` | `WorldClockEntity`, `WorldClockDao`, `WorldClockMapper`, `WorldClockRepository(+Impl)` — unchanged since M2; M8 is the milestone that finally calls all of it | | `data/worldclocks/` | `WorldClockEntity`, `WorldClockDao`, `WorldClockMapper`, `WorldClockRepository(+Impl)` — unchanged since M2; M8 is the milestone that finally calls all of it |
@@ -120,11 +122,12 @@ kit module — the kit's roadmap keeps schedulers app-local.
| `stopwatch/receiver/` | `StopwatchActionReceiver` (the notification's buttons; no extras, because there is one stopwatch) | | `stopwatch/receiver/` | `StopwatchActionReceiver` (the notification's buttons; no extras, because there is one stopwatch) |
| `stopwatch/service/` | `StopwatchService` — the `specialUse` foreground service — and `StopwatchNotifications` | | `stopwatch/service/` | `StopwatchService` — the `specialUse` foreground service — and `StopwatchNotifications` |
| `stopwatch/di/` | `StopwatchModule` — `@Binds` for the one seam | | `stopwatch/di/` | `StopwatchModule` — `@Binds` for the one seam |
| `interop/` | the `AlarmClock` boundary's Android half (M9): `AlarmClockActivity` (the one exported, permission-guarded, window-less door), `IntentExtrasReader` (`Bundle` → `IntentExtras`), `AlarmClockHandler` (the orchestrator — **no `android.*` import, ever**), `InteropIntents` (the internal transport to `MainActivity`) and `InteropLaunch` |
| `system/` | `RebootRepair` — the boot-id gate | | `system/` | `RebootRepair` — the boot-id gate |
| `ui/theme/`, `ui/crash/` | the M0/M1 theme and the crash-report surface | | `ui/theme/`, `ui/crash/` | the M0/M1 theme and the crash-report surface |
| `ui/shell/` | the navigation policy (`ShellNavigation`), the adaptive shell, the live pill and its source/ViewModel, the notification-permission ask. `EmptyTabScreen` is **gone**: its KDoc said it existed only until each of M5–M8 replaced its own tab, and the world clock was its last caller | | `ui/shell/` | the navigation policy (`ShellNavigation`), the adaptive shell, the live pill and its source/ViewModel, the notification-permission ask. `EmptyTabScreen` is **gone**: its KDoc said it existed only until each of M5–M8 replaced its own tab, and the world clock was its last caller |
| `ui/common/` | `rememberLocalTimeFormatter` — the app's **one** 12/24-hour formatter, over a `java.time.LocalTime`, with `rememberAlarmTimeFormatter` delegating to it (M8 D17) — and, since M6, the ringtone picker (`RingtonePickerState`, `RingtonePickerScreen`), which both editors need | | `ui/common/` | `rememberLocalTimeFormatter` — the app's **one** 12/24-hour formatter, over a `java.time.LocalTime`, with `rememberAlarmTimeFormatter` delegating to it (M8 D17) — and, since M6, the ringtone picker (`RingtonePickerState`, `RingtonePickerScreen`), which both editors need |
| `ui/alarms/` | the real Alarms tab (M5): the routes, the list's pure row builder and its source, the two ViewModels, the list, the editor, the M3 time-picker host, the repeat-day selector and the override pickers (the ringtone picker moved to `ui/common/` in M6) | | `ui/alarms/` | the real Alarms tab (M5): the routes, the list's pure row builder and its source, the two ViewModels, the list, the editor, the M3 time-picker host, the repeat-day selector and the override pickers (the ringtone picker moved to `ui/common/` in M6), and M9's `DismissChooser`/`DismissAlarmDialog` — the "which alarm should I dismiss?" question |
| `ui/timers/` | the real Timers tab (M6): the routes, the pure row builder and its source, the two ViewModels, the list, the row, the setup panel and its keypad, the editor | | `ui/timers/` | the real Timers tab (M6): the routes, the pure row builder and its source, the two ViewModels, the list, the row, the setup panel and its keypad, the editor |
| `ui/stopwatch/` | the real Stopwatch tab (M7): the pure row builder (`StopwatchRows`) and its source, the ViewModel, the readout panel with its controls, the lap table and its column header, `StopwatchDefaults` | | `ui/stopwatch/` | the real Stopwatch tab (M7): the pure row builder (`StopwatchRows`) and its source, the ViewModel, the readout panel with its controls, the lap table and its column header, `StopwatchDefaults` |
| `ui/worldclock/` | the real World clock tab (M8): the pure row builders (`WorldClockRows`, `ZonePickerRows`) and their state, `WorldClockSource`, the ViewModel, the tab, the analog face, the row and the zone picker, `WorldClockDefaults` | | `ui/worldclock/` | the real World clock tab (M8): the pure row builders (`WorldClockRows`, `ZonePickerRows`) and their state, `WorldClockSource`, the ViewModel, the tab, the analog face, the row and the zone picker, `WorldClockDefaults` |
@@ -134,7 +137,7 @@ kit module — the kit's roadmap keeps schedulers app-local.
## 4. The data model ## 4. The data model
`ClockulaDatabase` is at **version 2** with five tables. Every column is a `ClockulaDatabase` is at **version 3** with five tables. Every column is a
primitive: `Long`, `Int`, `String` or `Boolean`. There are **no Room primitive: `Long`, `Int`, `String` or `Boolean`. There are **no Room
`TypeConverter`s** — enums are stored as `Enum.name` in a `TEXT` column, `TypeConverter`s** — enums are stored as `Enum.name` in a `TEXT` column,
instants and durations as milliseconds, the repeat set as an `INTEGER` bitmask. instants and durations as milliseconds, the repeat set as an `INTEGER` bitmask.
@@ -157,6 +160,7 @@ JSON and M10's JSON backup all read the same vocabulary in a diff.
| `repeat_days` | INTEGER | 7-bit mask, see below | | `repeat_days` | INTEGER | 7-bit mask, see below |
| `skip_next_occurrence` | INTEGER | | | `skip_next_occurrence` | INTEGER | |
| `ringtone_uri`, `vibrate`, `snooze_minutes`, `snooze_limit`, `volume_ramp_seconds`, `dismiss_challenge` | nullable | **overrides**; `NULL` = inherit `ClockDefaults`. For `ringtone_uri`: `NULL` = inherit, `clockula://silent` = deliberate silence (§13), anything else a content URI | | `ringtone_uri`, `vibrate`, `snooze_minutes`, `snooze_limit`, `volume_ramp_seconds`, `dismiss_challenge` | nullable | **overrides**; `NULL` = inherit `ClockDefaults`. For `ringtone_uri`: `NULL` = inherit, `clockula://silent` = deliberate silence (§13), anything else a content URI |
| `delete_after_use` | INTEGER | v3 (M9): a transient alarm the `AlarmClock` contract asked for. Deleted rather than disabled when its cycle closes (§17) |
| `created_at`, `updated_at` | INTEGER | wall-clock epoch millis | | `created_at`, `updated_at` | INTEGER | wall-clock epoch millis |
Per-alarm settings are nullable *overrides*, never concrete copies of the Per-alarm settings are nullable *overrides*, never concrete copies of the
@@ -261,6 +265,7 @@ occurrence, so skipping it is dismissing it in advance.
| `ends_at_wall_clock_millis` | INTEGER? | `RUNNING` only — **post-reboot fallback only** | | `ends_at_wall_clock_millis` | INTEGER? | `RUNNING` only — **post-reboot fallback only** |
| `ringtone_uri` | TEXT? | `NULL` = inherit `ClockDefaults.timerRingtoneUri` | | `ringtone_uri` | TEXT? | `NULL` = inherit `ClockDefaults.timerRingtoneUri` |
| `sort_order` | INTEGER | indexed | | `sort_order` | INTEGER | indexed |
| `delete_after_use` | INTEGER | v3 (M9): a transient timer the contract asked for. Deleted rather than returned to IDLE when it is dismissed (§17) |
| `created_at`, `updated_at` | INTEGER | wall-clock epoch millis | | `created_at`, `updated_at` | INTEGER | wall-clock epoch millis |
**M6 changes this table by not one column.** No migration, no version bump: the **M6 changes this table by not one column.** No migration, no version bump: the
@@ -334,9 +339,11 @@ table. `RepeatDays`'s constructor is private and every entry point sanitises, so
a corrupt stored mask (high bits, negative) can only ever *narrow* to the seven a corrupt stored mask (high bits, negative) can only ever *narrow* to the seven
valid bits. valid bits.
Note for M9: the platform `AlarmClock.EXTRA_DAYS` contract speaks The platform `AlarmClock.EXTRA_DAYS` contract speaks `java.util.Calendar`
`java.util.Calendar` constants (Sunday = 1 … Saturday = 7). **That translation constants (Sunday = 1 … Saturday = 7). **That translation happens at the intent
happens at the intent boundary in M9, never in storage.** boundary and never in storage** — M9 put it in one private function,
`AlarmClockRequests.dayOfWeek`, which hands its `Set<DayOfWeek>` to
`RepeatDays.of` so the 7-bit mask is never built by hand.
### Reading is forgiving ### Reading is forgiving
@@ -363,6 +370,18 @@ 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 a migration that fails eats the user's alarms, so it is proved rather than
eyeballed. eyeballed.
**v2 → v3 (M9).** `MIGRATION_2_3` is two `ALTER TABLE … ADD COLUMN
delete_after_use INTEGER NOT NULL DEFAULT 0`, one on `alarms` and one on
`timers`. The column exists because the `AlarmClock` contract says twice that a
`SKIP_UI` alarm or timer "should be removed after it has been dismissed";
without it, a user who talks to their assistant every morning accumulates a list
of dead 06:30 rows (§17). Both entity columns declare
`defaultValue = "0"` — load-bearing, because without it the exported schema
records no default, `runMigrationsAndValidate` compares it against a migrated
table that *has* one, and the migration test fails for a reason nobody enjoys
finding. Every row written before M9 therefore reads as the permanent alarm or
timer it was.
--- ---
## 5. The two clocks ## 5. The two clocks
@@ -726,8 +745,8 @@ and a TalkBack-shaped accessibility click completing the hold challenge.
zones with awkward transitions. zones with awkward transitions.
- **Instrumentation-only** is the half a fake cannot prove: generated SQL, - **Instrumentation-only** is the half a fake cannot prove: generated SQL,
unique indices, real `@Transaction` behaviour, the foreign key cascading, the unique indices, real `@Transaction` behaviour, the foreign key cascading, the
v1 → v2 migration validated against the exported schema, and the database v1 → v2 and v2 → v3 migrations validated against the exported schema, and the
opening at version 2. database opening at its current version.
- **`ArchitectureRulesTest`** greps the main source set for Room leakage above - **`ArchitectureRulesTest`** greps the main source set for Room leakage above
`data/` and Android imports inside `domain/`, `alarm/AlarmEngine.kt` and `data/` and Android imports inside `domain/`, `alarm/AlarmEngine.kt` and
`system/`. It is the boundary of §2 made mechanical — and the engine's `system/`. It is the boundary of §2 made mechanical — and the engine's
@@ -837,8 +856,59 @@ really adding a city, and the **Remove** and **Move up** accessibility actions
really working the way TalkBack would drive them. **They have not been run** — really working the way TalkBack would drive them. **They have not been run** —
no device is attached to the machine this milestone was built on. no device is attached to the machine this milestone was built on.
**M9 kept it for a boundary**, which is the last place a project usually gives
in, because "it is an `Intent`" sounds like it needs a device. It does not. The
door is split so that everything which *decides* is pure Kotlin under
`domain/interop/` — `AlarmClockRequests` (every range, type and sentinel rule),
`AlarmMatching` (which alarms a search names), `IntentExtras`, `InteropText`,
`InteropDeepLinks` — and the Android half is two files that carry no policy:
one turns a `Bundle` into a `Map<String, Any?>`, one turns an outcome into an
`Intent`. `AlarmClockHandler` is the orchestrator and, like the three engines,
contains no `android.*` import, so the whole contract is asserted from a plain
JUnit test with hand-built inputs — including an intent hostile in every extra
at once.
One new harness, and it is deliberately **not** a composition of the existing
ones. `AlarmEngineHarness`, `TimerEngineHarness`, `StopwatchEngineHarness` and
`WorldClockHarness` each build a DataStore over the *same* file name under the
test's `@TempDir`, and DataStore refuses a second instance over a live file — so
composing two of them throws. `InteropHarness` builds **one** `PrefStore`, one
`SettingsPrefs`, the fake DAOs, the fake seams, the **real** `AlarmEngine`, the
**real** `TimerEngine` and the real repositories over them, and exposes the
handler built from those. `FakeAlarmDao` also grew an `onDeleted` hook the
harnesses wire to `alarm_states`, because a fake cannot infer `ON DELETE
CASCADE` from a schema it does not have — and M9 is the first milestone that
deletes an alarm from inside the engine.
Four more `ArchitectureRulesTest` rules: `interop/AlarmClockHandler.kt` joins
the Android-free list; the platform's `AlarmClock` literals appear only in
`domain/interop/AlarmClockContract.kt`, so a second file cannot hardcode
`"android.intent.action.SET_ALARM"` and drift; `alarm/android/AndroidAlarmScheduler.kt`
never names `AlarmRingActivity` in code, which pins the show-intent fix (§11);
and no file under `ui/` uses `AlarmClockHandler`, `AlarmClockRequests` or
`IntentExtras` — the screen parses no intent from another app.
And one new **`ManifestRulesTest`**, which reads `app/src/main/AndroidManifest.xml`
as a file the way `ArchitectureRulesTest` reads sources: exactly two exported
components, the permission on the door and **not** on `MainActivity`, all seven
actions present, both intent-filters present (one without a `<data>` element and
one with the `clockula` scheme), the door's recents/history/affinity attributes,
every receiver still unexported, and the requested-permission set unchanged. The
manifest is where this milestone could be silently wrong — none of it fails a
compiler — so it fails here.
Five more instrumentation tests: our literals really being the platform's own
`android.provider.AlarmClock` constants (the one comparison only a device can
make), each of the seven actions really resolving to the door, a deeplinked
`DISMISS_ALARM` really matching the second filter, the chooser dialog really
composing and a tap really dismissing, and the door really reaching `DESTROYED`
without showing a window — plus two migration cases for v2 → v3. **They have
not been run**: no device is attached to the machine this milestone was built
on.
Stack: JUnit 5 + Truth + Turbine + `kotlinx-coroutines-test`, with Stack: JUnit 5 + Truth + Turbine + `kotlinx-coroutines-test`, with
`useJUnitPlatform()` and `isReturnDefaultValues = true`. `useJUnitPlatform()` and `isReturnDefaultValues = true`. The JVM suite is
**1 436 tests**; 47 instrumentation tests compile in the gate.
--- ---
@@ -927,11 +997,39 @@ storage that is unreadable before the first unlock.
Compose drawing need none: the world clock schedules nothing, wakes nothing, Compose drawing need none: the world clock schedules nothing, wakes nothing,
rings nothing and posts no notification. rings nothing and posts no notification.
Components: `MainActivity`, the non-exported `CrashReportActivity` and **M9 adds no `<uses-permission>` either.** Its one new permission string is
`android:permission` **on the new activity** — a requirement the *caller* must
hold, never one Clockula requests. `com.android.alarm.permission.SET_ALARM` is
what `AlarmClock`'s own documentation asks an implementation to demand; it is
protection-level `normal`, so any app that declares it is granted it at install,
which costs a legitimate caller nothing and keeps a drive-by `startActivity`
from an app that never declared an interest in alarms out.
`interop/AlarmClockActivity` carries the contract's filters rather than
`MainActivity`, because `android:permission` on an activity is checked against
**every** caller, the Launcher included: putting the filters on `MainActivity`
would mean either dropping the permission or making the app unlaunchable.
`MainActivity`'s filter set is still exactly `MAIN`/`LAUNCHER`.
The door declares **two** `<intent-filter>` blocks, and this is the detail that
is silently wrong otherwise: a filter with no `<data>` element matches only
intents whose data is null, and a filter that declares a scheme matches only
intents that *have* data. `DISMISS_ALARM` and `DISMISS_TIMER` may arrive either
way, so filter A carries all seven actions with no data element, and filter B
carries those two plus `<data android:scheme="clockula"/>`.
`android.intent.category.VOICE` is deliberately **not** declared: it advertises
the `VoiceInteractor` follow-on flows, which v1 does not implement (§10). The
activity is `excludeFromRecents`, `noHistory`, `taskAffinity=""` and themed
`Theme.Clockula.Invisible` (translucent, no title) — it has no content view, so
nothing flashes on the way through.
Components: `MainActivity`, the exported and permission-guarded
`interop.AlarmClockActivity`, the non-exported `CrashReportActivity` and
`AlarmRingActivity`, two foreground services (`AlarmRingService` and M6's `AlarmRingActivity`, two foreground services (`AlarmRingService` and M6's
`TimerService`), five receivers — `AlarmFireReceiver`, `AlarmActionReceiver`, `TimerService`), five receivers — `AlarmFireReceiver`, `AlarmActionReceiver`,
`SystemEventReceiver`, `TimerExpiryReceiver`, `TimerActionReceiver` — and `SystemEventReceiver`, `TimerExpiryReceiver`, `TimerActionReceiver` — and
AppCompat's locale metadata holder service. AppCompat's locale metadata holder service. Exactly **two** of them are
exported, and `ManifestRulesTest` fails the build on a third.
--- ---
@@ -942,7 +1040,13 @@ AppCompat's locale metadata holder service.
| 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 | | 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 |
| 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 | | 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 | | 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 |
| The `android.provider.AlarmClock` intent surface and its hostile-extra validation (`TimeOfDay.clamped` and `Zones.normalise` exist so M9 has something to call) | M9 | | Voice-interaction follow-on flows: `Activity.isVoiceInteraction`, `VoiceInteractor.CompleteVoiceRequest` (publishing a deeplink to the alarm just created) and `PickOptionRequest` (disambiguating by voice). `android.intent.category.VOICE` is therefore not declared — advertising it while answering with a touch dialog would be a promise the app does not keep. Clockula *accepts* a `clockula://` deeplink and publishes none | not planned for v1 |
| A "most closely matched" time search for `DISMISS_ALARM`. `ALARM_SEARCH_MODE_TIME` matches **exactly**: dismissing an alarm the caller did not name is a missed alarm, and there is no distance at which that becomes a good trade | not planned for v1 |
| A toast or any other confirmation on a `SKIP_UI` write. `SKIP_UI` means "bypass any intermediate UI", a voice assistant speaks its own confirmation, and the status-bar alarm icon `setAlarmClock` lights is the platform's own receipt (§17) | not planned for v1 |
| Surfacing `delete_after_use` in either editor. It is a property the contract sets, not a setting; an alarm the user edits keeps it, and that consequence is documented (§17) rather than papered over with a checkbox | not planned for v1 |
| Carrying `EXTRA_MESSAGE` into the timer setup panel when no length was given. There is no row to carry it on and the panel has no label field | not planned for v1 |
| Verifying a ringtone URI at the intent boundary. M5 locked "an unreadable sound is reported, never rewritten"; an unmounted card comes back, and the boundary opens no `ContentResolver` | not planned for v1 |
| Exporting `delete_after_use` in a backup. There is no backup yet; M10's JSON schema must include the column | M10 |
| The self-check screen ("why might my alarm not ring?"), 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 | | 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 | | 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 |
@@ -1049,7 +1153,28 @@ being reached, or on auto-silence after `AUTO_SILENCE_AFTER` = **10 minutes**
A **non-repeating alarm is disabled when its cycle closes, never when it opens** — 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 disabling it at the start would clear its own snooze and the alarm would vanish
mid-snooze. mid-snooze. Since M9 there is one branch on top of that: a **transient** alarm
(`delete_after_use`, set only on the contract's `SKIP_UI` create path) is
**deleted** rather than disabled, wherever a permanent one would be disabled —
`persist`'s `disableAlarm` and `closeCycleLocked` both route through one private
`disableOrDeleteLocked`. The `alarm_states` foreign key cascades, so the ring
state goes with the row, and the existing `!isRepeating` guard means an alarm the
user later makes repeating is safe by construction (§17).
The engine's verbs, for a reader looking for the one to call: `reschedule`,
`onFire`, `onAutoSilence`, `snooze(id, minutesOverride = null)`, `dismiss`,
`dismissUpcoming`, `onBootCompleted`, `resumeRingingIfAny`, `ringSession` and
the read-only `upcoming`. M9 added the last of the dismissals and the snooze's
parameter: `dismissUpcoming(id)` is the `AlarmClock` contract's dismissal *and*
the chooser dialog's, so the user's dismissal and an assistant's are the same
code. It closes a ring, or arms a repeating alarm's skip, or disables (or
deletes) a one-shot, and it takes the lock **once** — a caller doing `find` →
`setEnabled` → `reschedule` would race a fire broadcast between the read and the
write, which is the exact bug the lock exists for. `minutesOverride` is a
one-off, clamped to 1..60 and written **nowhere**: the contract says setting
`EXTRA_ALARM_SNOOZE_DURATION` does not change the default snooze duration, and a
test asserts both the stored row and the preferences file are untouched. It buys
no extra snooze either — a refused snooze still dismisses.
Closing a cycle only touches the ring service and the auto-silence registration 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 **when that alarm is the one actually ringing**. Both are single, global slots
@@ -1084,6 +1209,16 @@ If exact alarms are unavailable the alarm is still registered, with
`setAndAllowWhileIdle`. There is no third branch: late is survivable, silent is `setAndAllowWhileIdle`. There is no third branch: late is survivable, silent is
not. not.
**The `showIntent` was wrong until M9.** It is what the system opens when the
user taps the status-bar alarm icon or the lockscreen's next-alarm line, and it
pointed at the ring screen with `FLAG_ACTIVITY_CLEAR_TASK` — so tapping "my next
alarm" opened a ring screen for an alarm that was **not ringing**, which resolved
to `Finished` and closed itself. It now opens `MainActivity` with
`AlarmIntents.ACTION_SHOW_ALARMS` and `NEW_TASK` only: `MainActivity` is
`singleTop`, so an existing task is brought forward and `onNewIntent` selects the
Alarms tab, which is what "show me my next alarm" means. A build rule fails the
build if `AndroidAlarmScheduler` ever names `AlarmRingActivity` again (§8).
### DST ### DST
`AlarmOccurrences` walks *local dates* forward and maps each to an instant with `AlarmOccurrences` walks *local dates* forward and maps each to an instant with
@@ -1299,6 +1434,47 @@ non-repeating, at the next whole hour, every override null — and then navigate
to its id. One route shape, one load path, and no branch in the ViewModel for to its id. One route shape, one load path, and no branch in the ViewModel for
"the thing I am editing does not exist yet". "the thing I am editing does not exist yet".
That decision is what settles a wording in `PLAN.md` §6 that would otherwise be
read too literally. §6 says a malformed `SET_ALARM` intent "opens the editor
pre-filled **rather than writing anything**". An editor that opens on nothing
does not exist in this app, and inventing one for M9 would overturn the rule
above. The sentence's force is **"`SKIP_UI` never means skip validation" — the
user must see the alarm rather than have one written behind their back** — and
that is honoured exactly: an incomplete or malformed `SET_ALARM` creates the row
from the extras that *were* valid, at `AlarmDefaults.nextWholeHour`, enabled,
never transient, and lands the user in its editor with a time picker, a day
selector and a **Delete** under their thumb. Nothing is ever written and hidden,
which is what §6 forbids (§17).
### "Which alarm?" — the chooser dialog
The `AlarmClock` contract says a `DISMISS_ALARM` search returning two or more
matches should show the results and let the user pick. M3's canonical component
for "pick one of a short list, or cancel" is a **basic dialog containing a
list**: `AlertDialog` at its defaults — `surfaceContainerHigh`, the 28dp corner,
`headlineSmall` title, tonal elevation 3 — not a full-screen dialog (that is for
a whole task) and not a bottom sheet (this is a question, not a surface).
Rows are canonical M3 `ListItem`s in a `Column` with `verticalScroll`, each
carrying the same three facts the Alarms row shows — the formatted time through
`rememberAlarmTimeFormatter` (the app's one formatter), the label as supporting
content and the repeat summary as the overline — so the dialog and the list
behind it agree. Each row sets
`ListItemDefaults.colors(containerColor = Color.Transparent)`, because a
`ListItem`'s default container is `surface`, which sits *lower* than the
dialog's own `surfaceContainerHigh` and would draw a visible lighter block per
row. There is **one** action button, Cancel, in `confirmButton`, where M3 puts a
lone action: tapping a row *is* the answer, the same rule M4 applied to the
dismiss challenge. Each row merges its descendants under one content description
naming the time, the label and what tapping does, and the 48dp target comes free
with `ListItem`.
`DismissChooser.rowsFor(rows, ids)` is pure and filters `AlarmsViewModel`'s
existing `AlarmRowState` list, in **its** order — so there is no second source
and no second ticker — and the answer goes to
`AlarmsViewModel.onDismissUpcoming(id)`, which is `AlarmEngine.dismissUpcoming`
(§11).
### The row ### The row
A canonical two-line M3 `ListItem` through the kit's `GroupedRow`: the formatted A canonical two-line M3 `ListItem` through the kit's `GroupedRow`: the formatted
@@ -1462,7 +1638,8 @@ resolution writes state, so an engine that resynced on every repository emission
would re-trigger itself on its own writes. `resync()` is called explicitly. would re-trigger itself on its own writes. `resync()` is called explicitly.
The engine owns the **lifecycle** verbs — `start`, `pause`, `reset`, `addTime`, The engine owns the **lifecycle** verbs — `start`, `pause`, `reset`, `addTime`,
`delete`, plus `onExpiryDue`/`resync`/`onBootCompleted` — because each of them `delete`, M9's `dismissAllExpired`/`dismissExpired`, plus
`onExpiryDue`/`resync`/`onBootCompleted` — because each of them
can move the slot, the service or the ring session, and every one of them is 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 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 the engine and not on a ViewModel-plus-`resync` path deliberately: deleting a
@@ -1581,7 +1758,19 @@ sound.
another expired timer remains, the ring continues for the next subject. Each 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 timer is a separate thing the user set, and silently resetting all of them
because one was acknowledged destroys the information "which ones finished". because one was acknowledged destroys the information "which ones finished".
Two timers cost two taps, which is correct — there is no "stop all". Two timers cost two taps, which is correct — there is still no "stop all"
**in the UI**, and M9 added no button. What M9 did add is
`TimerEngine.dismissAllExpired()`, reachable only from
`ACTION_DISMISS_TIMER` with no data URI: the rule above is about a *thumb* on
a *ring*, where silently resetting both destroys the information "which one
finished", while an app saying "dismiss all expired timers" has stated exactly
which information it is discarding. Its sibling `dismissExpired(id)` answers a
`clockula://timer/{id}` deeplink and resets the timer **only if it reads
EXPIRED**, a silent `false` otherwise — dismissing a running countdown because
a caller referenced it would destroy a measurement. Both route through the
same private reset, so both honour `delete_after_use` (§17), and both take the
engine's lock so the expiry slot, the service and the ring session move with
the state.
- **A session is one window.** It opens when the audio *actually starts - **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 sounding* and closes when the expired set returns to empty. A second timer
expiring mid-session inherits the session's remaining window rather than expiring mid-session inherits the session's remaining window rather than
@@ -1675,8 +1864,43 @@ deep link: the content intent carries `TimerIntents.ACTION_SHOW_TIMERS`,
`ShellNavigation.onTabSelected` — the same command a tab tap produces. `ShellNavigation.onTabSelected` — the same command a tab tap produces.
`startDestination` stays **Alarms**: it is the `popUpTo` anchor and the root of `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 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 that policy.
extends with the rest of the `AlarmClock` contract.
M9 finished that surface and widened it by one step. `tabForAction` gained
exactly **one** action, `AlarmIntents.ACTION_SHOW_ALARMS`: the platform's own
`AlarmClock` strings never reach `MainActivity` — the exported door translates
them into internal ones — so mapping them here would be dead code that looks
like a contract. And because three of the contract's outcomes are not "select a
tab", the shell's intent parameter grew from `openTab: ClockulaDestination?` to
`openRequest: ShellRequest?`, built by the pure
`ShellNavigation.requestFor(action, alarmId, alarmIds)`:
| `ShellRequest` | What the shell does |
|---|---|
| `OpenTab(destination)` | the tab command a tab tap produces — unchanged |
| `OpenAlarmEditor(id)` | the Alarms tab command, then `navigate(AlarmRoutes.editor(id))` — the same two steps the FAB takes, so the back stack is the one M5 asserted |
| `ComposeTimer` | the Timers tab command, then the setup panel: the `ModalBottomSheet` over a non-empty list, and nothing extra over an empty one, where the panel is already the empty state |
| `ChooseAlarmToDismiss(ids)` | the Alarms tab command, then the chooser dialog (§13) |
The launching intent is read **once**: `onCreate` derives a request only when
`savedInstanceState` is null. `setIntent` keeps the intent, so re-deriving it
on every configuration change would replay *stateful* navigation — a rotation
would force the user back into the editor, re-open the timer setup sheet, or
re-ask the dismiss question they had just answered. A genuinely new intent
arrives through `onNewIntent` instead. Every extra is read inside a
`runCatching`, for the same reason `IntentExtrasReader` does: `MainActivity` is
exported, reading any extra unparcels the whole `Bundle`, and a Parcelable whose
class is not in our classloader would otherwise throw inside `onCreate` — a
launch crash that also feeds `CrashReporter.isCrashLoop`. The action itself
never unparcels, so an intent we cannot unpack still selects its tab.
`requestFor` **degrades rather than throws**: a missing or non-positive alarm id,
or an empty id list, falls back to `OpenTab(ALARMS)`, because an intent that
reaches `MainActivity` malformed must still put the user somewhere sensible. An
unknown action is `null` and is never rescued by its extras. The chooser's
candidate ids live in `ClockulaShell` as a `rememberSaveable { LongArray }`, so
a rotation mid-question does not lose it; a process death does, which is
correct — the intent that asked has been consumed.
### Process death and reboot ### Process death and reboot
@@ -2011,6 +2235,14 @@ unrelated hits. Filtering preserves the catalog's own order rather than ranking,
because the catalog is already sorted by city and an alphabet everybody can because the catalog is already sorted by city and an alphabet everybody can
predict beats a relevance score nobody can. predict beats a relevance score nobody can.
Since M9 the folding itself lives in `domain/text/TextFolding`, and `ZoneSearch`
delegates to it: `ALARM_SEARCH_MODE_LABEL` has to fold the same way, and a
second copy of NFD-fold-and-lowercase is how "café" starts matching on one
surface and missing on the other. The *matching* is still different on purpose —
the picker is word-prefix, the label search is substring (§17) —
`ZoneSearchTest` passes unmodified, which is the proof the extraction changed no
behaviour.
### The offset and the day are read at the instant ### The offset and the day are read at the instant
`ZoneComparisons.compare(home, other, at)` reads **both** zones' offsets at `at` `ZoneComparisons.compare(home, other, at)` reads **both** zones' offsets at `at`
@@ -2128,3 +2360,218 @@ and no day** — `WorldClockRowState` carries all three as `null`, non-null
exactly when `known` is true — and says "This time zone is no longer on your exactly when `known` is true — and says "This time zone is no longer on your
device" instead. The row is kept, never rewritten: §4 already decided that, and device" instead. The row is kept, never rewritten: §4 already decided that, and
inventing a time for a zone nobody can resolve would be worse still. inventing a time for a zone nobody can resolve would be worse still.
---
## 17. System interop
`android.provider.AlarmClock` is the contract every other app on the device
drives a clock app through: an assistant, an automation app, a watch companion,
a shell script. M9 answers **all seven** of its actions — `SET_ALARM`,
`SET_TIMER`, `SHOW_ALARMS`, `SHOW_TIMERS`, `DISMISS_ALARM`, `SNOOZE_ALARM` and
`DISMISS_TIMER` — and the section to read before touching anything
intent-shaped.
### The shape: one pure parser, one Android adapter, one exported door
```
another app / Assistant
│ android.intent.action.SET_ALARM + extras + maybe a data URI
▼
interop/AlarmClockActivity ← exported, permission-guarded, no window
│ (1) IntentExtrasReader.read(intent) Android → plain Map
│ (2) AlarmClockRequests.parse(...) PURE. the validator.
│ (3) AlarmClockHandler.handle(request) suspend, @ApplicationScope
│ (4) InteropLaunch.intentFor(outcome) PURE. what to show, if anything
▼
MainActivity (internal actions only) → ShellNavigation.requestFor → shell
```
Everything that *decides* is pure Kotlin under `domain/interop/`. The Android
half is two files that carry no policy. `AlarmClockHandler` is the orchestrator
and contains **no `android.*` import ever**, exactly as the three engines do.
The **write** never happens in the shell and never happens twice: the door
handles the intent, then hands `MainActivity` an *internal* action that is
idempotent navigation and nothing else. That is why a process death recreating
`MainActivity` with its original intent cannot re-run a `SKIP_UI` write, which
it would if `MainActivity` parsed `SET_ALARM` itself. The handler runs on the
injected `@ApplicationScope` with `async`, and the door `await`s it in
`lifecycleScope` — so the write outlives a task swiped away mid-flight, while
the `startActivity` that follows only happens if the door is still alive. The
`await` is wrapped in `runCatching`: a caller's broken intent must not take the
app down mid-alarm.
### A `Bundle` modelled honestly
Hostile input is not "an out-of-range int". It is an `Int` where a `String` was
documented, a `CharSequence` that is not a `String`, a list with a `null` in it,
a key that is present and null. The platform's typed getters hide all of that
behind a default and cannot tell "absent" from "wrong type" — which is precisely
the distinction this milestone is about. So the parser's input is
`IntentExtras`, a value class over `Map<String, Any?>` whose four accessors are
**total**: absent *or* wrong type reads back as `null`. The adapter lifts
exactly the contract's eleven keys out of the `Bundle` with one documented
`@Suppress("DEPRECATION")` on `Bundle.get` — the only API that returns a
heterogeneous value with its type intact — so there is **one** copy of the type
rules, and it is the copy the tests run. `string()` accepts any `CharSequence`
(a `SpannableString` from another app is a legitimate message); `int()` accepts
an `Int` and nothing else, because a caller sending `7L` for `EXTRA_HOUR` is
broken and guessing on its behalf is how an alarm ends up at the wrong hour.
### The range rule
`PLAN.md` §6 says extras are "validated and clamped"; M6 locked the opposite for
timer presets, "dropped, never clamped". Both are right about different things,
and M9 needs one rule:
> **A value that decides when something will ring in the future is dropped when
> it is out of range. A value that modifies an action the user is taking right
> now is clamped.**
| Extra | Range | Out of range |
|---|---|---|
| `EXTRA_HOUR` | 0..23 | **dropped** ⇒ the spec is incomplete ⇒ the editor opens |
| `EXTRA_MINUTES` | 0..59 | **dropped** ⇒ incomplete. Absent ⇒ **0**, the contract's own default |
| `EXTRA_LENGTH` | 1..86 400 s | **dropped** ⇒ the setup panel opens |
| `EXTRA_DAYS` entry | Calendar 1..7 | **dropped**, per entry — but see below |
| `EXTRA_RINGTONE` | a scheme we accept | **dropped** ⇒ inherit the app default |
| `EXTRA_ALARM_SNOOZE_DURATION` | 1..60 min | **clamped** |
Clamping the hour would set an alarm for 23:00 that the caller asked to set for
25:00 — enabled, and ringing at an hour nobody chose. Clamping the snooze is the
opposite case: the alarm is sounding *now*, the user asked for quiet, and
refusing a 1 000-minute snooze by leaving it ringing is strictly worse than
granting a 60-minute one. 1..60 is `ClockPrefs`' own clamp, so a snooze an intent
can ask for is always one the user could have chosen by hand.
`EXTRA_DAYS` has one more rule, and it is the same rule read from the other
side. Entries are dropped one by one, but a list the caller **sent non-empty**
and from which **nothing** survived makes the spec incomplete, so the editor
opens and the user sees it. What counts is what was *sent*, not what parsed:
`[0, 99]` and `["mon", "tue"]` — the second is what
`putStringArrayListExtra` produces — are the same mistake, and treating either
as "the caller asked for a one-shot" turns a repeating alarm into an enabled
single alarm, which is a missed alarm next week. Only an **explicitly empty**
list, an absent one, or a value that is not a list at all is a one-shot.
`IntentExtras.listSize` exists for exactly this: `intList` filters by type, so
it cannot tell an empty list from a list of nothing but rubbish, and here the
two must not share an answer. A list sent as an `IntArray` counts as a list,
because `putExtra(key, intArrayOf(...))` is what a caller writes when it does
not reach for `putIntegerArrayListExtra`.
Two more sanitisers. `EXTRA_MESSAGE` becomes a label: trimmed, stripped of every
character below a space (a newline in a one-line `ListItem` is a layout bug and
a `\u0000` in a `TEXT` column is worse) and **truncated** to 256 characters —
truncated, not dropped, because half a label still names the alarm while half an
hour does not. `EXTRA_RINGTONE` is accepted only if, after trimming, it is at
most 2 048 characters, carries no control character and begins with `content://`
or `android.resource://`; `"silent"` (exact, case-sensitive) maps to
`Ringtones.SILENT_URI`, which forces vibration, so a silent alarm set by another
app is still an alarm. Everything else — `file://`, `http://`, a bare path —
reads back as `null`, which in Clockula means *inherit*, which is exactly the
contract's own documented fallback. It is a drop, not a rejection: an unusable
sound must never stop an alarm from being set.
### What each action does
| Action | Behaviour |
|---|---|
| `SET_ALARM`, valid hour, `SKIP_UI` | create-or-reuse the alarm, enabled, transient; no window |
| `SET_ALARM`, valid hour | the same, not transient, then the Alarms tab |
| `SET_ALARM`, no valid hour | create at `nextWholeHour` carrying every extra that *was* valid, enabled, never transient, then **its editor** (§13) |
| `SET_TIMER`, valid length | create-or-reuse a timer and **start** it — "this action always starts the timer" — with or without the Timers tab |
| `SET_TIMER`, no valid length | write **nothing**; the setup panel |
| `SHOW_ALARMS` / `SHOW_TIMERS` | the tab; no extra is read |
| `DISMISS_ALARM` | see below |
| `SNOOZE_ALARM` | the ringing alarm only, for the clamped minutes; nothing written if nothing is ringing |
| `DISMISS_TIMER` | with no data URI, every expired timer; with one, the timer `clockula://timer/{id}` names — and a URI naming no timer of ours dismisses **nothing** and opens the Timers tab, because a mistyped id must not widen to "all of them", which for a transient timer means deleting them (§14) |
| anything else, or null | `Unsupported`: no write, no window — opening the app because somebody aimed garbage at an exported activity would be a free way to take over the screen |
**Reuse** ("an identical alarm may be re-used") means time, repeat days, label,
ringtone and vibrate all equal the sanitised request — the five fields the
extras can set, so a snooze override the user set by hand does not prevent it.
Ties go to the **lower id**, the same rule the alarm engine's precedence
already uses. A reused alarm is enabled *and* has any pending skip cleared,
because leaving one armed would silently eat the occurrence just asked for, and
it is never made transient, as the contract's own parenthesis says. A timer is
identical only if it is **IDLE** with the same duration and label: restarting
somebody's running countdown is destructive.
### `DISMISS_ALARM`: which alarm, and when to ask
| Source | Candidates |
|---|---|
| data URI `clockula://alarm/{id}` | that alarm, enabled or not |
| `ALARM_SEARCH_MODE_NEXT` | the ringing alarm if there is one, else the next to fire |
| `ALARM_SEARCH_MODE_ALL` | every **enabled** alarm |
| `ALARM_SEARCH_MODE_TIME` | every enabled alarm at exactly that time of day |
| `ALARM_SEARCH_MODE_LABEL` | every enabled alarm whose folded label **contains** the folded phrase |
| neither | every **enabled** alarm |
| a mode whose parameters are unusable | none |
| a data URI that names no alarm of ours | none |
Three deliberate narrowings, each because the alternative is worse. **A data
URI that is present and unreadable is not the same as no URI**: it names none,
rather than falling through to the search mode — or, with no mode given, to the
unspecified search's *every enabled alarm*. Otherwise a caller that named one
alarm and mistyped its id would dismiss alarms it never named, and with exactly
one enabled alarm that happens silently, below the two-match dialog. **Time
matches exactly, never "nearest"**: the contract says "most closely matched", and
dismissing an alarm the caller did not name is a missed alarm, at any distance.
**`EXTRA_IS_PM` is consulted only when the hour is ambiguous**: 13..23 is a
24-hour reading and wins over a contradictory flag, 12 + AM is midnight and 12 +
PM is noon, and a 0..12 hour with the flag *absent* — the case the contract calls
ambiguous and says to ask about — carries **both** readings as candidates, so the
ambiguity only reaches the user when the data cannot settle it. Label search is
substring after folding rather than the zone picker's word-prefix, because "my
gym alarm" must find "Morning gym"; a **blank** phrase matches nothing, since a
needle that matched everything would dismiss every alarm in the app.
When to ask: `ALL` means "all of them", so it dismisses every match and never
asks — taken literally, the contract's "show the results" would make that mode
useless. Every other search with **two or more** matches returns
`ChooseAlarmToDismiss` and writes nothing; with one it dismisses; with none it
opens the Alarms tab and writes nothing. "Dismiss" is
`AlarmEngine.dismissUpcoming` (§11), which is also what the dialog calls.
### `delete_after_use`
The contract, twice: a `SKIP_UI` alarm that is not repeating, and a `SKIP_UI`
timer, "should be removed after it has been dismissed". Without it, "wake me at
6:30" every morning leaves a disabled 06:30 row behind every single time. The
flag is set **only** on the `SKIP_UI` create path — never on reuse, never on the
incomplete/editor path — so only the voice/automation route can produce a
transient row, and it is honoured in two places, both inside an engine and both
under its lock (§11, §14). `upcoming()` stays read-only: collecting it over a
transient alarm deletes nothing.
One consequence, accepted rather than papered over: a user who edits an
assistant-set alarm's label still has a transient alarm, and it still vanishes
after it rings. That is Google Clock's behaviour too, the alarm is still "the one
the assistant set", and the alternative — clearing the flag on any edit — means
the editor has to know about a contract it otherwise never touches.
### Deeplinks
Clockula **accepts** `clockula://alarm/{id}` and `clockula://timer/{id}`, parsed
strictly: exact lowercase scheme and host, one path segment of nothing but
digits, a positive `Long`, no query and no fragment, trimmed first because an
`Intent`'s data may arrive from a shell command line. Anything else is `null`
and falls through to the search mode rather than being guessed at. It
**publishes** none in v1: publishing one requires the
`VoiceInteractor.CompleteVoiceRequest` flow that §10 puts out of scope. Reading
an id from an untrusted caller is safe — dismissing an alarm is reversible, the
caller already holds `SET_ALARM`, and every engine verb is guarded by the state
it makes sense for.
### Next-alarm publishing, and no toast
`setAlarmClock` has drawn the status-bar alarm icon and the lockscreen line since
M3; M9 fixed what tapping them opens (§11). That icon is also why there is **no
toast** on a `SKIP_UI` write, where AOSP DeskClock has one: `SKIP_UI` means
"bypass any intermediate UI", a voice assistant speaks its own confirmation, the
app has shipped nine milestones with no toast anywhere, and formatting "Alarm set
for 7:00" would need a second, non-composable 12/24-hour formatter — the thing
M8 spent a milestone eliminating. The platform's own receipt is better than a
toast and costs nothing.