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

24 KiB
Raw Blame History

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 TypeConverters — 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.

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.