Any phone-to-watch channel sends data off the phone, which the privacy policy rules out today, so the entry now states that decision and the standalone alternative without speculating about notification bridging. Refs #27
629 lines
39 KiB
Markdown
629 lines
39 KiB
Markdown
# Clockula — roadmap
|
||
|
||
The status view and the work queue. Design rationale for every decision here
|
||
lives in [`PLAN.md`](PLAN.md); the shape of the code as it stands will live in
|
||
[`ARCHITECTURE.md`](ARCHITECTURE.md) from M2 onward.
|
||
|
||
Status legend: ✅ done · 🚧 in progress · ⬜ not started
|
||
|
||
---
|
||
|
||
## Current state (one line)
|
||
|
||
✅ **M10 done.** Clockula now has a settings hub (theme, dynamic colour,
|
||
language, the alarm/timer/clock defaults every new alarm or timer inherits,
|
||
help), a documented, versioned JSON backup (`docs/BACKUP.md`) that exports and
|
||
restores alarms/timers/world clocks and settings through SAF (configuration
|
||
only, never ring state, never a timer's running anchors) as a whole-state
|
||
replace the screen is explicit about before the user confirms, a "why might my
|
||
alarm not ring?" self-check that reads real device state (exact-alarm and
|
||
notification permissions, full-screen intent, battery optimisation, DND, alarm
|
||
volume, a known aggressive-OEM allowlist, and the system's own next alarm
|
||
compared against what Clockula computed) and deep-links to the exact settings
|
||
page for each, and three Glance home-screen widgets (next alarm, timer,
|
||
clock) that mirror existing state and redraw event-driven, on every write,
|
||
with no ticker of their own. M11 (release readiness) is next.
|
||
|
||
---
|
||
|
||
## How the loop works through this
|
||
|
||
One milestone per iteration, auto-continuing. Each iteration:
|
||
|
||
1. Pick the first milestone not marked ✅.
|
||
2. Implement it completely: every checklist item under it.
|
||
3. Build, and run the tests the milestone names. A milestone is not done until
|
||
they pass.
|
||
4. **Commit** (floret-kit submodule first where it changed, then Clockula's
|
||
pointer bump; separate commits, granular, lowercase area prefix).
|
||
5. Mark the milestone ✅ here, update **Current state**, and commit that too.
|
||
6. Continue to the next milestone.
|
||
|
||
Rules that hold across every iteration:
|
||
|
||
- **Never let a Room entity or `@Query` string leak above the data layer**
|
||
(`PLAN.md` §0).
|
||
- **Never** add a foojay toolchain resolver, anywhere, not even in a comment.
|
||
- Commit each finished unit of work; never accumulate a WIP pile.
|
||
- If a milestone turns out to be wrong, say so and amend `PLAN.md`. Do not
|
||
silently build something else.
|
||
- `ARCHITECTURE.md` and `CHANGELOG.md` are updated as part of the milestone that
|
||
changes them, not in a later cleanup pass.
|
||
|
||
---
|
||
|
||
## v1 milestones
|
||
|
||
### ✅ M0 — Skeleton & pipeline
|
||
The project exists, builds, is themed, and ships itself.
|
||
- Gradle project: `settings.gradle.kts`, version catalog copied from Agendula
|
||
(drop `:provider`, add Room + KSP), AGP 9.x conventions, `vcsInfo` off on
|
||
release, no foojay resolver.
|
||
- floret-kit as a git submodule + `includeBuild`, with the gitignored
|
||
`floret-kit/local.properties` arrangement documented.
|
||
- Hilt, Compose, `MainActivity`, `FloretExpressiveTheme` reseeded to `#6B7A5C`
|
||
(`ui/theme/Color.kt` + `Theme.kt`), hand-tuned light/dark schemes.
|
||
- `core-crash` wired; `core-locale` + `res/xml/locales_config.xml`.
|
||
- Codeberg repo, canonical from the first commit. Full pipeline per `PLAN.md` §10:
|
||
`ci.yaml`, `translations.yaml`, `release.yaml` (Agendula spine + Calendula `play`
|
||
job), `renovate.yml`, issue/PR templates, F-Droid metadata, fastlane tree.
|
||
- `README.md`, `LICENSE` (MIT), `CHANGELOG.md`, `.editorconfig`, `.gitattributes`,
|
||
`.gitignore`, `docs/README.md` index.
|
||
- **Done when:** it builds, installs, shows a themed placeholder, and CI is green.
|
||
|
||
✅ `assembleDebug`, `lintDebug` and `testDebugUnitTest` all pass;
|
||
`scripts/check_reproducible_release.sh` reports every invariant holding.
|
||
Two notes for later: the store listing and `fastlane` changelogs are written
|
||
but no screenshots exist yet (M11), and `docs/ARCHITECTURE.md` is still
|
||
unwritten by design. M2 writes it, once there is an architecture to
|
||
describe.
|
||
|
||
### ✅ M1 — floret-kit: `core-prefs` + `core-di`
|
||
Pay the extraction the kit's roadmap has been waiting for a third app to trigger.
|
||
- In the kit: `core-prefs` (`ThemeMode`, `dynamicColor`, typed DataStore wrapper,
|
||
`toEnum()`) and `core-di` (`@IoDispatcher` + provider), each with tests, module
|
||
docs, `CHANGELOG` and `ROADMAP`/`ARCHITECTURE` updates.
|
||
- Clockula consumes both. Migrating Calendula and Agendula is explicitly out of
|
||
scope; note it in the kit's roadmap as a follow-up.
|
||
- **Done when:** the kit's tests pass, Clockula builds against the new modules,
|
||
and both repos are committed (submodule first, then the pointer bump).
|
||
|
||
✅ floret-kit 0.4.0. `core-prefs` (56 kit tests) gives the family a typed
|
||
`Pref<T>`/`PrefStore` over DataStore; reads degrade to the default instead of
|
||
throwing, and `flow()` is distinct-until-changed. The appearance key names are
|
||
frozen at what Calendula and Agendula already write, so the sibling migration
|
||
(a follow-up in the kit's roadmap, not here) needs no data migration.
|
||
`core-di` carries `hilt-compiler` itself, since an `@InstallIn` module only
|
||
reaches a consumer's component if its own library was processed; Clockula's
|
||
`assembleDebug` is the integration test for that binding. Clockula's theme now
|
||
reads the stored preference, with the DataStore built on a corruption handler
|
||
so an unreadable prefs file cannot brick startup.
|
||
|
||
### ✅ M2 — Data layer
|
||
The whole storage stack, headless. No UI.
|
||
- Room: `alarms`, `timers`, `world_clocks`, `stopwatch_laps` (`PLAN.md` §5),
|
||
schema exported and committed, DAOs.
|
||
- Domain models (plain Kotlin) + mappers. Repositories exposing Flows.
|
||
- DataStore prefs (theme, dynamic colour, defaults, stopwatch running state).
|
||
- DI modules.
|
||
- **Tests:** mappers, repository behaviour, the elapsed-realtime vs wall-clock
|
||
distinction under a simulated clock change.
|
||
- Write the first `ARCHITECTURE.md`.
|
||
- **Done when:** the schema is exported and committed, no Room type or query
|
||
string appears above `data/`, and the build, lint and unit tests pass.
|
||
|
||
✅ Four tables (`alarms`, `timers`, `world_clocks`, `stopwatch_laps`), schema
|
||
v1 exported to `app/schemas` so migrations can be tested from the beginning.
|
||
The app talks to four repository interfaces speaking domain types and Flows,
|
||
and `ArchitectureRulesTest` fails the build if Room, an entity or a query
|
||
string ever appears above `data/`. Time is a parameter rather than an ambient
|
||
read: `WallClock` and `ElapsedRealtimeClock` are distinct types, so a running
|
||
timer anchors on the monotonic clock and keeps a wall-clock value only as a
|
||
post-reboot fallback, and the stopwatch keeps none at all. That leaves one
|
||
gap: once a new boot's uptime climbs past a stored anchor the reboot goes
|
||
unnoticed, so closing it needs a persisted boot id and M3's `BOOT_COMPLETED`
|
||
receiver. Every read-modify-write runs inside a DAO transaction, and the
|
||
stopwatch run record is written whole in one DataStore edit, so concurrent
|
||
writers cannot swallow each other's edits or persist a torn record. 151 JVM
|
||
tests over fake DAOs and injected fake clocks; the migration test is the only
|
||
thing needing a device.
|
||
|
||
### ✅ M3 — Alarm engine
|
||
The hard part (`PLAN.md` §4), still headless apart from the ring screen's
|
||
plumbing.
|
||
- Next-fire resolution: local time-of-day + repeat mask → next instant in the
|
||
device zone; `skipNextOccurrence`; snooze.
|
||
- `AlarmScheduler` over `setAlarmClock`, single-next-alarm registration,
|
||
`USE_EXACT_ALARM` with a `SCHEDULE_EXACT_ALARM` fallback path.
|
||
- Receivers: fire, `BOOT_COMPLETED`, `TIME_SET`, `TIMEZONE_CHANGED`,
|
||
`MY_PACKAGE_REPLACED`.
|
||
- Ringing foreground service: audio focus, `USAGE_ALARM`, volume ramp, vibration,
|
||
ringtone URIs; survives process death and reboot mid-ring.
|
||
- Full-screen-intent notification + `canUseFullScreenIntent()` handling, degrading
|
||
to a high-priority heads-up notification that still rings, never to silence.
|
||
- **Tests:** DST spring-forward and fall-back, every repeat configuration, skip,
|
||
snooze-vs-next-occurrence. These are the tests the app lives or dies on.
|
||
|
||
✅ 326 JVM tests, 172 of them new. The engine is a plain Kotlin class that
|
||
reaches Android through four interfaces it does not implement, so the
|
||
behaviour the app lives or dies on is pinned without an emulator.
|
||
`ArchitectureRulesTest` fails the build if `android.*` ever appears in
|
||
`domain/`, `AlarmEngine.kt` or `system/`. Occurrences are generated by walking
|
||
local dates through `ZonedDateTime.of` rather than by adding 24 hours to an
|
||
instant, which is the whole DST story: a 02:30 alarm rings at 03:30 on a
|
||
spring-forward night instead of vanishing, and once rather than twice on a
|
||
fall-back night. Asserted for Berlin and New York and across all 128 repeat
|
||
masks. Two rules keep constant re-resolution honest: a two-minute grace window
|
||
so a late fire still counts, and a `handled_occurrence` watermark that can
|
||
suppress a re-fire but can never silence the future, because suppression also
|
||
requires the candidate to be at-or-before now. Skip needed a watermark of its
|
||
own, since a bare flag eats a second alarm once the skipped occurrence passes.
|
||
At most one alarm rings and a newly-firing one takes over, so closing a cycle
|
||
touches the shared ring and auto-silence slots only when that alarm is the one
|
||
ringing; every write to `alarm_states` runs under one mutex, because a cold
|
||
start triggered by the fire broadcast otherwise races its own reschedule pass.
|
||
The "never to silence" chain is a testable invariant: full-screen intent, else
|
||
a heads-up notification that still rings, else the default ringtone, else
|
||
forced vibration. Schema v2 adds `alarm_states`, and the boot id closes M2's
|
||
blind spot for running timers and the stopwatch, a scope move from M7
|
||
recorded in `ARCHITECTURE.md` §5. Knowingly open: the migration and the other
|
||
12 instrumentation tests compile but have never run on hardware, as no device
|
||
was attached; an auto-silenced alarm is silently missed, with no missed-alarm
|
||
notification until someone asks for one.
|
||
|
||
### ✅ M4 — App shell
|
||
- Navigation host, four tabs, M3 navigation bar, adaptive rail on wide layouts.
|
||
- The **live pill** (`PLAN.md` §9): shared running-state surface, pause + stop
|
||
from any tab.
|
||
- The ring screen UI: over-lockscreen, turn-screen-on, snooze/dismiss, optional
|
||
dismiss challenge that cannot strand the user.
|
||
- Motion from the kit's `identity` module; predictive back.
|
||
|
||
✅ 480 JVM tests, 154 of them new, and still no Robolectric. Keeping it that
|
||
way meant moving everything that decides something out of the composables:
|
||
`ShellNavigation` (what a tab tap does to the back stack, and that back from a
|
||
tab goes to Alarms while back from Alarms leaves the app),
|
||
`LivePillSelector` (which of several running things the pill is about),
|
||
`ClockFormat`, and `ChallengeGate`. The adaptive part is
|
||
`NavigationSuiteScaffold` taking its own default (the short bar on a phone,
|
||
the wide rail on a tablet) rather than a hand-rolled width branch that would
|
||
miss the tabletop posture. The pill shows for paused and expired timers too,
|
||
not only running ones: pausing from the pill must not make the pill disappear
|
||
under the thumb that pressed it. Its value comes from the snapshot, so a timer
|
||
that reaches zero flips to "finished" before M6's expiry write lands. The
|
||
challenge's escape hatch opens on three wrong answers or sixty seconds of
|
||
ringing, for every challenge kind, asserted as one parameterised invariant;
|
||
snooze is never gated; a completed hold *is* the dismissal, and an
|
||
accessibility click completes the hold, because a challenge TalkBack cannot
|
||
answer is exactly the stranding the requirement forbids. floret-kit went to
|
||
0.5.0 for one change: `CollapsingScaffold`'s `onBack` is nullable, so a
|
||
top-level tab draws no back arrow that would pop nothing.
|
||
|
||
Knowingly open: M4 ships no in-app way to *start* a timer or the stopwatch, so
|
||
the live pill cannot be demonstrated by hand until M6/M7. It is proven by its
|
||
unit tests and by an instrumentation test that seeds the repository directly,
|
||
and no debug button was added to paper over it. The nine new instrumentation
|
||
tests compile in the gate but have never run on hardware, as no device was
|
||
attached. `POST_NOTIFICATIONS` is asked for once on first launch; explaining a
|
||
denial, and the exact-alarm and full-screen-intent grant deep links, moved
|
||
wholly to M10's self-check, which is the screen with room to say why.
|
||
|
||
### ✅ M5 — Alarms screen
|
||
List, create, edit, delete, enable/disable. Time picker, repeat-day selector,
|
||
label, ringtone picker, vibrate, snooze settings, per-alarm overrides. Next-fire
|
||
line ("in 9h 12m") on each row. Built on `GroupedSurface`/`GroupedRow`.
|
||
|
||
✅ 624 JVM tests, 144 of them new, and still no Robolectric, kept for a
|
||
whole *form*, which is where a screen usually reaches for one. Everything a
|
||
tap decides is a pure object (`AlarmListRows`, `NextFireFormat`,
|
||
`RepeatDaysSummary`, `AlarmDefaults`, `AlarmOverrides`, `RingtonePicker`,
|
||
`AudioSourcePolicy`, `AlarmRoutes`) or a ViewModel method tested over the
|
||
real `AlarmEngine`; three new `ArchitectureRulesTest` rules make that a
|
||
build fact rather than a habit.
|
||
|
||
The decisions worth knowing: the next-fire line comes from
|
||
`AlarmEngine.upcoming`, which grew a `refresh: Flow<Unit>` cadence parameter
|
||
so the countdown advances without a database write and the screen re-derives
|
||
none of the engine's time arithmetic; the list is ordered by time of day,
|
||
not by next fire, so rows never swap under a thumb at 07:00; every per-alarm
|
||
override is an `OptionPicker` whose first row is "App default (On)", because
|
||
the stored value is ternary and a `Switch` cannot say "I have not chosen";
|
||
silence is an explicit sentinel (`clockula://silent`) that resolves to an
|
||
empty audio-source list and therefore forces vibration, so a "silent" alarm
|
||
is still an alarm; an unreadable sound is reported, never rewritten, because
|
||
an unmounted card comes back; and every editor write is a transactional
|
||
read-modify-write, so a one-shot alarm that disabled itself underneath an open
|
||
editor is not re-enabled by the next unrelated tap.
|
||
|
||
The correctness find: disabling or deleting a ringing alarm used to clear
|
||
`ringingSince` without stopping the ring service (whose auto-silence backstop
|
||
then disarms itself on its own null guard), so the alarm would have sounded
|
||
until the 15-minute wake-lock timeout. Both new ViewModels now dismiss through
|
||
the engine first, conditionally, so a merely snoozed alarm keeps its snooze.
|
||
|
||
floret-kit went to 0.6.0 for one change: `CollapsingScaffold` takes a
|
||
`floatingActionButton` slot, forwarded to the underlying `Scaffold`, rather
|
||
than each app re-deriving the inset maths in a `Box`.
|
||
|
||
Knowingly open: no hand reordering of alarms (the time order answers it, and
|
||
`alarms` has no `sort_order` column), no multi-select or bulk delete, no undo
|
||
chip after a delete (traded for a confirmation that names the alarm), no
|
||
repeat presets, and no per-alarm auto-silence (still M10 at the earliest).
|
||
Editing the app-wide defaults every override row offers to follow is M10's
|
||
settings screen. The seven new instrumentation tests compile in the gate but
|
||
have never run on hardware, as no device was attached.
|
||
|
||
### ✅ M6 — Timers
|
||
Multiple concurrent timers, presets, labels, add/pause/reset/+1min. Foreground
|
||
service with notification controls, expiry ringing reusing M3's audio path.
|
||
Elapsed-realtime anchored.
|
||
|
||
✅ 843 JVM tests, 219 of them new, and still no Robolectric, kept for a
|
||
second screen *and* for a foreground service, which is the other place a
|
||
project usually gives in. Every decision is a pure object (`TimerReadings`,
|
||
`TimerExpiry`, `TimerRingPolicy`, `TimerNotificationPolicy`, `TimerPresets`,
|
||
`TimerDurationEntry`, `TimerListRows`, `TimerRoutes`) or a method on the
|
||
engine or one of the two ViewModels, tested over the real
|
||
`TimerRepositoryImpl` and the real `TimerEngine`. `TimerService` holds no
|
||
policy and no state beyond one flag; even its two `delay` amounts come from
|
||
the engine, so the timings are asserted in a JVM test. Three new
|
||
`ArchitectureRulesTest` rules pin it, one of which makes a *second*
|
||
`MediaPlayer` a build failure. The four new instrumentation tests bring the
|
||
compiled total to 32.
|
||
|
||
The decisions worth knowing: the expiry slot is one registration on the
|
||
`ELAPSED_REALTIME_WAKEUP` base (so a `TIME_SET` cannot warp a running timer,
|
||
and a timer never populates `getNextAlarmClock()`, which belongs to the user's
|
||
next *alarm*), and its fire is an idempotent sweep carrying no id, so a
|
||
late delivery expires everything due, an early one writes nothing, and the
|
||
anchors *are* the watermark; the service adds a second, independent trigger
|
||
for the same sweep, because neither has to be reliable alone; one
|
||
`systemExempted` foreground service covers the countdown and the ring, alive
|
||
on the live pill's own "active, not running" predicate, and the countdown
|
||
holds no wake lock (the alarm slot is what wakes the device); an alarm
|
||
ringing wins the audio and the timer's ring is deferred, because the
|
||
arbitration is a pure function of current state rather than an event, so the
|
||
reverse case is symmetric and free; two timers expiring
|
||
together share one ring session and one ten-minute window, and each is stopped
|
||
on its own, because silently resetting both destroys the information "which
|
||
one finished"; auto-silence stops the noise and leaves the row EXPIRED, which
|
||
is the load-bearing difference from an alarm; presets are app-wide in
|
||
DataStore with out-of-range values dropped, never clamped; and the
|
||
notification's countdown is the platform chronometer, so the service posts
|
||
once per state change rather than once per second for forty-five minutes.
|
||
|
||
Two things the checklist did not name were changed anyway, and are recorded in
|
||
`ARCHITECTURE.md`: "+1 min" on an expired timer now resumes it with
|
||
exactly the extra, in one transaction (M2 left it paused, written before any
|
||
UI existed and pinned by no test; resuming is the only way to avoid
|
||
emitting an intermediate PAUSED frame to the pill and the row); and the live
|
||
pill's timer actions now go through `TimerEngine`, because after M6 there is
|
||
an AlarmManager registration and a service that have to move with the state.
|
||
The pill's contract is otherwise unchanged: M6 *extracted* its precedence into
|
||
`TimerReadings` so the pill, the notification and the ring share one ordering,
|
||
and `LivePillSelectorTest` passing unmodified is the proof.
|
||
|
||
No schema change (the database stays at v2; the ring session's one volatile
|
||
fact is a DataStore record, which is `PLAN.md` §5's own record-versus-rows
|
||
split and keeps it out of M10's backup), no new permission (a timer never
|
||
takes over the screen: no full-screen intent, no ring activity), and no
|
||
floret-kit change: the kit earned `CollapsingScaffold`'s
|
||
`floatingActionButton` slot at 0.6.0 for exactly this shape of tab, and M6
|
||
consumes it.
|
||
|
||
Knowingly open: no hand reordering of timers (`sort_order` and
|
||
`TimerRepository.reorder` stay caller-less, as they do for alarms), no
|
||
per-timer vibrate, volume-ramp or dismiss-challenge overrides (`timers` has
|
||
one nullable settings column and four more would be a schema change), no
|
||
"stop all" when several timers are expired, no undo after a delete (traded for
|
||
a confirmation, as with alarms), no ramp on a timer's ring, no presets
|
||
management screen and no editing of `default_timer_duration_millis` or
|
||
`default_timer_ringtone_uri` (M10's settings screen). `TimerSnapshot.anchorIsStale`
|
||
is still not surfaced in the UI. Of `AlarmClock`'s intent contract only the
|
||
in-process hook `ShellNavigation.tabForAction` exists, wired to one action;
|
||
M9 owns the rest. The four new instrumentation tests compile in the gate but
|
||
have never run on hardware, as no device was attached; M5's seven carry the
|
||
same caveat.
|
||
|
||
Two cases in the new suite are left red. Both are defects in the test, not in
|
||
the code, so they are a design conversation rather than a behaviour gap:
|
||
`TimerIntentsTest` §5.17 #3 asserts `containsNoDuplicates` over a list that
|
||
contains the *same* `requestCodeFor(REQUEST_PAUSE, 1L)` call twice by
|
||
construction, which no deterministic function can satisfy; and `TimerPrefsTest`
|
||
§5.10 #11 opens a second DataStore over a live file in the same scope, which
|
||
DataStore refuses by design, as `SettingsPrefsTest` itself documents.
|
||
|
||
### ✅ M7 — Stopwatch
|
||
Start/stop/reset, laps with splits and cumulative times, best/worst lap emphasis.
|
||
Foreground service so it survives backgrounding; laps persist across process
|
||
death. This is where the big-readout typography gets settled for the whole app.
|
||
The post-reboot repair is not here: M3 took it (`ARCHITECTURE.md` §5), and
|
||
M7 owns only how the paused result is presented.
|
||
|
||
✅ 968 JVM tests, 125 of them new, and still no Robolectric, kept now for
|
||
a third screen and a third service. Everything the tab decides is a pure
|
||
object (`StopwatchFormat`, `StopwatchReadings`, `LapStats`, `StopwatchLaps`,
|
||
`StopwatchNotificationPolicy`, `StopwatchRows`) or a method on the engine or
|
||
the ViewModel, tested over the real `StopwatchRepositoryImpl` across the
|
||
real `appendLap` transaction and a real DataStore under a `@TempDir`.
|
||
`StopwatchService` holds no policy and no state at all (not even the
|
||
timers' one `alreadyAlerted` flag, because nothing here ever alerts), and it
|
||
has no `delay`. Two new `ArchitectureRulesTest` rules and two extended; the
|
||
four new instrumentation tests bring the compiled total to 36.
|
||
|
||
The decisions worth knowing: the foreground service is `specialUse`, not
|
||
`systemExempted`, because a stopwatch is not alarm functionality. It
|
||
schedules nothing, wakes nothing and rings nothing, and claiming the type
|
||
would have the app tell the platform something untrue. It holds no wake
|
||
lock, ever, since the elapsed value is derived from a persisted monotonic
|
||
anchor at read time; the notification is the platform chronometer counting
|
||
up, with its base `now - elapsed` derived at post time and never stored,
|
||
which is how the run record keeps its "no wall-clock field whatsoever" rule;
|
||
its channel is `IMPORTANCE_LOW` and silent, because nothing here ever
|
||
finishes, so there is no alert-once rule and no session window anywhere in the
|
||
path; the readout is hundredths and it is two strings, so the
|
||
fastest-changing glyphs are not the loudest ones; best and worst need three
|
||
laps, ties go to the earliest, and all-equal means neither; laps are capped at
|
||
999, guarded in the engine over one `COUNT(*)` so appending never
|
||
materialises the rows; and the tab ticks at ~60 Hz only while running, so
|
||
a paused or idle stopwatch costs nothing.
|
||
|
||
Three things the checklist did not name were changed anyway, and are recorded
|
||
in `ARCHITECTURE.md`: the live pill's stopwatch actions now go through
|
||
`StopwatchEngine` (M6 recorded this as knowingly open), with the pill's own
|
||
contract unchanged and `LivePillSelectorTest` passing unmodified as the
|
||
proof; `LivePillSelector` was re-pointed at a new pure `StopwatchReadings`,
|
||
the same extraction M6 made for timers, so the pill, the shade and the tab
|
||
cannot answer "what does this read right now" three different ways; and
|
||
`ui/theme/Type.kt` stopped being `Typography()`: tabular figures on the
|
||
display, headline and title roles, app-wide, with no call-site change
|
||
anywhere, plus `ClockulaReadoutDefaults` naming the three roles a readout uses.
|
||
|
||
The correctness find: a run that met a reboot mid-lap had `accumulated = 0`
|
||
while its laps still recorded forty seconds, so the honest reading is
|
||
floored at the last lap's total as well as at zero. A readout sitting
|
||
below a lap the same run printed is not one anyone can believe, and the laps
|
||
are real measurements that are never deleted to tidy the arithmetic. Recorded
|
||
in `ARCHITECTURE.md` §5. The review found the other half of it: the floor has
|
||
to be banked as well as displayed. Resuming writes
|
||
`accumulated = max(accumulated, lastLapCumulative)`, and pausing and lapping
|
||
bank the same floored reading; otherwise the record ticks from below its own
|
||
last lap, the shade's free-running chronometer counts while the tab sits
|
||
frozen, and the next lap records a cumulative below the previous one. The
|
||
same review pass moved the engine's verbs onto the reading's mode rather than
|
||
the stored row's, so a stale record's Resume button resumes instead of hitting
|
||
a silent no-op. `pauseAfterReboot` itself is still untouched.
|
||
|
||
No schema change (the database stays at v2; `LapDao` gains one `COUNT(*)` and
|
||
nothing else), one new permission (`FOREGROUND_SERVICE_SPECIAL_USE`, the
|
||
one that names what the service actually does), and no floret-kit change:
|
||
the kit stays at 0.6.0, untouched.
|
||
|
||
Knowingly open: no lap export, sharing, deletion or editing, and no "previous
|
||
run" history; no Lap button on the live pill (it has two controls by design);
|
||
no user-configurable lap cap or readout precision; no reordering, grouping or
|
||
filtering of laps; no countdown-to-start, split-lap alarm, sound or vibration
|
||
from the stopwatch (a build rule enforces the silence); no analog stopwatch
|
||
face and no `MaterialShapes` showpiece, which `PLAN.md` §8 reserves for the
|
||
world-clock face, the ring screen and timer progress. `StopwatchReading.anchorIsStale`
|
||
is still not surfaced, the same answer M6 gave for timers. Of `AlarmClock`'s
|
||
intent contract only the in-process hook `ShellNavigation.tabForAction` grew a
|
||
second action; M9 owns the rest. The four new instrumentation tests compile in
|
||
the gate but have never run on hardware, as no device was attached; M5's
|
||
seven and M6's four carry the same caveat.
|
||
|
||
### ✅ M8 — World clock
|
||
IANA zone list with search, ICU-localised city and zone display names, offset and
|
||
day-difference relative to home, reorder, home-zone handling. The analog face
|
||
built from `MaterialShapes` / `androidx.graphics.shapes` is the app's one
|
||
deliberate showpiece.
|
||
|
||
✅ 1 139 JVM tests, 171 of them new, and still no Robolectric, kept now
|
||
for a fourth screen. This was the milestone most tempted to break it,
|
||
because ICU and `Canvas` both look like they need a device. They do not:
|
||
everything ICU answers is behind `ZoneNames` (faked, with per-method call
|
||
counters and a `failEverything` flag), everything the face draws is
|
||
`DrawScope` calls over `AnalogFace`'s angles and `DayNight`'s fraction (two
|
||
pure objects asserted at every minute of the day without a pixel), and
|
||
everything the list and the picker decide is `WorldClockRows`,
|
||
`ZonePickerRows`, `ZoneSearch`, `ZoneComparisons`, `ZoneCatalog` and
|
||
`WorldClocks`. Every write is a `WorldClockViewModel` method over the real
|
||
`WorldClockRepositoryImpl` across `FakeWorldClockDao` (so `addIfAbsent` and
|
||
`reorder`'s real transactions run) and a real DataStore under a `@TempDir`.
|
||
Three new `ArchitectureRulesTest` rules; the four new instrumentation tests
|
||
bring the compiled total to 40.
|
||
|
||
The decisions worth knowing: there is no engine and must not be one, because a
|
||
world clock owns no AlarmManager slot, no service and no ring session, so a
|
||
fourth engine would be three empty seams and a `Mutex` guarding nothing; the
|
||
tab reads the wall clock, and re-reads both the instant and the device
|
||
zone on every tick, which is what makes `TIME_SET` and `TIMEZONE_CHANGED`
|
||
free (no new receiver branch); the zone ids stay pure (through a new
|
||
`ZoneProvider.available()`) while only the names cross the ICU seam, which
|
||
is one file, `runCatching`-guarded end to end and pinned there by a build
|
||
rule; the catalog keeps only region-based ids that are their own canonical
|
||
id (so the tzdata's own canonicalisation drops Asia/Calcutta in favour of
|
||
Asia/Kolkata and no table of ours can rot), unless the canonical id is not on
|
||
the device at all, since ICU and tzdata are separately updatable APEX modules
|
||
that can disagree, plus `UTC` by name; search is
|
||
word-prefix over folded text, so "erl" deliberately does not find Berlin;
|
||
the offset and day are computed at the instant from both zones' rules, the
|
||
same discipline `AlarmOccurrences` uses, so Berlin↔Sydney is ten hours in
|
||
January and eight in July; labels are data, not strings, so Kathmandu is
|
||
`Ahead(4, 45)` rather than a signed 285 for a translator to interpret; the
|
||
cadence is 200 ms with the state truncated to the second (face) and the
|
||
minute (rows) and `distinctUntilChanged` over it; the list is capped at
|
||
24, with the cap spelled out on the screen once it is reached, because
|
||
M3's `ExtendedFloatingActionButton` has no `enabled` to dim; and
|
||
reduce-motion snaps the morph but never stops the second hand,
|
||
because a clock whose second hand stops is a broken clock.
|
||
|
||
Three things outside the checklist were changed anyway, and are recorded in
|
||
`ARCHITECTURE.md`: `ZoneProvider` gained `available()`, because
|
||
`ZoneId.getAvailableZoneIds()` at a call site is the same ambient read
|
||
`PLAN.md` §5 bans for `now()`; `ui/common/AlarmTimeFormatter.kt` gained
|
||
`rememberLocalTimeFormatter` with the existing function delegating to it, so
|
||
M5's "one 12/24-hour formatter" survives a fourth caller instead of growing a
|
||
second answer; and `ShellDefaults.EmptyTabScreen` was deleted, its own
|
||
KDoc having said it existed only until each of M5–M8 replaced its own tab;
|
||
the world clock was its last caller.
|
||
|
||
No schema change at all (the database stays at v2; `WorldClockEntity`, its
|
||
DAO, its mapper and its repository are not edited), no new permission and no
|
||
manifest entry, one new dependency coordinate
|
||
(`androidx.graphics:graphics-shapes:1.0.1`, pinned to the version that already
|
||
resolved transitively, because M8 imports that package directly), and no
|
||
floret-kit change: the kit stays at 0.6.0, untouched, with
|
||
`ReorderableColumn`, `GroupedSurface`, `FullScreenPicker`, `InlineTextField`
|
||
and `CollapsingScaffold`'s FAB slot already covering the screen.
|
||
|
||
Knowingly open: no rename UI for `world_clocks.label` (the column is honoured
|
||
on read and M10's backup import will carry labels); no automatic "home" row
|
||
while travelling (a separate feature with its own preference, and the hero
|
||
face already follows the device); no per-city detail screen, map,
|
||
sunrise/sunset or day-length bar (`DayNight` is a stated convention for a
|
||
shape morph, not an ephemeris); no live times or long zone names in the picker;
|
||
no seeded data, so a fresh install really has an empty list; no reordering by
|
||
anything but `sort_order`; and no `MaterialShapes` on the ring screen or timer
|
||
progress, which `PLAN.md` §8 also sanctions; the build rule makes widening
|
||
that allowlist a deliberate edit. The four new instrumentation tests compile
|
||
in the gate but have never run on hardware, as no device was attached;
|
||
M5's seven, M6's four and M7's four carry the same caveat.
|
||
|
||
### ✅ M9 — System interop
|
||
The full `android.provider.AlarmClock` contract (`PLAN.md` §6): `SET_ALARM`,
|
||
`SET_TIMER`, `SHOW_ALARMS`, `SHOW_TIMERS`, `DISMISS_ALARM`, `SNOOZE_ALARM`, plus
|
||
next-alarm publishing. Hostile-input validation at the intent boundary.
|
||
- **Tests:** extra validation, including malformed and out-of-range input.
|
||
|
||
✅ 1 420 JVM tests, 281 of them new, and still no Robolectric, kept now
|
||
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 (`AlarmClockRequests`,
|
||
`AlarmMatching`, `IntentExtras`, `InteropText`, `InteropDeepLinks`) and the
|
||
Android half is two policy-free files: a `Bundle` reader and an `Intent`
|
||
builder. `AlarmClockHandler` orchestrates with no `android.*` import, as
|
||
all three engines do, and is tested over both real engines through a new
|
||
`InteropHarness`. It is deliberately not a composition of the existing
|
||
harnesses, since each builds a DataStore over the same file name and DataStore
|
||
refuses a second instance over a live one. Four new `ArchitectureRulesTest`
|
||
rules and a new `ManifestRulesTest`, because the manifest is where this
|
||
milestone could be silently wrong and none of it fails a compiler. The five
|
||
new instrumentation tests bring the compiled total to 45.
|
||
|
||
`ACTION_DISMISS_TIMER`, the seventh action, named neither in this checklist
|
||
nor in `PLAN.md` §6's table, was built anyway: "the full contract" is the
|
||
milestone's own first sentence, and shipping six of seven and calling it full
|
||
would be the kind of quiet gap this loop exists to avoid.
|
||
|
||
The decisions worth knowing: one range rule settles every extra. A value
|
||
that decides when something will ring in the *future* is dropped out of
|
||
range, and a value that modifies what the user is doing *now* is clamped, so
|
||
hour 25 never becomes an enabled 23:00 alarm while a 1 000-minute snooze
|
||
becomes the 60 minutes `ClockPrefs` would have allowed; a `Bundle` is
|
||
modelled honestly as `Map<String, Any?>` with four *total* accessors,
|
||
because hostile input is an `Int` where a `String` was documented far more
|
||
often than it is an out-of-range number, and the platform's typed getters
|
||
cannot tell "absent" from "wrong type"; `SKIP_UI` never means skip
|
||
validation, so an incomplete `SET_ALARM` creates the row from the extras that
|
||
*were* valid, at the next whole hour, and lands the user in its editor, since
|
||
M5 locked that the editor edits a persisted row and nothing is ever written
|
||
and hidden; `ALARM_SEARCH_MODE_TIME` matches exactly, never "nearest", and
|
||
an ambiguous 0..12 hour with no `IS_PM` carries *both* readings as candidates
|
||
so the question only reaches the user when the data cannot settle it;
|
||
`ALARM_SEARCH_MODE_ALL` never asks (taken literally, the contract's "show
|
||
the results" would make the mode useless), while every other search with two
|
||
or more matches opens the milestone's one new surface, an M3 basic dialog with
|
||
a `ListItem` list where tapping a row *is* the answer.
|
||
|
||
A correctness find, fixed here: the `AlarmClockInfo` `showIntent` (the
|
||
thing the system opens when the user taps the status-bar alarm icon or the
|
||
lockscreen's next-alarm line) pointed at the *ring screen* with
|
||
`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` on the Alarms tab, and a build rule fails the build if
|
||
`AndroidAlarmScheduler` ever names `AlarmRingActivity` again. That intent is
|
||
what "next-alarm publishing" means to a user.
|
||
|
||
The schema went to v3: `delete_after_use` on `alarms` and on `timers`, two
|
||
`ALTER TABLE`s in `MIGRATION_2_3`, `3.json` exported and committed, `1.json`
|
||
and `2.json` untouched. The contract says in two places that a `SKIP_UI`
|
||
alarm or timer should be removed once dismissed, and without the column a
|
||
daily "wake me at 6:30" turns the Alarms tab into a graveyard.
|
||
The flag is set only on the `SKIP_UI` create path and honoured inside the two
|
||
engines, under their locks.
|
||
|
||
Four things outside the checklist were changed anyway, and are recorded in
|
||
`ARCHITECTURE.md`: `AlarmEngine` gained `dismissUpcoming(id)` and a
|
||
`minutesOverride` on `snooze`, because both move the ring slot, the
|
||
auto-silence backstop and the next-alarm registration, and a handler calling
|
||
three repositories in a row would race a fire broadcast; `TimerEngine` gained
|
||
`dismissAllExpired()` and `dismissExpired(id)` (the first is the "stop all"
|
||
the UI still deliberately does not offer, because that rule was about a thumb
|
||
on a ring and a programmatic request has stated exactly what it is discarding);
|
||
`domain/text/TextFolding` was extracted and `ZoneSearch` delegates to it,
|
||
with `ZoneSearchTest` passing unmodified as the proof; and the shell's
|
||
`openTab` became `openRequest: ShellRequest?`, since three of the contract's
|
||
outcomes are not "select a tab".
|
||
|
||
No new `<uses-permission>`: the one new permission string is
|
||
`android:permission` *on the door*, a requirement on the caller. No new
|
||
dependency coordinate and no floret-kit change; the kit stays at 0.6.0,
|
||
untouched, because the one new surface is material3's own `AlertDialog`.
|
||
|
||
Knowingly open: no voice-interaction follow-on flows
|
||
(`VoiceInteractor.CompleteVoiceRequest`/`PickOptionRequest`), so
|
||
`android.intent.category.VOICE` is deliberately not declared and Clockula
|
||
accepts a `clockula://` deeplink while publishing none; no "most closely
|
||
matched" time search; no toast on a `SKIP_UI` write (the status-bar icon is
|
||
the platform's own receipt); no `delete_after_use` surface in either editor, so
|
||
an assistant-set alarm the user edits stays transient, as it does in Google
|
||
Clock; no `EXTRA_MESSAGE` carried into the timer setup panel when no length
|
||
was given; and `AlarmScheduler.systemNextAlarm()` is still caller-less, since it
|
||
belongs to M10's self-check screen. The five new instrumentation tests and the
|
||
two new migration cases compile in the gate but have never run on
|
||
hardware, as no device was attached; M5's seven, M6's four, M7's four and
|
||
M8's four carry the same caveat.
|
||
|
||
### ✅ M10 — Settings, backup, the self-check, and widgets
|
||
- Settings composed from kit components: theme, dynamic colour, language,
|
||
alarm/timer/clock defaults, about + crash reporting.
|
||
- JSON backup export/import via SAF (`PLAN.md` §7), schema documented in
|
||
`docs/BACKUP.md`.
|
||
- The "why might my alarm not ring?" self-check screen (`PLAN.md` §4):
|
||
real device state, deep links to the exact settings pages.
|
||
- Three Glance home-screen widgets: next alarm, timer, clock (`PLAN.md` §9).
|
||
- **Tests:** backup round-trip and unknown-field tolerance; self-check rule
|
||
logic per permission/state combination; widget model selection/formatting.
|
||
|
||
### ⬜ M11 — Release readiness
|
||
Translations wired to Weblate, fastlane metadata and screenshots, F-Droid
|
||
reproducibility guard verified against the submodule, instrumentation smoke
|
||
tests, accessibility pass (TalkBack on the ring screen especially), on-device
|
||
reliability soak across a few real nights. Then 1.0.0.
|
||
|
||
---
|
||
|
||
## Post-v1
|
||
|
||
Deferred deliberately, in rough priority order:
|
||
|
||
- **Dock / screensaver mode**: a `DreamService` full-screen clock. Cheapest big
|
||
win, and the surface people look at most.
|
||
- **Quick Settings tile**: timer control and next alarm from the shade.
|
||
- **Bedtime / sleep schedule**: wind-down and wake, with DND handoff. A feature
|
||
in its own right, realistically v2.
|
||
- **`core-notification` in floret-kit**: revisit once Clockula's real
|
||
notification surface is known (`PLAN.md` §3).
|
||
- **Migrate Calendula and Agendula onto `core-prefs` + `core-di`**: tracked in
|
||
the kit's roadmap, not here.
|
||
- **Wear OS companion** ([#27](https://codeberg.org/jlmakiola/clockula/issues/27)):
|
||
unscoped. A watch that shows and controls the phone's timers and next alarm
|
||
needs a phone-to-watch channel, and the usual one, Google Play Services'
|
||
Wearable Data Layer, is a proprietary service that `docs/PRIVACY.md` and the
|
||
F-Droid build rule out. Any channel, ours or Google's, would also send data
|
||
off the phone, which the privacy policy currently says never happens. So
|
||
scoping this starts with a privacy decision, not with code. A standalone
|
||
watch app with its own timers and alarms and no sync avoids the question but
|
||
doesn't control the phone.
|