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:
@@ -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.
|
||||
Reference in New Issue
Block a user