Files
clockula/docs/ARCHITECTURE.md
T
makiolajandClaude Opus 5 971ee4f7a3 docs: point the reboot repair at the milestone that actually owns it
The roadmap puts the BOOT_COMPLETED and TIME_SET receivers, the ringing
service and the full-screen intent in M3; ARCHITECTURE.md credited them
to M6 and M7, which are Timers and Stopwatch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 13:53:11 +02:00

460 lines
24 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Clockula — architecture
How Clockula is built **today**, after M2. 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".
---
## 1. The thesis
**Clockula owns its storage, and pays for that privilege with a hard seam.**
Its siblings read a platform provider — Calendula the calendar, Agendula
tasks — so their data is open by construction. There is no open provider behind
a clock (`PLAN.md` §0), so Clockula keeps its own SQLite database. The honest
asterisk is that "open data" here has to be *earned* rather than inherited: by a
committed, reviewable schema; by a JSON export the user can actually take with
them (M10); and by a boundary strict enough that the storage engine stays an
implementation detail rather than becoming the app's shape.
The discipline that keeps that honest is one rule: **only `data/` knows Room
exists**. Everything above it talks to four repository interfaces and to
plain-Kotlin models. `ArchitectureRulesTest` fails the build if anyone reaches
through.
---
## 2. Layers
```
┌─────────────────────────────────────────────────────────┐
│ UI — Compose (M4+) │
│ today: MainActivity + ui/theme only │
└───────────────────────────┬─────────────────────────────┘
│ plain-Kotlin models, Flows
┌───────────────────────────┴─────────────────────────────┐
│ domain/ — Alarm, Timer, WorldClock, Stopwatch, │
│ ClockDefaults, WallClock, ElapsedRealtimeClock │
│ no android.*, no androidx.*, no Room │
└───────────────────────────┬─────────────────────────────┘
│
┌───────────────────────────┴─────────────────────────────┐
│ data/…/…Repository — four interfaces │
│ AlarmRepository · TimerRepository · │
│ WorldClockRepository · StopwatchRepository │
└──────────────┬──────────────────────────┬───────────────┘
│ entities │ typed prefs
┌──────────────┴─────────────┐ ┌─────────┴───────────────┐
│ DAOs + mappers (Room) │ │ PrefStore (DataStore) │
│ ClockulaDatabase v1 │ │ clockula_prefs │
└──────────────┬─────────────┘ └─────────┬───────────────┘
│ │
SQLite preferences_pb
```
The seam is the repository interface list. Above it there is no `AlarmEntity`,
no `@Query`, no `androidx.room` import and no `ClockulaDatabase` reference —
`ArchitectureRulesTest` greps the whole main source set for exactly those names
and for `import android.`/`import androidx.` inside `domain/`, and fails with
the offending paths listed. A grep is a blunter tool than a compiler, but it is
the one that runs in the gate.
There is deliberately **no `internal`** on anything the data layer adds. Room's
KSP processor generates Java against the DAO types and Kotlin mangles
`internal` member names; Clockula is a single Gradle module, so `internal` would
buy no encapsulation the architecture test does not already buy, and would buy a
class of build failures.
---
## 3. Modules and packages
One app module, `:app`, plus floret-kit as a git submodule wired in as a Gradle
composite build (`includeBuild("floret-kit")`, consumed as
`de.jeanlucmakiola.floret:<module>`). M2 added no Gradle module and touched no
kit module.
| Package | Holds |
|---|---|
| `domain/` | `Alarm.kt`, `Timer.kt`, `WorldClock.kt`, `Stopwatch.kt`, `ClockDefaults.kt` — plain Kotlin, no Android |
| `domain/time/` | `WallClock`, `ElapsedRealtimeClock` |
| `data/db/` | `ClockulaDatabase` — `@Database` v1, `exportSchema = true` |
| `data/alarms/` | `AlarmEntity`, `AlarmDao`, `AlarmMapper`, `AlarmRepository(+Impl)` |
| `data/timers/` | `TimerEntity`, `TimerDao`, `TimerMapper`, `TimerRepository(+Impl)` |
| `data/worldclocks/` | `WorldClockEntity`, `WorldClockDao`, `WorldClockMapper`, `WorldClockRepository(+Impl)` |
| `data/stopwatch/` | `LapEntity`, `LapDao`, `LapMapper`, `StopwatchStateStore`, `StopwatchRepository(+Impl)` |
| `data/prefs/` | `SettingsPrefs`, `ClockPrefs`, `StopwatchPrefs` |
| `data/time/` | `SystemWallClock`, `SystemElapsedRealtimeClock` |
| `data/di/` | `DataModule`, `DatabaseModule`, `RepositoryModule`, `TimeModule` |
| `ui/theme/`, `ui/crash/` | the M0/M1 theme and the crash-report surface |
---
## 4. The data model
`ClockulaDatabase` is at **version 1** with four 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.
That puts the whole entity↔domain translation inside the mappers, which are
plain JVM objects that a unit test can feed a corrupt row. A converter would
move the same translation into generated code, where an unknown enum name throws
*inside a cursor read* — which is a crashed list, not a degraded row.
Column names are snake_case via `@ColumnInfo`, so the SQL, the exported schema
JSON and M10's JSON backup all read the same vocabulary in a diff.
### `alarms`
| Column | Type | Notes |
|---|---|---|
| `id` | INTEGER pk | autogenerated |
| `hour`, `minute` | INTEGER | read back through `TimeOfDay.clamped` |
| `label` | TEXT | |
| `enabled` | INTEGER | indexed — `enabled()` filters on it |
| `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` |
| `created_at`, `updated_at` | INTEGER | wall-clock epoch millis |
Per-alarm settings are nullable *overrides*, never concrete copies of the
defaults. Storing concrete values would mean a later change to a default
silently failed to reach alarms the user never customised — the opposite of what
"default" means. `Alarm.resolveSettings(defaults)` is the pure function that
collapses the two, and it uses `?:` throughout: a stored `false` for `vibrate`
is a choice, not an absent value.
### `timers`
| Column | Type | Notes |
|---|---|---|
| `id` | INTEGER pk | |
| `label` | TEXT | |
| `duration_millis` | INTEGER | the configured length; `addTime` never moves it |
| `state` | TEXT | `IDLE`/`RUNNING`/`PAUSED`/`EXPIRED`; unknown degrades to `IDLE` |
| `remaining_millis` | INTEGER | authoritative when not `RUNNING` |
| `started_at_elapsed_realtime_millis` | INTEGER? | `RUNNING` only |
| `ends_at_elapsed_realtime_millis` | INTEGER? | `RUNNING` only — **authoritative** |
| `ends_at_wall_clock_millis` | INTEGER? | `RUNNING` only — **post-reboot fallback only** |
| `ringtone_uri` | TEXT? | `NULL` = inherit `ClockDefaults.timerRingtoneUri` |
| `sort_order` | INTEGER | indexed |
| `created_at`, `updated_at` | INTEGER | wall-clock epoch millis |
### `world_clocks`
| Column | Type | Notes |
|---|---|---|
| `id` | INTEGER pk | |
| `zone_id` | TEXT | **unique index**; IANA zone id |
| `label` | TEXT? | `NULL` = show the ICU-resolved city name (M8) |
| `sort_order` | INTEGER | indexed |
`zone_id` being unique is what makes `WorldClockRepository.add` idempotent: it
trims the id, rejects a blank or non-IANA one with `IllegalArgumentException`,
and returns the *existing* row's id when the zone is already there. The lookup,
the sort-order read and the insert happen in one transaction
(`WorldClockDao.addIfAbsent`), so no other writer can slip a row in — or delete
the row that won — between them, and the id handed back is always a row that
exists rather than the insert's `-1` sentinel. Validity
means membership of `java.time.ZoneId.getAvailableZoneIds()`, so `UTC` is
accepted and fixed offsets like `+02:00` are not — IANA zone ids, not a bespoke
city table (`PLAN.md` §5). A zone the device's tzdata later drops is **kept**,
not blanked; `WorldClock.isKnownZone` reports it and the UI can show it as
broken. Losing the user's row silently would be worse.
### `stopwatch_laps`
| Column | Type | Notes |
|---|---|---|
| `id` | INTEGER pk | not exposed to the domain |
| `lap_index` | INTEGER | **unique index**; 1-based, and the lap's identity |
| `split_millis`, `cumulative_millis` | INTEGER | |
`LapDao.appendLap` is `@Transaction`: it reads the previous lap, derives the
index and the split, and inserts — so two concurrent laps cannot collide on the
unique index.
### The repeat mask
Bit `n` is ISO day `n + 1`: **Monday is bit 0, Sunday bit 6**, so the conversion
is `1 shl (day.value - 1)` against `java.time.DayOfWeek.value` with no lookup
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.**
### Reading is forgiving
Every mapper read degrades rather than throws: out-of-range hours clamp, unknown
enum names fall back through `core-prefs`' `toEnum`, blank strings read back as
`null`, negative millis clamp to zero. An absent `dismiss_challenge` stays
`null` (it is an unset override); a *present but unknown* one degrades to `NONE`.
### The schema is the contract
The exported JSON lives at
`app/schemas/de.jeanlucmakiola.clockula.data.db.ClockulaDatabase/1.json`, is
committed, and is also wired in as an `androidTest` asset directory so M3's
`MigrationTestHelper` can open a v1 database on device. `SchemaExportTest`
fails the JVM test run if it goes missing. There is **no
`fallbackToDestructiveMigration`**: `PLAN.md` §12 requires tested migrations
from v1, and a destructive fallback would quietly eat a user's alarms on the
first schema change. There are no migrations yet — v1 is the first version.
---
## 5. The two clocks
`PLAN.md` §5 calls the wall-clock / elapsed-realtime distinction "the single
easiest thing to get wrong in a clock app". This is the section to read before
touching anything time-shaped.
```kotlin
interface WallClock { fun now(): Instant }
interface ElapsedRealtimeClock { fun elapsedRealtime(): Duration }
```
Both are **injected everywhere and never called statically** — that is the only
reason the distinction can be tested from both sides. Their Android
implementations are two one-line classes in `data/time/`
(`System.currentTimeMillis()` and `SystemClock.elapsedRealtime()`). Any new API
here must name which clock it takes in its parameter list; a bare `now()` is
forbidden.
| Uses the wall clock | Uses elapsed realtime |
|---|---|
| alarms (they are calendar facts) | a running timer's countdown |
| `created_at` / `updated_at` on every row | the stopwatch |
| a running timer's post-reboot fallback, and nothing else | |
### A running timer's three anchors
`started_at_elapsed_realtime_millis` is the monotonic instant of the last
start/resume; `ends_at_elapsed_realtime_millis` is when it expires and is
**authoritative**; `ends_at_wall_clock_millis` is the same instant on the wall
clock and is a **fallback only**.
`Timer.snapshotAt(elapsedRealtime, wallClock)` decides staleness
**monotonically, without touching the wall clock at all**:
> `elapsedRealtime() < startedAtElapsedRealtime` ⇒ this is a different boot,
> because `elapsedRealtime()` never decreases within one boot.
So a user moving the system clock cannot make the anchor look stale and cannot
warp a running timer — forwards or backwards. The wall-clock value is read
*only* when that monotonic test says "different boot", and then only to answer
"approximately how much is left", with `TimerSnapshot.anchorIsStale = true` so
the UI can say so. Without it, a reboot would strand every running timer.
**Known blind spot, accepted:** the monotonic test only catches a reboot while
the *new* boot's uptime is still below the old `startedAtElapsedRealtime`. Once
the device has been up longer than that, the same comparison says "same boot"
and the row is resolved against a dead pre-reboot anchor, reading as **live and
not stale** — `endsAtWallClock` is never consulted.
This window opens sooner than "after it would have expired". A timer started two
minutes into a boot re-enters it about two minutes into the *next* boot: a
30-minute timer started at uptime 2 min, with the device rebooted ten minutes
later, reads as live and non-stale from uptime 2 min of the new boot onwards, and
counts down from a number that means nothing. So: **a stale anchor can read as
live, well before expiry.** The cost is accepted here because the alternative —
consulting the wall clock to decide staleness — would let a user moving the
system clock warp a running timer, which `PLAN.md` §5 forbids outright.
Closing it properly needs a persisted boot identifier, which is not in this
schema. **M3's `BOOT_COMPLETED` receiver is the authoritative repair:** it runs
at the top of every boot, before the new uptime can climb past any stored
anchor, and rewrites every running timer from `endsAtWallClock`.
Every timer write is a read-modify-write inside **one transaction**
(`TimerDao.updateWithin`): the row is read, the domain rule applied and the
result written back before another writer can interleave. The ringing service
marking a timer expired and the user adding a minute to it therefore cannot
swallow each other's edit. `addTime` on a running timer rebases on the clamped
remaining and rewrites all three anchors from a single reading of both clocks,
so "+1 min" always grants a whole minute even when the end anchor has already
gone by.
A `RUNNING` row missing either elapsed anchor is treated as corrupt, not as
stale-by-reboot: it reads back whatever it last banked, flagged stale. It never
throws — a throw inside a list read is a crashed screen.
### The stopwatch has no fallback, on purpose
`StopwatchRun` carries **no wall-clock field whatsoever**, and `StopwatchPrefs`
stores no wall-clock value (a test asserts that on the stored bytes). A
stopwatch that spans a reboot has lost information nobody can reconstruct, and
unlike a timer there is nothing to ring. A stale run therefore resolves to
**paused at the accumulated time**, discarding the lost segment, with
`anchorIsStale = true`. Discarding is the honest answer; guessing would be a lie
displayed to two decimal places.
`StopwatchRun.snapshotAt` decides staleness with the same monotonic test as the
timer, and inherits the same blind spot: once the new boot's uptime passes the
stored `startedAtElapsedRealtime`, the reboot goes unnoticed and the run reports
`accumulated + (elapsedRealtime - startedAtElapsedRealtime)` as **live and not
stale** — a segment it never actually ran. So the discarding above is what
happens when the reboot *is* detected, not a guarantee; until a
`BOOT_COMPLETED` receiver pauses the run at boot — the receiver itself arrives
in M3, the stopwatch's own repair in M7 — a stopwatch that spanned a reboot can
show a fabricated segment.
---
## 6. Preferences
One DataStore file, `clockula_prefs`, behind floret-kit's typed `PrefStore`.
Three slices:
| Slice | Keys | Owner |
|---|---|---|
| Appearance (M1) | `theme_mode`, `dynamic_color` | `AppearancePrefs` in `core-prefs` — the family's shared key names |
| Clock defaults (M2) | `default_snooze_minutes`, `default_snooze_limit`, `default_vibrate`, `default_volume_ramp_seconds`, `default_alarm_ringtone_uri`, `default_timer_ringtone_uri`, `default_dismiss_challenge`, `default_timer_duration_millis`, `home_zone_id` | `ClockPrefs`, surfaced as `SettingsPrefs.defaults: Flow<ClockDefaults>` |
| Stopwatch run record (M2) | `stopwatch_state`, `stopwatch_started_elapsed_millis`, `stopwatch_accumulated_millis`, `stopwatch_last_lap_cumulative_millis` | `StopwatchPrefs`, behind `StopwatchStateStore` |
The stopwatch's *run* is a single record, so it lives in DataStore rather than
as a one-row table (`PLAN.md` §5); its *laps* are a list, so they live in Room.
It is also read and written as a *record*: `StopwatchPrefs.read`/`write` map all
four keys in one snapshot and one `PrefStore.edit` transaction, and
`StopwatchStateStore.run` maps that snapshot rather than combining four key
flows. A state and its anchor only mean anything together, so no reader — and no
process killed mid-write — ever sees `RUNNING` without its start anchor.
**Clamp on read as well as on write.** Every bounded value goes through
`Pref.map`, which applies the same clamp in both directions: snooze 1–60
minutes, snooze limit 0–10, volume ramp 0–60 s, timer duration 1 s–24 h. A
hand-edited file or a restore from a future version therefore cannot produce a
value the user could never have chosen. Unknown enum names degrade to their
default; a `home_zone_id` the device's tzdata no longer knows reads back as
absent; blank strings read back as `null`.
`SettingsPrefs.defaults` and `StopwatchStateStore.run` are each built **once**
as a property, not per access, and are distinct-until-changed. A collector keyed
on the flow instance (as `collectAsStateWithLifecycle` is) would otherwise tear
down and restart the DataStore collection on every recomposition. Each is
composed from the individual key flows, so writing an unrelated preference never
re-emits them.
A corrupt `preferences_pb` is replaced with empty preferences rather than
throwing `CorruptionException` out of every launch — these values feed the theme
before the first frame, and the alternative is an app only "clear data" can fix.
---
## 7. Dependency injection
Hilt, `SingletonComponent` throughout. Four app modules plus the kit's:
| Module | Provides |
|---|---|
| `DataModule` | the `DataStore<Preferences>` (built explicitly so its scope runs on the kit's `@IoDispatcher`, with the corruption handler) and `PrefStore` |
| `DatabaseModule` | `ClockulaDatabase` (`@Singleton`) and the four DAOs |
| `RepositoryModule` | `@Binds` for the four repository interfaces |
| `TimeModule` | `@Binds` for `WallClock` and `ElapsedRealtimeClock` |
From floret-kit: `core-di`'s `CoroutinesModule` supplies `@IoDispatcher`, and
`core-prefs` supplies `PrefStore`, `Pref` and the appearance keys.
Repositories take **no `CoroutineDispatcher`**. Room already dispatches suspend
queries onto its own executor and runs Flow queries off the main thread, so a
`withContext(io)` wrapper around a DAO call would be cargo cult. The DataStore
half already runs on `@IoDispatcher`, wired once in `DataModule`.
---
## 8. Testing
JVM-first. 147 unit tests run in the gate; six instrumentation tests compile in
it and run only on a device.
- **Fakes over `MutableStateFlow`.** The four fake DAOs are backed by a
`MutableStateFlow<List<Entity>>`, so a write re-emits on the observing flow
exactly as Room's would. The three abstract DAOs are *extended*, not
reimplemented, so their real `@Transaction` bodies (`reorder`, `appendLap`,
`updateWithin`, `addIfAbsent`) are the ones under test. The fakes also mirror the constraints SQLite
enforces: a duplicate `zone_id` insert returns `-1`, a duplicate `lap_index`
throws.
- **A real DataStore on a temp file.** The preference and stopwatch tests write
an actual `preferences_pb` under a JUnit 5 `@TempDir`, which is what makes
"the run survives process death" a real assertion — a second repository is
constructed over the same file — rather than a fake's memory.
- **Injected fake clocks.** `FakeWallClock` moves by hand, including backwards,
as a user can; `FakeElapsedRealtimeClock` has an explicit `reboot()`. The
headline test starts a timer, jumps the wall clock three hours each way, and
asserts both that the remaining time does not move and that the stored row is
byte-for-byte unchanged.
- **Instrumentation-only** is the half a fake cannot prove: generated SQL,
unique indices, real `@Transaction` behaviour and the database opening at
version 1. Those six tests are short and obvious by design.
- **`ArchitectureRulesTest`** greps the main source set for Room leakage above
`data/` and Android imports inside `domain/`. It is the boundary of §2, made
mechanical.
Stack: JUnit 5 + Truth + Turbine + `kotlinx-coroutines-test`, with
`useJUnitPlatform()` and `isReturnDefaultValues = true`.
---
## 9. Build and tooling
AGP 9.2.1 · KSP 2.3.9 · Hilt 2.59.2 · Room 2.8.3 · DataStore 1.2.1, all pinned
in `gradle/libs.versions.toml`. `compileSdk 37`, `targetSdk 36`, **`minSdk 29`**,
Java/Kotlin target 17.
Room's schema export is switched on with
`ksp { arg("room.schemaLocation", "$projectDir/schemas") }`, and the same
directory is added as an `androidTest` assets source dir. It is written during
`compileDebugKotlin`, so `assembleDebug` must run before the test that reads it.
Reproducibility rules, checked by `scripts/check_reproducible_release.sh`:
`vcsInfo { include = false }` on release, `dependenciesInfo` out of the APK and
bundle, and **no foojay toolchain resolver anywhere** — not in this repo, not in
floret-kit, not in a comment.
floret-kit is a git submodule consumed as a composite build; it needs a
gitignored `floret-kit/local.properties` pointing at the SDK locally, and
`ANDROID_HOME` in CI.
Time types: the domain speaks `kotlin.time.Instant`, `kotlin.time.Duration`,
`java.time.DayOfWeek` and `java.time.ZoneId`. `java.time` is native at
`minSdk 29`, so there is no desugaring. `kotlinx-datetime` is on the classpath
for M3's DST work but is not used yet.
### Manifest
No permissions are declared yet, deliberately. Clockula's permission set is the
alarm engine's (`USE_EXACT_ALARM`, `POST_NOTIFICATIONS`,
`RECEIVE_BOOT_COMPLETED`, `USE_FULL_SCREEN_INTENT`, `FOREGROUND_SERVICE*`) and it
arrives in M3 alongside the receivers and the ringing service that need it, so
the manifest never claims a capability the code cannot honour. Components today:
`MainActivity`, the non-exported `CrashReportActivity`, and AppCompat's locale
metadata holder service.
---
## 10. What is not built yet
| Not here | Milestone |
|---|---|
| Next-fire resolution, DST arithmetic, `Alarm.nextFireTime` | M3 |
| `AlarmScheduler`, the exact-alarm plumbing, snooze state (schema **v2**, with a tested migration) | M3 |
| Any UI, ViewModel or navigation beyond the theme | M4+ |
| Alarm edit surface, ringtone picker, per-alarm override UI | M5 |
| `BOOT_COMPLETED` / `TIME_SET` receivers — the authoritative repair after a reboot | M3 |
| The ringing foreground service, full-screen intent, notifications | M3 (timers reuse its audio path in M6) |
| Stopwatch presentation, best/worst lap analysis | M7 |
| ICU city and zone display names, offsets, day differences | M8 |
| The `android.provider.AlarmClock` intent surface and its hostile-extra validation (`TimeOfDay.clamped` and `Zones.normalise` exist so M9 has something to call) | M9 |
| Settings screen, JSON backup / SAF export | M10 |
| Screenshots and store listing polish | M11 |
Also absent by design: any seeded default data (no starter alarm, no home world
clock on first run), and a "silent" ringtone sentinel — `ringtoneUri == null`
means *inherit*, and silent arrives with M5's picker, where the user can
actually choose it.