ARCHITECTURE gains a section 11 on the engine itself — the state machine, the two AlarmManager slots, the DST table, and the chain that keeps a denied permission from turning into a silent morning. Section 5's known blind spot is amended rather than deleted: the boot id exists now, and the stopwatch repair moved from M7 to here. Section 9's "no permissions are declared yet, deliberately" is finally untrue, so it is replaced with the real set and why each one is there. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
42 KiB
Clockula — architecture
How Clockula is built today, after M3. 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>). M3 added no Gradle module and touched no
kit module — the kit's roadmap keeps schedulers app-local.
| Package | Holds |
|---|---|
domain/ |
Alarm.kt, Timer.kt, WorldClock.kt, Stopwatch.kt, ClockDefaults.kt — plain Kotlin, no Android |
domain/alarm/ |
the alarm engine's pure half: occurrences, the resolver, the ring state, the volume ramp, the ring policies |
domain/time/ |
WallClock, ElapsedRealtimeClock, ZoneProvider, BootId |
data/db/ |
ClockulaDatabase — @Database v2, exportSchema = true — and Migrations |
data/alarms/ |
the alarm and ring-state entities, DAOs, mappers and repositories |
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, SystemZoneProvider, AndroidBootIdProvider |
data/di/ |
DataModule, DatabaseModule, RepositoryModule, TimeModule |
alarm/ |
AlarmEngine and the four seams it talks to — AlarmScheduler, AlarmCapabilities, RingCoordinator, AlarmNotifier — plus AlarmIntents |
alarm/android/ |
the seams' Android implementations: AlarmManager, the capability reads, the service handle, the snoozed notification |
alarm/receiver/ |
AlarmFireReceiver, AlarmActionReceiver, SystemEventReceiver |
alarm/ring/ |
the ringing foreground service, its audio player, its vibrator, its notifications |
alarm/di/ |
AlarmModule — @Binds for the four seams |
system/ |
RebootRepair — the boot-id gate |
ui/theme/, ui/crash/ |
the M0/M1 theme and the crash-report surface |
ui/ring/ |
AlarmRingActivity — M3 ships its window flags and a placeholder; M4 ships the screen |
4. The data model
ClockulaDatabase is at version 2 with five 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.
alarm_states
Volatile ring state, one row per alarm, added at v2 (M3).
| Column | Type | Notes |
|---|---|---|
alarm_id |
INTEGER pk | also a FOREIGN KEY … ON DELETE CASCADE to alarms(id) |
snoozed_until |
INTEGER? | the absolute instant a snooze is due |
snooze_count |
INTEGER | snoozes taken in the current ring cycle |
ringing_since |
INTEGER? | non-null exactly while the alarm is ringing |
handled_occurrence |
INTEGER? | the occurrence of the most recent ring cycle |
skipped_occurrence |
INTEGER? | the occurrence skip_next_occurrence is armed on |
It is a table and not five more columns on alarms for three reasons: volatile
ring state must not appear in M10's JSON backup of an alarm, deleting an alarm
must take its ring state with it (hence the cascade), and the alarm mapper's
tests stay about alarms.
A missing row is not an error. AlarmStateRepository.state(id) returns
AlarmRingState.initial(id) — every instant null, count 0 — and nothing is
written until there is something to write. upcoming() therefore resolves every
alarm without creating a single row.
The two rules that keep re-resolution honest
An alarm's next fire time is never stored; it is re-resolved from the local
time-of-day and the current zone on every fire, boot, TIME_SET, zone change
and edit (PLAN.md §4). Two failure modes fall out of that, and each has
exactly one rule. They are the app's least obvious invariants, and the ones a
future reader will otherwise "simplify" away.
The fire-grace window. Candidates are generated strictly after
now - AlarmRing.FIRE_GRACE (2 minutes). A fire delayed by doze, a slow boot or
a TIME_SET nudge still counts, and a device powered on at 07:01 still rings
its 07:00 alarm. An alarm missed by hours does not ring hours later.
The handled_occurrence watermark. Written the moment an alarm fires. A
candidate is suppressed iff candidate <= handledOccurrence and
candidate <= now. The second half is load-bearing: without it, a user who set
the clock forward, let an alarm fire, then set it back would have every future
occurrence suppressed forever. With it, only a past-or-present candidate can be
suppressed — the watermark can silence a re-fire, but it can never silence the
future.
One column, three bugs: "do not ring the same occurrence twice", "a backwards
TIME_SET must not re-ring a dismissed alarm" and "a snoozed alarm's natural
occurrence must stay quiet" are the same rule.
The skip watermark
alarms.skip_next_occurrence is the user-facing boolean;
alarm_states.skipped_occurrence is the concrete instant the skip is armed on.
The flag alone is unusable: resolve at 06:00 on Monday for a daily 07:00 alarm
and you correctly get Tuesday, but resolve again at 08:00 — after the skipped
occurrence has gone by — and a naive "drop the first candidate" gives Wednesday,
so the skip eats a second alarm. With the watermark:
- flag set, no watermark ⇒ arm it on the first eligible candidate and fire the one after;
- watermark still in the future ⇒ fire the first candidate after it; write nothing;
- watermark at or before now ⇒ consumed: clear the flag and the watermark, and still return the first candidate after it, so the skipped occurrence cannot ring on its way out through the grace window.
Arming is deferred while a snooze is pending, and a non-repeating alarm with the flag set is disabled rather than skipped: a one-shot has exactly one occurrence, so skipping it is dismissing it in advance.
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/, one file per
version, all committed, and the directory is also wired in as an androidTest
asset directory so MigrationTestHelper can open a v1 database on device.
SchemaExportTest fails the JVM test run if a version goes missing — the
previous one is never regenerated away. There is no
fallbackToDestructiveMigration: PLAN.md §12 requires tested migrations
from v1, and a destructive fallback would quietly eat a user's alarms.
Migrations.ALL is the single list the database builder is handed and the list
the tests assert on. MIGRATION_1_2 is one CREATE TABLE alarm_states, copied
verbatim from the exported schema's createSql, and the instrumentation test
runs it against a real v1 database and validates the result against 2.json —
a migration that fails eats the user's alarms, so it is proved rather than
eyeballed.
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, becauseelapsedRealtime()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, and M3 added one.
RebootRepair runs at the top of every boot — from SystemEventReceiver's
BOOT_COMPLETED and again from ClockulaApp.onCreate, so a broadcast the
system never delivered is still caught — and rewrites every running timer from
endsAtWallClock before the new uptime can climb past any stored anchor. A
timer already past its wall-clock end becomes EXPIRED; one with no wall-clock
fallback is paused at what it banked.
The gate is the boot id, not a flag in memory: BootId is
Settings.Global.BOOT_COUNT (API 24+, no permission), and two ids that both
have a count are the same boot exactly when the counts match. When either side
has no count — a device that will not give BOOT_COUNT up — it falls back to
comparing wallClock.now() - elapsedRealtime(), the instant the device booted,
within one minute. That fallback is best-effort and documented as such: the
derived instant drifts under NTP correction, which is what the tolerance
absorbs. It costs nine lines and means an unreadable BOOT_COUNT degrades to
approximately-right rather than to never noticing a reboot. The id is stored as
one DataStore string, last_boot_id, and decoding is strict — anything
malformed reads as "no known previous boot", i.e. "repair".
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 had 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.
M3 closes it. The same boot gate that repairs timers also calls
StopwatchRepository.pauseAfterReboot(): a RUNNING run is written back
PAUSED at exactly its banked accumulated, with the start anchor dropped and
nothing invented in its place. A paused or idle run is not written at all. That
is three lines on top of machinery the alarm engine needs anyway, and leaving a
knowingly-fabricated elapsed reading on screen for two more milestones was not
defensible. M3 owns the stopwatch's post-reboot repair; M7 owns its
presentation.
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 |
| System facts (M3) | last_boot_id |
SystemPrefs, behind BootStateStore — the persisted half of the reboot gate |
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. Five 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, built with .addMigrations(*Migrations.ALL)) and the five DAOs |
RepositoryModule |
@Binds for the five repository interfaces |
TimeModule |
@Binds for WallClock, ElapsedRealtimeClock, ZoneProvider and BootIdProvider |
AlarmModule |
@Binds for the alarm engine's four seams: AlarmScheduler, AlarmCapabilities, RingCoordinator, AlarmNotifier |
From floret-kit: core-di's CoroutinesModule supplies @IoDispatcher and the
process-lifetime @ApplicationScope the receivers launch on, and core-prefs
supplies PrefStore, Pref and the appearance keys.
BroadcastReceiver.onReceive is abstract, so a Kotlin subclass cannot call
super.onReceive(...) — which is exactly where Hilt injects. The three
receivers therefore extend one concrete no-op base, HiltBroadcastReceiver; the
Hilt plugin rewrites each receiver's superclass to its generated Hilt_… class
and the super call lands on the injecting one.
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. 324 unit tests run in the gate; twelve instrumentation tests compile
in it and run only on a device. There is no Robolectric and no plan for it:
everything that would have needed a shadow is behind one of the injected seams,
or is a pure function in domain/alarm/.
- Fakes over
MutableStateFlow. The five fake DAOs are backed by aMutableStateFlow<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@Transactionbodies (reorder,appendLap,updateWithin,addIfAbsent) are the ones under test. The fakes also mirror the constraints SQLite enforces: a duplicatezone_idinsert returns-1, a duplicatelap_indexthrows, ring state for an alarm that is not there fails the foreign key, and an explicit id moves the row-id allocator past it as SQLite's would. - Six fakes for the engine's seams.
FakeAlarmSchedulerholds each of the two AlarmManager slots as the value it currently holds, so a test asserts on what the system would be showing rather than on a call log;FakeRingCoordinatorrecords start/stop in order, because the order is the take-over contract;FakeAlarmNotifier,FakeAlarmCapabilities,FakeZoneProvider(a zone a test can change under a scheduled alarm) andFakeBootIdProvidercomplete the set. - A real DataStore on a temp file. The preference and stopwatch tests write
an actual
preferences_pbunder 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.
FakeWallClockmoves by hand, including backwards, as a user can;FakeElapsedRealtimeClockhas an explicitreboot(). 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. - Pure functions carry the hard parts. DST, the resolver's precedence, the
volume ramp and the "never to silence" policies are all
objects with no collaborators, so the milestone's riskiest behaviour is asserted a hundred times over with hand-built inputs — including all 128 repeat masks and four zones with awkward transitions. - Instrumentation-only is the half a fake cannot prove: generated SQL,
unique indices, real
@Transactionbehaviour, the foreign key cascading, the v1 → v2 migration validated against the exported schema, and the database opening at version 2. ArchitectureRulesTestgreps the main source set for Room leakage abovedata/and Android imports insidedomain/,alarm/AlarmEngine.ktandsystem/. It is the boundary of §2 made mechanical — and the engine's testability is a build-gate fact rather than a habit.
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; the DST arithmetic itself is java.time's, wrapped in one
function (§11).
Manifest
Clockula's permission set is the alarm engine's, and nothing more. Each one is declared because a specific path would otherwise fail — silently, at 07:00.
| Permission | Why |
|---|---|
USE_EXACT_ALARM |
install-granted from API 33 for an app whose core function is alarms; no dialog, nothing to revoke |
SCHEDULE_EXACT_ALARM android:maxSdkVersion="32" |
covers 31–32, where it is pre-granted. Bounded at 32 so it never overlaps the one above — that is the documented pattern and it keeps the store listing's story clean. Below 31 an exact alarm needs no permission at all |
RECEIVE_BOOT_COMPLETED |
AlarmManager keeps nothing across a reboot |
USE_FULL_SCREEN_INTENT |
the ring screen over the lock screen |
POST_NOTIFICATIONS |
the ring and snoozed notifications |
FOREGROUND_SERVICE, FOREGROUND_SERVICE_SYSTEM_EXEMPTED |
the ringing service |
WAKE_LOCK |
keep the CPU up for the length of a ring |
VIBRATE |
the alarm vibrates, and vibrates alone when no audio source opens |
Neither of the two revocable ones can silence an alarm. A denied
POST_NOTIFICATIONS costs the user the notification and nothing else — the
audio belongs to the foreground service. A denied USE_FULL_SCREEN_INTENT
degrades to a PRIORITY_MAX, CATEGORY_ALARM heads-up notification on the same
HIGH-importance channel, which still rings. M3 declares them; M4 asks for them
and M10's self-check screen explains them.
The ringing service's android:foregroundServiceType is systemExempted,
which is the case Android's own fgs-types-required guidance names: an app
holding SCHEDULE_EXACT_ALARM or USE_EXACT_ALARM and using a foreground
service to continue alarms in the background. Not mediaPlayback — an alarm is
not the user's media session, and it would owe the store a media justification —
and emphatically not shortService, which caps at about three minutes against a
ten-minute ring window.
The three receivers are android:exported="false": a protected system broadcast
is delivered to an unexported receiver anyway (this is how androidx.work
declares its own RescheduleReceiver), and a PendingIntent the app created is
delivered regardless — so nothing else can fake a fire.
ACTION_LOCKED_BOOT_COMPLETED is deliberately not handled: it needs
directBootAware, and the database and DataStore live in credential-encrypted
storage that is unreadable before the first unlock.
Components: MainActivity, the non-exported CrashReportActivity and
AlarmRingActivity, the AlarmRingService, the three receivers, and AppCompat's
locale metadata holder service.
10. What is not built yet
| Not here | Milestone |
|---|---|
| The ring screen itself — its layout, its motion, its dismiss challenge, its ViewModel. M3 ships the window flags and a plain placeholder | M4 |
| Any other UI, ViewModel or navigation beyond the theme | M4+ |
| Alarm list, edit surface, time picker, ringtone picker, repeat-day selector, per-alarm override UI | M5 |
The runtime permission requests and the exact-alarm / full-screen-intent deep links. AlarmCapabilities.snapshot() exists; asking is M4's and explaining is M10's |
M4 / M10 |
| Timers ringing — M6 reuses this milestone's audio path; M3 wires no timer to it | M6 |
| Stopwatch presentation, best/worst lap analysis (its post-reboot repair shipped in M3) | 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 |
| The self-check screen ("why might my alarm not ring?"), settings screen, JSON backup / SAF export | M10 |
| A user-configurable auto-silence duration, an unlimited-snooze option, per-alarm auto-silence | M10 at the earliest |
| 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.
Four more deliberate gaps the alarm engine leaves open:
- No missed-alarm notification or history. An auto-silenced alarm is missed silently. Not on the roadmap for v1.
- No upcoming-alarm notification.
getNextAlarmClock()already draws the status-bar icon, which is the platform's answer. - No direct-boot awareness, so an alarm cannot ring in the window between a reboot and the first unlock — see §9.
- No bundled fallback ringtone. The audio chain ends in forced vibration (§11), which makes a shipped asset a path a real device cannot reach.
11. The alarm engine
The milestone's real deliverable, and the section to read before changing anything alarm-shaped.
The shape
AlarmResolver.resolve(alarm, state, now, zone) is pure. It returns the next
fire instant, where it came from, the ring state as it should now be persisted,
and two instructions that live outside the state table (clearSkipFlag,
disableAlarm). It never touches a repository, a clock or
ZoneId.systemDefault(), which is why the hard half of this milestone is
callable from a plain JUnit test with hand-built inputs and no fakes at all.
AlarmEngine owns every write the resolver asks for and every call into the
four seams. It is never driven by a Flow: resolution writes state, so an
engine that rescheduled on each repository emission would re-trigger itself on
its own writes. reschedule() is called explicitly — from ClockulaApp, from
the receivers, and from M5's edit surface. upcoming() runs the same resolver in
preview mode and discards the state it returns; a read must not write, and a
test asserts the store recorded nothing across a full collection.
Every entry point is a read-modify-write over alarm_states, and they arrive
from threads that know nothing about each other — a broadcast on
Dispatchers.Default, ClockulaApp's launch-time re-sync, the ring screen — so
they are serialised on one Mutex. Without it, a cold start caused by the
fire broadcast can read the pre-ring state and write it back over ringingSince
or the handled-occurrence watermark, which either silences the alarm or lets it
ring twice. The lock is not reentrant, so exactly one layer takes it: the public
entry points. ClockulaApp goes through onBootCompleted() rather than calling
repair, resume and reschedule one by one, so its whole pass is inside the lock.
Precedence, fixed and total
- Disabled ⇒ clear the snooze, the ring and the skip watermark; keep
handled_occurrence, because re-enabling must not un-suppress a dismissal. - Ringing within
AUTO_SILENCE_AFTER⇒RESUMED_RING, nothing scheduled: it is not an alarm to come, it is one that is happening. A clock moved backwards under a ringing alarm reads as a negative age, which is inside the window — so it keeps ringing. Beyond the window it is auto-dismissed as missed and the resolution falls through. - Snoozed in the future ⇒ the snooze is the next fire, unless the natural occurrence is earlier (defined so the function is total). Due-but-past by less than the ring window ⇒ still the snooze, at its own past instant, which AlarmManager fires at once: a snooze promised before a reboot is kept. Older than that ⇒ abandoned.
- The natural occurrence, with §4's grace window, watermark and skip rules.
The ring cycle
A cycle opens when the alarm fires and closes on dismiss, on the snooze limit
being reached, or on auto-silence after AUTO_SILENCE_AFTER = 10 minutes
(AOSP DeskClock's default; not user-configurable in v1). Snoozing keeps it open.
A non-repeating alarm is disabled when its cycle closes, never when it opens — disabling it at the start would clear its own snooze and the alarm would vanish mid-snooze.
Closing a cycle only touches the ring service and the auto-silence registration when that alarm is the one actually ringing. Both are single, global slots (there is one ring service, and stopping it stops whatever it is playing), so dismissing a merely snoozed alarm from its notification must leave a different, genuinely ringing alarm sounding with its backstop intact.
At most one alarm rings, and a newly-firing alarm takes over. Two ringtones at once is not a feature, and queueing invents a state machine nobody can debug at 07:00. The alarm that is displaced keeps its watermark, so it does not come back the moment the slot is re-resolved.
snoozeLimit == 0 means snoozing is disabled, not unlimited — v1 offers no
unlimited, and "0 means infinite" is a trap. A refused snooze dismisses; it
never leaves the user with a ringing alarm they cannot silence.
Two AlarmManager slots, deliberately separate
| Slot | Registered with | Purpose |
|---|---|---|
| next alarm | setAlarmClock(AlarmClockInfo(fireAt, showIntent), …) |
the user's next alarm. Doze-exempt, and the only variant that populates getNextAlarmClock() |
| auto-silence backstop | setExactAndAllowWhileIdle |
stops a ring nobody dismissed |
They are separate because getNextAlarmClock() is what draws the status-bar icon
and the lockscreen line: a backstop sharing that slot would overwrite the user's
07:00 with an internal 07:10. Two PendingIntents, two request codes, two
independent cancels — and a test asserts no two request codes collide, because
that collision is exactly how a backstop eats a user's alarm. A snooze does
go through the next-alarm slot: a snoozed alarm is the next alarm.
If exact alarms are unavailable the alarm is still registered, with
setAndAllowWhileIdle. There is no third branch: late is survivable, silent is
not.
DST
AlarmOccurrences walks local dates forward and maps each to an instant with
one ZonedDateTime.of(date, time, zone) call — the only DST-aware call in the
app. Adding 24 hours to yesterday's fire time is the bug this milestone exists
not to have.
| Transition | java.time's default resolver | What the user sees |
|---|---|---|
| Gap (spring forward) — the local time does not exist | shifts forward by the gap's own length | a 02:30 alarm on a Berlin spring-forward night rings at 03:30 local. It rings; it is never skipped. A 30-minute gap moves it by 30 minutes, not an hour |
| Overlap (fall back) — the local time happens twice | takes the earlier offset | a 02:30 alarm on a fall-back night rings once, at the first 02:30. Never late, never twice |
These are AOSP DeskClock's behaviours, they are the two answers a user would defend ("I still got woken", "I only got woken once"), and they come free from the platform rather than from hand-rolled offset maths. They are locked by tests that assert exact instants in Europe/Berlin, America/New_York, Australia/Lord_Howe (a 30-minute gap) and Pacific/Apia (a calendar day that never existed), not by a comment.
A snooze, by contrast, is an absolute instant: no transition can move it.
Never to silence
The one non-negotiable, and it is a chain of fallbacks rather than a hope:
- the alarm's own ringtone URI, else
- the device's default alarm URI, else
RingtoneManager.getDefaultUri(TYPE_ALARM), else- forced vibration —
RingFallbackPolicy.vibrationRequiredturns vibration on even for an alarm the user set not to vibrate, because with no audio source the alternative is silence.
The audio is the foreground service's, played over USAGE_ALARM /
CONTENT_TYPE_SONIFICATION so Do Not Disturb's alarm exemption applies, with
AUDIOFOCUS_GAIN_TRANSIENT — an alarm interrupts; it does not duck. The volume
ramp is a pure function, MIN + (1 - MIN) · progress², stepped every 200 ms into
MediaPlayer.setVolume and never into AudioManager.setStreamVolume, which
would edit the user's own alarm volume and leave it edited.
RingPresentationPolicy decides how the ring is presented, and its
soundsAnyway is true in all eight capability combinations — asserted
exhaustively, because that is the invariant the app lives on.