diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..315af62 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -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:`). 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` | +| 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` (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>`, 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.