docs: the first ARCHITECTURE.md

How Clockula is built today: the layers and the data seam, the package
layout, the four tables column by column, dependency injection, and the
JVM-first testing posture.

The section on the two clocks is the one a future contributor will need
most — which aggregate resolves against which clock and why, the timer's
three anchors, and the reboot window where a dead anchor can still read
as live, stated as it actually behaves rather than as it was first
assumed to. Closing that window needs a persisted boot id; M3's
BOOT_COMPLETED receiver is where it belongs.

Also says plainly what is not built yet, and points each at its milestone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-11 13:52:33 +02:00
co-authored by Claude Opus 5
parent 19b0719066
commit 1e6443c907
+458
View File
@@ -0,0 +1,458 @@
# 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. **M6'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 M6's
`BOOT_COMPLETED` receiver pauses the run at boot, 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 | M6 |
| The ringing foreground service, full-screen intent, notifications | M6/M7 |
| 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.