diff --git a/CHANGELOG.md b/CHANGELOG.md index 59b584b..a7e4de5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -168,3 +168,24 @@ Tag sections feed the release notes — see [`docs/RELEASING.md`](docs/RELEASING some people cannot rearrange at all. - 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. +- 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. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index e7ebaeb..c45cddb 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,6 +1,6 @@ # 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 describing a plan as though it were code. `PLAN.md` is the "why"; this is the "what, right now". @@ -48,7 +48,7 @@ through. │ entities │ typed prefs ┌──────────────┴─────────────┐ ┌─────────┴───────────────┐ │ DAOs + mappers (Room) │ │ PrefStore (DataStore) │ - │ ClockulaDatabase v2 │ │ clockula_prefs │ + │ ClockulaDatabase v3 │ │ clockula_prefs │ └──────────────┬─────────────┘ └─────────┬───────────────┘ │ │ 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/time/` | `WallClock`, `ElapsedRealtimeClock`, `ZoneProvider` (`current()` and, since M8, `available()` — the device's tzdata), `BootId`, `Ticker` | | `domain/worldclock/` | the world clock's pure half (M8): `ZoneCatalog` (`ZoneEntry`, the pickable-id filter, the city fallback, the sort), `ZoneSearch` (folding and word-prefix matching), `ZoneComparison` (the offset and day arithmetic, `OffsetLabel`/`DayLabel`, `ZoneOffsetFormat`), `WorldClocks` (`MAX`, the move arithmetic) + `HomeZone`, `AnalogFace` (hand and tick angles) + `DayNight` (the day fraction) | +| `domain/interop/` | the `android.provider.AlarmClock` boundary's pure half (M9): `AlarmClockContract` (the platform's vocabulary as literals — the **only** file in `src/main` that may hold them), `IntentExtras` (a Bundle modelled honestly), `InteropLimits`, `InteropText` (the label and ringtone sanitisers), `InteropDeepLinks`, `AlarmClockRequest`/`AlarmSpec`/`AlarmSearch`/`InteropOutcome`, `AlarmClockRequests` (the validator) and `AlarmMatching` (which alarms a search names) | +| `domain/text/` | `TextFolding` (M9) — NFD-fold, strip combining marks, lowercase through `Locale.ROOT`, `containsFolded`. One copy, shared by the zone search and the label search | | `domain/live/` | `LivePillSelector` and its state — which running thing the live pill is about, as a pure function | | `domain/format/` | `ClockFormat` — `M:SS` / `H:MM:SS`, countdowns rounded up, elapsed truncated — `NextFire` (`NextFireLabel` + `NextFireFormat`), the alarm row's "in 9h 12m" as data, and `StopwatchFormat` — the hundredths readout, split into a major field and two digits so the fraction can be drawn smaller | -| `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/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 | @@ -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/service/` | `StopwatchService` — the `specialUse` foreground service — and `StopwatchNotifications` | | `stopwatch/di/` | `StopwatchModule` — `@Binds` for the one seam | +| `interop/` | the `AlarmClock` boundary's Android half (M9): `AlarmClockActivity` (the one exported, permission-guarded, window-less door), `IntentExtrasReader` (`Bundle` → `IntentExtras`), `AlarmClockHandler` (the orchestrator — **no `android.*` import, ever**), `InteropIntents` (the internal transport to `MainActivity`) and `InteropLaunch` | | `system/` | `RebootRepair` — the boot-id gate | | `ui/theme/`, `ui/crash/` | the M0/M1 theme and the crash-report surface | | `ui/shell/` | the navigation policy (`ShellNavigation`), the adaptive shell, the live pill and its source/ViewModel, the notification-permission ask. `EmptyTabScreen` is **gone**: its KDoc said it existed only until each of M5–M8 replaced its own tab, and the world clock was its last caller | | `ui/common/` | `rememberLocalTimeFormatter` — the app's **one** 12/24-hour formatter, over a `java.time.LocalTime`, with `rememberAlarmTimeFormatter` delegating to it (M8 D17) — and, since M6, the ringtone picker (`RingtonePickerState`, `RingtonePickerScreen`), which both editors need | -| `ui/alarms/` | the real Alarms tab (M5): the routes, the list's pure row builder and its source, the two ViewModels, the list, the editor, the M3 time-picker host, the repeat-day selector and the override pickers (the ringtone picker moved to `ui/common/` in M6) | +| `ui/alarms/` | the real Alarms tab (M5): the routes, the list's pure row builder and its source, the two ViewModels, the list, the editor, the M3 time-picker host, the repeat-day selector and the override pickers (the ringtone picker moved to `ui/common/` in M6), and M9's `DismissChooser`/`DismissAlarmDialog` — the "which alarm should I dismiss?" question | | `ui/timers/` | the real Timers tab (M6): the routes, the pure row builder and its source, the two ViewModels, the list, the row, the setup panel and its keypad, the editor | | `ui/stopwatch/` | the real Stopwatch tab (M7): the pure row builder (`StopwatchRows`) and its source, the ViewModel, the readout panel with its controls, the lap table and its column header, `StopwatchDefaults` | | `ui/worldclock/` | the real World clock tab (M8): the pure row builders (`WorldClockRows`, `ZonePickerRows`) and their state, `WorldClockSource`, the ViewModel, the tab, the analog face, the row and the zone picker, `WorldClockDefaults` | @@ -134,7 +137,7 @@ kit module — the kit's roadmap keeps schedulers app-local. ## 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 `TypeConverter`s** — enums are stored as `Enum.name` in a `TEXT` column, 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 | | `skip_next_occurrence` | INTEGER | | | `ringtone_uri`, `vibrate`, `snooze_minutes`, `snooze_limit`, `volume_ramp_seconds`, `dismiss_challenge` | nullable | **overrides**; `NULL` = inherit `ClockDefaults`. For `ringtone_uri`: `NULL` = inherit, `clockula://silent` = deliberate silence (§13), anything else a content URI | +| `delete_after_use` | INTEGER | v3 (M9): a transient alarm the `AlarmClock` contract asked for. Deleted rather than disabled when its cycle closes (§17) | | `created_at`, `updated_at` | INTEGER | wall-clock epoch millis | Per-alarm settings are nullable *overrides*, never concrete copies of the @@ -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** | | `ringtone_uri` | TEXT? | `NULL` = inherit `ClockDefaults.timerRingtoneUri` | | `sort_order` | INTEGER | indexed | +| `delete_after_use` | INTEGER | v3 (M9): a transient timer the contract asked for. Deleted rather than returned to IDLE when it is dismissed (§17) | | `created_at`, `updated_at` | INTEGER | wall-clock epoch millis | **M6 changes this table by not one column.** No migration, no version bump: the @@ -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 valid bits. -Note for M9: the platform `AlarmClock.EXTRA_DAYS` contract speaks -`java.util.Calendar` constants (Sunday = 1 … Saturday = 7). **That translation -happens at the intent boundary in M9, never in storage.** +The platform `AlarmClock.EXTRA_DAYS` contract speaks `java.util.Calendar` +constants (Sunday = 1 … Saturday = 7). **That translation happens at the intent +boundary and never in storage** — M9 put it in one private function, +`AlarmClockRequests.dayOfWeek`, which hands its `Set` to +`RepeatDays.of` so the 7-bit mask is never built by hand. ### 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 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 @@ -726,8 +745,8 @@ and a TalkBack-shaped accessibility click completing the hold challenge. zones with awkward transitions. - **Instrumentation-only** is the half a fake cannot prove: generated SQL, unique indices, real `@Transaction` behaviour, the foreign key cascading, the - v1 → v2 migration validated against the exported schema, and the database - opening at version 2. + v1 → v2 and v2 → v3 migrations validated against the exported schema, and the + database opening at its current version. - **`ArchitectureRulesTest`** greps the main source set for Room leakage above `data/` and Android imports inside `domain/`, `alarm/AlarmEngine.kt` and `system/`. It is the boundary of §2 made mechanical — and the engine's @@ -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** — 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`, 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 `` element and +one with the `clockula` scheme), the door's recents/history/affinity attributes, +every receiver still unexported, and the requested-permission set unchanged. The +manifest is where this milestone could be silently wrong — none of it fails a +compiler — so it fails here. + +Five more instrumentation tests: our literals really being the platform's own +`android.provider.AlarmClock` constants (the one comparison only a device can +make), each of the seven actions really resolving to the door, a deeplinked +`DISMISS_ALARM` really matching the second filter, the chooser dialog really +composing and a tap really dismissing, and the door really reaching `DESTROYED` +without showing a window — plus two migration cases for v2 → v3. **They have +not been run**: no device is attached to the machine this milestone was built +on. + Stack: JUnit 5 + Truth + Turbine + `kotlinx-coroutines-test`, with -`useJUnitPlatform()` and `isReturnDefaultValues = true`. +`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, rings nothing and posts no notification. -Components: `MainActivity`, the non-exported `CrashReportActivity` and +**M9 adds no `` 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** `` blocks, and this is the detail that +is silently wrong otherwise: a filter with no `` 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 ``. +`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 `TimerService`), five receivers — `AlarmFireReceiver`, `AlarmActionReceiver`, `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 | | 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 | -| 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 | | 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 | @@ -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** — 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 **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 not. +**The `showIntent` was wrong until M9.** It is what the system opens when the +user taps the status-bar alarm icon or the lockscreen's next-alarm line, and it +pointed at the ring screen with `FLAG_ACTIVITY_CLEAR_TASK` — so tapping "my next +alarm" opened a ring screen for an alarm that was **not ringing**, which resolved +to `Finished` and closed itself. It now opens `MainActivity` with +`AlarmIntents.ACTION_SHOW_ALARMS` and `NEW_TASK` only: `MainActivity` is +`singleTop`, so an existing task is brought forward and `onNewIntent` selects the +Alarms tab, which is what "show me my next alarm" means. A build rule fails the +build if `AndroidAlarmScheduler` ever names `AlarmRingActivity` again (§8). + ### DST `AlarmOccurrences` walks *local dates* forward and maps each to an instant with @@ -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 "the thing I am editing does not exist yet". +That decision is what settles a wording in `PLAN.md` §6 that would otherwise be +read too literally. §6 says a malformed `SET_ALARM` intent "opens the editor +pre-filled **rather than writing anything**". An editor that opens on nothing +does not exist in this app, and inventing one for M9 would overturn the rule +above. The sentence's force is **"`SKIP_UI` never means skip validation" — the +user must see the alarm rather than have one written behind their back** — and +that is honoured exactly: an incomplete or malformed `SET_ALARM` creates the row +from the extras that *were* valid, at `AlarmDefaults.nextWholeHour`, enabled, +never transient, and lands the user in its editor with a time picker, a day +selector and a **Delete** under their thumb. Nothing is ever written and hidden, +which is what §6 forbids (§17). + +### "Which alarm?" — the chooser dialog + +The `AlarmClock` contract says a `DISMISS_ALARM` search returning two or more +matches should show the results and let the user pick. M3's canonical component +for "pick one of a short list, or cancel" is a **basic dialog containing a +list**: `AlertDialog` at its defaults — `surfaceContainerHigh`, the 28dp corner, +`headlineSmall` title, tonal elevation 3 — not a full-screen dialog (that is for +a whole task) and not a bottom sheet (this is a question, not a surface). + +Rows are canonical M3 `ListItem`s in a `Column` with `verticalScroll`, each +carrying the same three facts the Alarms row shows — the formatted time through +`rememberAlarmTimeFormatter` (the app's one formatter), the label as supporting +content and the repeat summary as the overline — so the dialog and the list +behind it agree. Each row sets +`ListItemDefaults.colors(containerColor = Color.Transparent)`, because a +`ListItem`'s default container is `surface`, which sits *lower* than the +dialog's own `surfaceContainerHigh` and would draw a visible lighter block per +row. There is **one** action button, Cancel, in `confirmButton`, where M3 puts a +lone action: tapping a row *is* the answer, the same rule M4 applied to the +dismiss challenge. Each row merges its descendants under one content description +naming the time, the label and what tapping does, and the 48dp target comes free +with `ListItem`. + +`DismissChooser.rowsFor(rows, ids)` is pure and filters `AlarmsViewModel`'s +existing `AlarmRowState` list, in **its** order — so there is no second source +and no second ticker — and the answer goes to +`AlarmsViewModel.onDismissUpcoming(id)`, which is `AlarmEngine.dismissUpcoming` +(§11). + ### The row A canonical two-line M3 `ListItem` through the kit's `GroupedRow`: the formatted @@ -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. 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 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 @@ -1581,7 +1758,19 @@ sound. another expired timer remains, the ring continues for the next subject. Each timer is a separate thing the user set, and silently resetting all of them because one was acknowledged destroys the information "which ones finished". - Two timers cost two taps, which is correct — there is no "stop all". + Two timers cost two taps, which is correct — there is still no "stop all" + **in the UI**, and M9 added no button. What M9 did add is + `TimerEngine.dismissAllExpired()`, reachable only from + `ACTION_DISMISS_TIMER` with no data URI: the rule above is about a *thumb* on + a *ring*, where silently resetting both destroys the information "which one + finished", while an app saying "dismiss all expired timers" has stated exactly + which information it is discarding. Its sibling `dismissExpired(id)` answers a + `clockula://timer/{id}` deeplink and resets the timer **only if it reads + EXPIRED**, a silent `false` otherwise — dismissing a running countdown because + a caller referenced it would destroy a measurement. Both route through the + same private reset, so both honour `delete_after_use` (§17), and both take the + engine's lock so the expiry slot, the service and the ring session move with + the state. - **A session is one window.** It opens when the audio *actually starts sounding* and closes when the expired set returns to empty. A second timer expiring mid-session inherits the session's remaining window rather than @@ -1675,8 +1864,43 @@ deep link: the content intent carries `TimerIntents.ACTION_SHOW_TIMERS`, `ShellNavigation.onTabSelected` — the same command a tab tap produces. `startDestination` stays **Alarms**: it is the `popUpTo` anchor and the root of M4's asserted back policy, so changing it for a notification tap would rewrite -that policy. The pure part is `ShellNavigation.tabForAction(action)`, which M9 -extends with the rest of the `AlarmClock` contract. +that policy. + +M9 finished that surface and widened it by one step. `tabForAction` gained +exactly **one** action, `AlarmIntents.ACTION_SHOW_ALARMS`: the platform's own +`AlarmClock` strings never reach `MainActivity` — the exported door translates +them into internal ones — so mapping them here would be dead code that looks +like a contract. And because three of the contract's outcomes are not "select a +tab", the shell's intent parameter grew from `openTab: ClockulaDestination?` to +`openRequest: ShellRequest?`, built by the pure +`ShellNavigation.requestFor(action, alarmId, alarmIds)`: + +| `ShellRequest` | What the shell does | +|---|---| +| `OpenTab(destination)` | the tab command a tab tap produces — unchanged | +| `OpenAlarmEditor(id)` | the Alarms tab command, then `navigate(AlarmRoutes.editor(id))` — the same two steps the FAB takes, so the back stack is the one M5 asserted | +| `ComposeTimer` | the Timers tab command, then the setup panel: the `ModalBottomSheet` over a non-empty list, and nothing extra over an empty one, where the panel is already the empty state | +| `ChooseAlarmToDismiss(ids)` | the Alarms tab command, then the chooser dialog (§13) | + +The launching intent is read **once**: `onCreate` derives a request only when +`savedInstanceState` is null. `setIntent` keeps the intent, so re-deriving it +on every configuration change would replay *stateful* navigation — a rotation +would force the user back into the editor, re-open the timer setup sheet, or +re-ask the dismiss question they had just answered. A genuinely new intent +arrives through `onNewIntent` instead. Every extra is read inside a +`runCatching`, for the same reason `IntentExtrasReader` does: `MainActivity` is +exported, reading any extra unparcels the whole `Bundle`, and a Parcelable whose +class is not in our classloader would otherwise throw inside `onCreate` — a +launch crash that also feeds `CrashReporter.isCrashLoop`. The action itself +never unparcels, so an intent we cannot unpack still selects its tab. + +`requestFor` **degrades rather than throws**: a missing or non-positive alarm id, +or an empty id list, falls back to `OpenTab(ALARMS)`, because an intent that +reaches `MainActivity` malformed must still put the user somewhere sensible. An +unknown action is `null` and is never rescued by its extras. The chooser's +candidate ids live in `ClockulaShell` as a `rememberSaveable { LongArray }`, so +a rotation mid-question does not lose it; a process death does, which is +correct — the intent that asked has been consumed. ### Process death and reboot @@ -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 predict beats a relevance score nobody can. +Since M9 the folding itself lives in `domain/text/TextFolding`, and `ZoneSearch` +delegates to it: `ALARM_SEARCH_MODE_LABEL` has to fold the same way, and a +second copy of NFD-fold-and-lowercase is how "café" starts matching on one +surface and missing on the other. The *matching* is still different on purpose — +the picker is word-prefix, the label search is substring (§17) — +`ZoneSearchTest` passes unmodified, which is the proof the extraction changed no +behaviour. + ### The offset and the day are read at the instant `ZoneComparisons.compare(home, other, at)` reads **both** zones' offsets at `at` @@ -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 device" instead. The row is kept, never rewritten: §4 already decided that, and inventing a time for a zone nobody can resolve would be worse still. + +--- + +## 17. System interop + +`android.provider.AlarmClock` is the contract every other app on the device +drives a clock app through: an assistant, an automation app, a watch companion, +a shell script. M9 answers **all seven** of its actions — `SET_ALARM`, +`SET_TIMER`, `SHOW_ALARMS`, `SHOW_TIMERS`, `DISMISS_ALARM`, `SNOOZE_ALARM` and +`DISMISS_TIMER` — and the section to read before touching anything +intent-shaped. + +### The shape: one pure parser, one Android adapter, one exported door + +``` + another app / Assistant + │ android.intent.action.SET_ALARM + extras + maybe a data URI + ▼ + interop/AlarmClockActivity ← exported, permission-guarded, no window + │ (1) IntentExtrasReader.read(intent) Android → plain Map + │ (2) AlarmClockRequests.parse(...) PURE. the validator. + │ (3) AlarmClockHandler.handle(request) suspend, @ApplicationScope + │ (4) InteropLaunch.intentFor(outcome) PURE. what to show, if anything + ▼ + MainActivity (internal actions only) → ShellNavigation.requestFor → shell +``` + +Everything that *decides* is pure Kotlin under `domain/interop/`. The Android +half is two files that carry no policy. `AlarmClockHandler` is the orchestrator +and contains **no `android.*` import ever**, exactly as the three engines do. + +The **write** never happens in the shell and never happens twice: the door +handles the intent, then hands `MainActivity` an *internal* action that is +idempotent navigation and nothing else. That is why a process death recreating +`MainActivity` with its original intent cannot re-run a `SKIP_UI` write, which +it would if `MainActivity` parsed `SET_ALARM` itself. The handler runs on the +injected `@ApplicationScope` with `async`, and the door `await`s it in +`lifecycleScope` — so the write outlives a task swiped away mid-flight, while +the `startActivity` that follows only happens if the door is still alive. The +`await` is wrapped in `runCatching`: a caller's broken intent must not take the +app down mid-alarm. + +### A `Bundle` modelled honestly + +Hostile input is not "an out-of-range int". It is an `Int` where a `String` was +documented, a `CharSequence` that is not a `String`, a list with a `null` in it, +a key that is present and null. The platform's typed getters hide all of that +behind a default and cannot tell "absent" from "wrong type" — which is precisely +the distinction this milestone is about. So the parser's input is +`IntentExtras`, a value class over `Map` 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.