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:
@@ -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.
|
||||
|
||||
+466
-19
@@ -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<DayOfWeek>` 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<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
|
||||
`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 `<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
|
||||
`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<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.
|
||||
|
||||
Reference in New Issue
Block a user