# Conflicts: # CHANGELOG.md # app/src/main/java/de/jeanlucmakiola/clockula/alarm/AlarmEngine.kt # app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmEditorViewModel.kt # app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmRow.kt # app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmsScreen.kt # app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmsViewModel.kt # app/src/main/java/de/jeanlucmakiola/clockula/ui/timers/TimersScreen.kt # app/src/test/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmEditorViewModelTest.kt # app/src/test/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmsViewModelTest.kt # docs/ROADMAP.md
210 KiB
Clockula — architecture
How Clockula is built today, after M9. 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 v4 │ │ clockula_prefs │
└──────────────┬─────────────┘ └─────────┬───────────────┘
│ │
SQLite preferences_pb
One seam sits beside the repositories rather than under them:
data/zones/'s ZoneNames and ZoneDirectory (M8). They read the platform
(the device's tzdata through ZoneProvider.available() and ICU's localised names
through android.icu), not storage, so they have no entity, no DAO and no
migration. They live in data/ for the same reason data/ringtones/ does: the
thing behind them is an Android API the rest of the app must not name.
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, Ringtone.kt (the silent and bundled-tone sentinels), RepeatSummary.kt, AlarmDefaults.kt. Plain Kotlin, no Android |
domain/alarm/ |
the alarm engine's pure half: occurrences, the resolver, the ring state, the presentation and scheduling policies, the challenge gate, VolumeButtonPolicy (#18), and AlarmSetCountdown, what the "Alarm in …" chip says (#19). The shared ring vocabulary moved out to domain/ring/ in M6 |
domain/ring/ |
what both ring paths share: RingAudio (the ramp tick, the volume floor), VolumeRamp, AudioSourcePolicy/RingtoneSourceKind, RingFallbackPolicy, VibrationPattern |
domain/stopwatch/ |
the stopwatch path's pure half: StopwatchReadings (the one reading the pill, the shade and the tab share), LapStats (best and worst), StopwatchLaps (the lap cap), StopwatchNotificationPolicy |
domain/timer/ |
the timer path's pure half: TimerReadings (the app's one timer precedence), TimerExpiry (the sweep and the slot's value), TimerRingPolicy, TimerNotificationPolicy, TimerPresets, TimerDurationEntry, TimerSettings, TimerRing |
domain/time/ |
WallClock, ElapsedRealtimeClock, ZoneProvider (current() and, since M8, available(), the device's tzdata), BootId, Ticker, and for the screen saver ClockChanges (a time, zone or 12/24-hour change while collected), HourFormat and MinuteBoundary |
domain/worldclock/ |
the world clock's pure half (M8): ZoneCatalog (ZoneEntry, the pickable-id filter, the city fallback, the sort), ZoneSearch (folding and word-prefix matching), ZoneComparison (the offset and day arithmetic, OffsetLabel/DayLabel, ZoneOffsetFormat), WorldClocks (MAX, the move arithmetic) + HomeZone + LocalClock (when the device's zone gets a temporary row), AnalogFace (hand and tick angles) + DayNight (the day fraction) |
domain/interop/ |
the android.provider.AlarmClock boundary's pure half (M9): AlarmClockContract (the platform's vocabulary as literals; the only file in src/main that may hold them), IntentExtras (a Bundle modelled honestly), InteropLimits, InteropText (the label and ringtone sanitisers), InteropDeepLinks, AlarmClockRequest/AlarmSpec/AlarmSearch/InteropOutcome, AlarmClockRequests (the validator) and AlarmMatching (which alarms a search names) |
domain/text/ |
TextFolding (M9): NFD-fold, strip combining marks, lowercase through Locale.ROOT, containsFolded. One copy, shared by the zone search and the label search |
domain/live/ |
LivePillSelector and its state: which running thing the live pill is about, as a pure function |
domain/format/ |
ClockFormat (M:SS / H:MM:SS, countdowns rounded up, elapsed truncated), NextFire (NextFireLabel + NextFireFormat), the alarm row's "in 9h 12m" as data, and StopwatchFormat, the hundredths readout, split into a major field and two digits so the fraction can be drawn smaller |
domain/backup/ |
BackupContent (M10): the plain Kotlin model between data/backup's JSON codec and the repository/engine; decoupled from the wire DTOs the same way the data layer is decoupled from Room |
domain/selfcheck/ |
SelfCheck (M10): the "why might my alarm not ring?" rule table: pure functions over a DeviceState snapshot, each producing a CheckResult with a status and an optional deep-link SelfCheckFix |
domain/bedtime/ |
the bedtime schedule's pure half (issue #1): BedtimeSettings/BedtimeEffects/BedtimeLimits and BedtimeSchedule (the window a night resolves to, the active window, the due reminder, the next event). §18 |
domain/screensaver/ |
the screen saver's pure half (issue #25): ScreenSaverSettings (style and night mode), ScreenSaverDrift, the once-a-minute burn-in position as fractions of the free space, derived from the minute alone, and ScreenSaverAlarmDay, which dates a next alarm a week or more away rather than naming its weekday alone |
domain/widget/ |
the three widget presenters (M10), NextAlarmWidgetModel/TimerWidgetModel/ClockWidgetModel: selection and formatting only, so a widget's content is unit-tested without Glance |
domain/tile/ |
QuickTimerTileModel/QuickTimerTilePresenter (#26): what the quick-timer Quick Settings tile shows and what a tap does, over the quick-timer widget's own selection. The next-alarm tile needs no presenter of its own; it reads NextAlarmWidgetModel |
data/db/ |
ClockulaDatabase (@Database v4, exportSchema = true) and Migrations |
data/alarms/ |
the alarm and ring-state entities, DAOs, mappers and repositories |
data/timers/ |
TimerEntity, TimerDao, TimerMapper, TimerRepository(+Impl), TimerRingStateStore (the ring session's one DataStore record), QuickTileStateStore (which timer the quick-timer tile started, #26) |
data/worldclocks/ |
WorldClockEntity, WorldClockDao, WorldClockMapper, WorldClockRepository(+Impl), unchanged since M2; M8 is the milestone that finally calls all of it |
data/zones/ |
the ICU seam (M8): ZoneNames, IcuZoneNames (the only android.icu file in the app) and ZoneDirectory, the locale-keyed catalog cache and the DST-keyed display-name memo |
data/stopwatch/ |
LapEntity, LapDao (one COUNT(*) added in M7), LapMapper, StopwatchStateStore, StopwatchRepository(+Impl) |
data/prefs/ |
SettingsPrefs, ClockPrefs, StopwatchPrefs, TimerPrefs, UiPrefs, BedtimePrefs (issue #1), ScreenSaverPrefs (issue #25) |
data/ringtones/ |
the two ringtone seams, RingtoneCatalog (the bundled and device alarm sounds, titles, playability, a SAF grant) and RingtonePreviewer, plus their System* implementations, and MediaLibrary/SystemMediaLibrary, the picker's view of other apps' MediaBrowserService libraries |
data/time/ |
SystemWallClock, SystemElapsedRealtimeClock, SystemZoneProvider, AndroidBootIdProvider, RealTicker, AndroidClockChanges (a receiver for TIME_SET/TIMEZONE_CHANGED registered only while collected), AndroidHourFormat |
data/di/ |
DataModule, DatabaseModule, RepositoryModule, TimeModule, RingtoneModule, ZoneModule (M8; one @Binds, ZoneNames → IcuZoneNames), BackupModule (M10) |
data/backup/ |
the backup's data-layer half (M10): BackupDto/BackupCodec (the pure JSON wire format, ignoreUnknownKeys, strict on known fields), BackupDao (one @Transaction replace-all over alarms/timers/world clocks; no schema or version change), BackupRepository(+Impl), mapping through the existing AlarmMapper/TimerMapper/WorldClockMapper, never a second copy |
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 and upcoming notifications |
alarm/receiver/ |
AlarmFireReceiver, AlarmActionReceiver, SystemEventReceiver |
alarm/ring/ |
the alarm's ringing foreground service and its notifications. Its audio player and vibrator moved to ring/ in M6, shared with the timer's ring |
alarm/di/ |
AlarmModule: @Binds for the four seams |
ring/ |
RingAudioPlayer and RingVibrator: one MediaPlayer wrapper and one vibrator for both ring paths. A build rule fails on a second one |
timer/ |
TimerEngine and the three seams it talks to (TimerScheduler, TimerServiceHandle, AlarmRingStatus + AlarmStateRingStatus), plus TimerIntents, and QuickTileTimer (#26), the quick-timer tile's model and tap, through the engine and with no Android type |
timer/android/ |
the seams' Android implementations: the one elapsed-realtime AlarmManager slot, the service handle |
timer/receiver/ |
TimerExpiryReceiver (the slot arriving), TimerActionReceiver (the notification's buttons) |
timer/service/ |
TimerService (one foreground service for the countdown and the ring) and TimerNotifications |
timer/di/ |
TimerModule: @Binds for the three seams |
stopwatch/ |
StopwatchEngine and the one seam it talks to, StopwatchServiceHandle, plus StopwatchIntents |
stopwatch/android/ |
ServiceStopwatchHandle, the seam's Android implementation |
stopwatch/receiver/ |
StopwatchActionReceiver (the notification's buttons; no extras, because there is one stopwatch) |
stopwatch/service/ |
StopwatchService (the specialUse foreground service) and StopwatchNotifications |
stopwatch/di/ |
StopwatchModule: @Binds for the one seam |
interop/ |
the AlarmClock boundary's Android half (M9): AlarmClockActivity (the one exported, permission-guarded, window-less door), IntentExtrasReader (Bundle → IntentExtras), AlarmClockHandler (the orchestrator; no android.* import, ever), InteropIntents (the internal transport to MainActivity) and InteropLaunch |
bedtime/ |
BedtimeEngine (issue #1) and its three seams, BedtimeScheduler, BedtimeMode and BedtimeNotifier, plus BedtimeIntents. bedtime/android/ holds their implementations (the one wall-clock slot, the Do Not Disturb rule, the reminder), bedtime/receiver/ the BedtimeReceiver, bedtime/di/ the BedtimeModule. §18 |
system/ |
RebootRepair, the boot-id gate |
backup/ |
BackupEngine (M10), the orchestrator (a ringing alarm dismissed and running timers deleted before a replace, AlarmEngine.reschedule()/TimerEngine.resync() after it), plus BackupFiles, the SAF seam, with its Android implementation in backup/android/ |
widget/ |
the three GlanceAppWidget/GlanceAppWidgetReceiver pairs (M10) and WidgetSync, the event-driven collector (started from ClockulaApp.onCreate) that redraws all three, and since #26 the two Quick Settings tiles, on any change to the state they mirror. widget/di/ holds the @EntryPoint Glance needs, since a GlanceAppWidgetReceiver is never @AndroidEntryPoint-constructed |
tile/ |
the two Quick Settings tiles (#26): NextAlarmTileService and QuickTimerTileService, which only render and forward a click, and TileKit (opening the app, behind an unlock on a locked device, through the PendingIntent overload on API 34+; asking for a redraw). Both are active tiles, redrawn only when WidgetSync (on a change they mirror) or SystemEventReceiver (on a clock or zone change) asks the system to, never on every panel open |
ui/tile/ |
TilePreferencesActivity (#26), what long-pressing a tile opens: the quick-timer tile's duration, through the quick-timer widget's own picker |
ui/theme/, ui/crash/ |
the M0/M1 theme and the crash-report surface |
ui/shell/ |
the navigation policy (ShellNavigation), the adaptive shell, the live pill and its source/ViewModel, the notification-permission ask. EmptyTabScreen is gone: its KDoc said it existed only until each of M5–M8 replaced its own tab, and the world clock was its last caller |
ui/common/ |
rememberLocalTimeFormatter, the app's one 12/24-hour formatter, over a java.time.LocalTime, with rememberAlarmTimeFormatter delegating to it (M8 D17); and, since M6, the ringtone picker (RingtonePickerState, RingtonePickerScreen), which both editors need |
ui/alarms/ |
the real Alarms tab (M5): the routes, the list's pure row builder and its source, the two ViewModels, the list, the editor, the M3 time-picker host, the repeat-day selector and the override pickers (the ringtone picker moved to ui/common/ in M6), and M9's DismissChooser/DismissAlarmDialog, the "which alarm should I dismiss?" question; and AlarmSetAnnouncer/AlarmSetChipBand, the "Alarm in …" floret SnackChip both ViewModels raise after a user action (#19) |
ui/timers/ |
the real Timers tab (M6): the routes, the pure row builder and its source, the two ViewModels, the list, the row, the setup panel and its keypad, the editor |
ui/stopwatch/ |
the real Stopwatch tab (M7): the pure row builder (StopwatchRows) and its source, the ViewModel, the readout panel with its controls, the lap table and its column header, StopwatchDefaults |
ui/worldclock/ |
the real World clock tab (M8): the pure row builders (WorldClockRows, ZonePickerRows) and their state, WorldClockSource, the ViewModel, the tab, the analog face, the row and the zone picker, WorldClockDefaults |
ui/ring/ |
AlarmRingActivity, its ViewModel, RingScreen and the dismiss-challenge controls |
ui/settings/ |
the settings hub (M10): SettingsScreen/SettingsViewModel (appearance, alarm/timer/clock defaults, reliability, data, help), SelfCheckScreen/SelfCheckViewModel, BackupScreen/BackupViewModel, all reachable from every tab's overflow menu (ui/shell/ShellOverflowMenu, which also opens the screen saver), with no owning tab of their own |
ui/bedtime/ |
BedtimeScreen/BedtimeViewModel (issue #1), a nested destination reached from the settings hub, the bedtime reminder and the mode's page in system Settings, never a tab |
ui/screensaver/ |
the screen saver (issue #25), here rather than beside the widgets because both of its hosts host Compose: ClockulaDreamService (the system's dream; DreamViewOwner puts the lifecycle, view-model-store and saved-state owners a ComposeView needs on the dream window's decor view), ScreenSaverActivity (the same content full-screen, from the tabs' overflow menu, since an app cannot start a dream without a system permission), ScreenSaverSource (prefs + AlarmEngine.upcoming() + the clocks, waking once a minute on the minute and on a ClockChanges), the content (numerals or the world clock's WorldClockFace without its second hand, the date and the next alarm through NextAlarmWidgetPresenter), and the settings rows shared by Settings → Clock and ScreenSaverSettingsActivity, the system's gear. ui/theme/ScreenSaverTheme.kt is the dark scheme over black, dimmed towards black in night mode |
ui/widget/ |
the three widgets' @Composable Glance content (M10), the only Compose this milestone adds outside a tab; the GlanceAppWidget/GlanceAppWidgetReceiver classes themselves live in widget/, since they are manifest components, not UI. ui/widget/config/ holds each widget's config activity; every widget has one, if only for its per-instance colours (domain/widget/WidgetColors.kt: System/Light/Dark and, on API 31+, wallpaper colours on/off). Glance content takes those through WidgetTheme; the plain-RemoteViews faces, readouts and chronometers through WidgetPalette.kt, which resolves the widget_* palette under a forced day/night (the app's own tones are widget_app_*) and sets concrete colours, or day/night pairs, on the views. The analog and digital clocks are re-coloured on API 31+ only, since below that AnalogClock's hands and a shape's background tint can't be set through RemoteViews |
4. The data model
ClockulaDatabase is at version 4 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, local dates as epoch days, 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, and the result is a crashed list instead of 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. For ringtone_uri: NULL = inherit, clockula://silent = deliberate silence (§13), clockula://tone/timer_expired = the sound bundled in the APK (resolved by BundledTones, never Uri.parsed directly), clockula://media?… = another app's track (§13), anything else a content URI |
delete_after_use |
INTEGER | v3 (M9): a transient alarm the AlarmClock contract asked for. Deleted rather than disabled when its cycle closes (§17) |
scheduled_date |
INTEGER? | v4: a one-shot's date, as LocalDate.toEpochDay(). A local date, never an instant, so no zone is baked in (§11, "A date and a pause") |
pause_start, pause_end |
INTEGER? | v4: a repeating alarm's pause, both ends inclusive epoch days. One without the other reads as no pause |
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, which is 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.
AlarmDao is an abstract class rather than an interface, so it can carry
a @Transaction open suspend fun updateWithin(id, transform): a read, a
transform and a write as one unit, mirroring TimerDao.updateWithin.
AlarmRepository.edit(id, transform) is its domain face: it stamps updated_at
from the wall clock and forces the id, so a transform can change a field but
never move a row. The editor writes through it exclusively, because the engine
can call setEnabled(id, false) underneath an open editor when a one-shot
alarm's cycle closes, and a whole-row write from a stale read would re-enable
an alarm that had just rung. M5 changes no schema: no column was added, no
version bumped, no migration written; a DAO becoming an abstract class changes
no SQL.
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 never a future occurrence.
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, record the skipped occurrence as the handled one, and still return the first candidate after it, so the skipped occurrence cannot ring on its way out through the grace window, on this pass or the next (#16).
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; otherwise the same values as the alarm's ringtone_uri |
sort_order |
INTEGER | indexed |
delete_after_use |
INTEGER | v3 (M9): a transient timer the contract asked for. Deleted rather than returned to IDLE when it is dismissed (§17) |
created_at, updated_at |
INTEGER | wall-clock epoch millis |
M6 changes this table by not one column. No migration, no version bump: the
database stays at v2. The only new persistent fact the Timers tab needs is
when the current ring session began sounding, and that is a single record,
which §5 of docs/PLAN.md sends to DataStore rather than to Room. It is not a
timers column for the three reasons this section already gives: volatile ring
state must not appear in M10's JSON backup of a timer, it must not survive the
timer's deletion, and the timer mapper's tests should stay about timers. It is
not a timer_states table either, because at most one timer ring sounds at a
time, so it is timer_ring_sounding_since_millis behind TimerRingStateStore
(§6).
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 between them
(or delete the row that won), 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. The ids are 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.
M8 changes no schema here. M2 built exactly the surface the world clock
needs, so the milestone that finally uses this table adds no column, no version
bump and no migration, and WorldClockEntity, WorldClockDao,
WorldClockMapper and WorldClockRepository(+Impl) are not edited at all. What
it does is give all three of label, sort_order and the unique zone_id
their first caller: a row shows the user's label when it has one and the
ICU city otherwise, sort_order carries the hand reorder (reorder's first
caller, from both a drag and the accessibility actions), and zone_id's
uniqueness is what lets the picker leave an already-added zone tappable. The
read path M2's comment promised is real too: a zone the tzdata dropped is kept
and drawn with no time, no offset and no day (§16).
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.
M7 changes no schema here. The table already had every column the tab
needs, so the stopwatch milestone adds no column, no version bump and no
migration; LapDao gains exactly one @Query("SELECT COUNT(*) …"), which the
engine's lap cap reads so appending a lap never materialises 999 rows. The
database stays at version 2.
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.
The platform AlarmClock.EXTRA_DAYS contract speaks java.util.Calendar
constants (Sunday = 1 … Saturday = 7). That translation happens at the intent
boundary and never in storage. M9 put it in one private function,
AlarmClockRequests.dayOfWeek, which hands its Set<DayOfWeek> to
RepeatDays.of so the 7-bit mask is never built by hand.
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, so 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.
v2 → v3 (M9). MIGRATION_2_3 is two ALTER TABLE … ADD COLUMN delete_after_use INTEGER NOT NULL DEFAULT 0, one on alarms and one on
timers. The column exists because the AlarmClock contract says twice that a
SKIP_UI alarm or timer "should be removed after it has been dismissed";
without it, a user who talks to their assistant every morning accumulates a list
of dead 06:30 rows (§17). Both entity columns declare
defaultValue = "0". That is load-bearing: without it the exported schema
records no default, runMigrationsAndValidate compares it against a migrated
table that has one, and the migration test fails for a reason nobody enjoys
finding. Every row written before M9 therefore reads as the permanent alarm or
timer it was.
v3 → v4 (#21). MIGRATION_3_4 adds three nullable INTEGER columns to
alarms: scheduled_date, pause_start and pause_end, with no default, as
the entity declares them, so every alarm written before v4 reads as unscheduled
and unpaused. A date is only ever stored on a non-repeating alarm and a pause
only on a repeating one; AlarmMapper reads the other combination, or an epoch
day outside AlarmDates (2000-01-01 to 9999-12-31), as absent rather than
throwing.
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, and 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".
The mirror image of the reboot repair is the clock change. A reboot kills
the monotonic anchors and leaves endsAtWallClock standing;
ACTION_TIME_CHANGED/ACTION_TIMEZONE_CHANGED does the opposite: the
monotonic anchors still hold and the wall-clock one has just become a lie. That
value is the countdown notification's chronometer base and
the reboot fallback, so a stale one makes a running timer's notification read
finished and a reboot after the change ring the timer at the wrong minute. So
TimerEngine.onSystemTimeOrZoneChanged calls reanchorWallClocks, which
re-derives endsAtWallClock for every RUNNING row from what its monotonic
anchor says is left, writes nothing when the value has not moved, and leaves a
row whose anchor is already stale to repairAfterReboot. Nothing is
rescheduled: the expiry slot is ELAPSED_REALTIME_WAKEUP, so a clock change
cannot move it.
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 ("+1 min") is one rule over all four states, and the extra is
measured from the tap, never from an anchor that has gone by:
| Before | After |
|---|---|
RUNNING |
still running; both end anchors rebased on snapshot.remaining + extra, from one reading of both clocks |
PAUSED |
still paused, remaining + extra. The user paused deliberately |
EXPIRED |
RUNNING with exactly extra left, all three anchors written, in the same one transaction |
IDLE |
nothing. A stale notification button is a silent no-op |
duration_millis is never moved on any branch, so reset still returns the
timer to the length the user configured, which is what makes a timers row its
own preset.
M6 rewrote the EXPIRED branch, which M2 left "paused, not running: the user
still has to press start". "+1 min" on a timer that has just rung is the single
most common gesture in a timer app, and a second tap on Start is a papercut.
Resuming in the same transaction is also the only way to avoid emitting an
intermediate PAUSED frame to the live pill and to the row. The reasoning
is M2's own, generalised: the user asked for extra more than zero, not
extra more than an anchor that has 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, because 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, counting 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.
M7 closes the loop. RebootRepair, pauseAfterReboot and snapshotAt are
untouched; what M7 adds is what the user then sees, and it is decided in one
pure place, StopwatchReadings.of (§15). The rule it carries is that the mode
comes from the snapshot, not from the stored row: a RUNNING record whose
anchor did not survive reads PAUSED at what it banked, so a stale run shows
Resume · Reset rather than a Pause button and a ticking chronometer, on all
three surfaces at once.
One honest consequence, recorded rather than papered over. A lap recorded inside
a segment the repair later discarded keeps its own cumulative, which can exceed
the run's banked accumulated: a stopwatch that ran forty seconds and lapped
twice, then met a reboot, has accumulated = 0 and lastLapCumulative = 40s.
The reading is therefore floored at the last lap's total as well as at zero:
a readout sitting below a lap the same run printed is not one anyone can
believe, and the lap is a real measurement. The laps themselves are never
deleted to make the arithmetic tidy, and the in-progress row's split is clamped
at zero for the same reason. Reset clears all of it in one tap.
The floor is banked, not only displayed. Resuming such a run writes
accumulated = max(accumulated, lastLapCumulative), and pausing and lapping
bank the same floored reading rather than the raw snapshot. Without that the
record would tick from below its own last lap: the tab would sit frozen at the
floored value for as long as the discarded segment lasted while the shade's
chronometer (derived from the floored reading once and then free-running)
counted on without it, and the next lap would record a cumulative below the
previous one and drag every readout backwards. pauseAfterReboot itself is
still untouched; the banking happens on the way back out, in
StopwatchRepositoryImpl.start, pause and lap.
For the same reason the engine's verbs guard on the reading's mode rather
than the stored row's, so they agree with the controls the user is being
offered: between a reboot and the repair, a record that still says RUNNING
reads PAUSED everywhere, and its Resume button resumes (banking first, as the
repair would have) instead of hitting a silent "already running" no-op, while
Lap is refused rather than recorded below the last one.
M8 adds a wall-clock surface. The world clock is a calendar fact about
somewhere else, so it reads WallClock.now() and never ElapsedRealtimeClock,
the same column of the table above that alarms sit in. Both the instant and the
device zone are re-read on every tick, from the injected WallClock and ZoneProvider. Since #23 the
tick is a minute long unless seconds are on, so TIME_SET and
TIMEZONE_CHANGED also restart it through ClockChanges, a receiver the tab
registers while it collects (§16, "The cadence"); SystemEventReceiver still
needs no new branch. The zone never
comes from ZoneId.systemDefault() at a call site; a build rule (§8) fails on
one anywhere under domain/ or ui/.
6. Preferences
One DataStore file, clockula_prefs, behind floret-kit's typed PrefStore.
Six 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_timer_vibrate, default_timer_volume_ramp_seconds (#22), default_dismiss_challenge, home_zone_id, volume_buttons_while_ringing (#18; app-wide, no per-alarm override), automatic_local_clock (#24) |
ClockPrefs, surfaced as SettingsPrefs.defaults: Flow<ClockDefaults> |
| World clock display (#23) | world_clock_show_seconds |
ClockPrefs, surfaced as its own SettingsPrefs.worldClockSeconds, not a clock default, so flipping it re-emits nothing alarms and timers watch |
| Stopwatch run record (M2) | stopwatch_state, stopwatch_started_elapsed_millis, stopwatch_accumulated_millis, stopwatch_last_lap_cumulative_millis (M7 is the first caller of the last, for the in-progress lap's split) |
StopwatchPrefs, behind StopwatchStateStore |
| System facts (M3) | last_boot_id |
SystemPrefs, behind BootStateStore, the persisted half of the reboot gate |
| Timers (M6) | timer_presets, timer_ring_sounding_since_millis |
TimerPrefs: the presets surfaced as SettingsPrefs.timerPresets, the ring session's anchor behind TimerRingStateStore |
| Bedtime (issue #1) | bedtime_enabled, bedtime_start_minutes, bedtime_wake_minutes, bedtime_days, bedtime_reminder_minutes, bedtime_effect_grayscale, bedtime_effect_dim_wallpaper, bedtime_effect_dark_theme, bedtime_effect_hide_aod, bedtime_applied_active, bedtime_rule_stale, bedtime_state_owed |
BedtimePrefs, surfaced as SettingsPrefs.bedtime: Flow<BedtimeSettings> and written as one record by BedtimeEngine only. Times clamp into a day, the reminder into 0–120 minutes, the days through RepeatDays.fromMask. The system rule's id is not stored (§18) |
| Screen saver (issue #25) | screen_saver_style, screen_saver_night_mode |
ScreenSaverPrefs, surfaced as SettingsPrefs.screenSaver: Flow<ScreenSaverSettings> and carried in the backup |
| Quick-timer tile (#26) | quick_tile_duration_millis, quick_tile_timer_id |
TimerPrefs: the duration (a backed-up setting) surfaced as SettingsPrefs.quickTileDuration, the transient timer the tile started (volatile, not backed up) behind QuickTileStateStore |
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. 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.
home_zone_id got its first reader and its first writer in M8, two
milestones after the key was added. The reader is HomeZone.resolve(stored, deviceZone), which is the whole home-zone rule in one pure function (the
stored zone when the device still knows it, else the device's own), and the
writer is the World clock tab's top-bar action, which also clears it so home
follows the device again. Nothing is seeded: a fresh install has no stored home
zone and no world clock row, and the hero face simply shows the device's zone
(§16).
timer_presets is the same discipline over a list: one comma-joined string of
millis, sanitised both ways through TimerPresets.sanitise: non-numeric
entries dropped, anything outside 1 s…24 h dropped rather than clamped
(a clamp would silently turn one preset into another, possibly a duplicate),
de-duplicated, sorted ascending, capped at twelve. An absent key means the
built-in set (1, 2, 3, 5, 10, 15, 30 min, 1 h); an explicitly empty value
means the user removed them all, and is honoured. Adds and removes go through
PrefStore.update, so two concurrent edits cannot swallow each other.
timer_ring_sounding_since_millis is wall clock, deliberately: "how long has
this been making noise" is a question a reboot must not reset, which is why
alarm_states.ringing_since is wall clock too. §5's elapsed-realtime rule
governs the countdown, not the ring's age. Null (the key absent) means no
session, and the engine writes it only when the value changed.
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. Nine 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 |
RingtoneModule |
@Binds for the two ringtone seams, RingtoneCatalog and RingtonePreviewer, and for MediaLibrary |
ZoneModule |
@Binds for the one ICU seam: ZoneNames → IcuZoneNames (M8). ZoneDirectory needs no binding; it is a @Singleton @Inject class |
StopwatchModule |
@Binds for the stopwatch engine's one seam: StopwatchServiceHandle (the foreground service). It has no scheduler, because nothing the stopwatch does has a deadline |
TimerModule |
@Binds for the timer engine's three seams: TimerScheduler (the one elapsed-realtime slot), TimerServiceHandle (the foreground service), AlarmRingStatus (read-only: "is an alarm ringing") |
BedtimeModule |
@Binds for the bedtime engine's three seams (issue #1): BedtimeScheduler, BedtimeMode, BedtimeNotifier |
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. All five
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.
SystemRingtoneCatalog is the first documented exception: it takes
@IoDispatcher, because a raw ContentResolver query dispatches nothing
of its own and would otherwise block whichever thread asked. The same goes for
RingtoneManager's cursor and for MediaPlayer.prepare in
SystemRingtonePreviewer.
ZoneDirectory (M8) is the second, for the same reason: building the zone
catalog is ~450 ids times three ICU calls, ICU dispatches nothing of its own,
and the build happens the first time a picker opens. It is also cached on the
locale tag (AppCompat's per-app language can change under a running activity)
and guarded by one Mutex, so two collectors opening the picker at once build
once.
8. Testing
JVM-first. 1 139 unit tests run in the gate; 40 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/, domain/ring/ or domain/timer/.
That posture had to be earned again when the UI arrived, not merely kept. The
shell's whole decision surface was pushed out of the composables and into pure
Kotlin: ShellNavigation (what a tab tap does to the back stack, and where back
goes), LivePillSelector (which of several running things the pill is about),
ClockFormat (the readout) and ChallengeGate + MathProblems (the barrier in
front of a dismissal, and the promise that it always opens). What is left in the
composables is Text(...) over those values. The two ViewModels that need a main
dispatcher get one from a twenty-line JUnit 5 MainDispatcherExtension.
What genuinely needs hardware is written as the nine new instrumentation tests
and honestly labelled as such: a window really appearing over a lock screen with
FLAG_KEEP_SCREEN_ON set, a real system back press, a real activity recreation,
and a TalkBack-shaped accessibility click completing the hold challenge.
- 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 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 rather than a fake's memory: a second repository is constructed over the same file. - 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, v2 → v3 and v3 → v4 migrations validated against the exported schema, and the database opening at its current version. 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 it turns the engine's testability into a build-gate fact rather than a habit.
M5 kept the posture for a whole form, which is where a screen usually
reaches for Robolectric. Everything a tap decides was pushed out of the
composables: AlarmListRows (what the list shows, in what order, which row is
"next"), NextFireFormat ("in 9h 12m"), RepeatDaysSummary/RepeatDaysOrder
("Weekdays", and which day column comes first), AlarmDefaults (a new alarm's
time, resolved through a spring-forward night), AlarmOverrides (inherited or
chosen), RingtonePicker (which rows exist and which is checked),
AudioSourcePolicy (the fallback order, silence included) and AlarmRoutes
(the route strings). Every write, every reschedule and every preview is a method
on one of the two ViewModels, which the tests build over the real
AlarmEngine across the fake DAOs and fake seams, the arrangement
testing/AlarmEngineHarness.kt now lifts out of AlarmRingViewModelTest. What
is left in the composables is Text(...), Switch(...) and stringResource.
Three ArchitectureRulesTest rules pin that mechanically: no *ViewModel.kt,
*UiState.kt or *Source.kt imports androidx.compose.; the same files import
no android.; and no file under ui/ names AlarmResolver or
AlarmOccurrences: the screen takes the engine's resolution and re-derives
none of its time arithmetic. All three held retroactively for M4's files, so
they pin existing discipline rather than an aspiration.
What was left to the device is the seven new instrumentation tests: the row's switch really toggling and the row really dimming, the FAB creating an alarm and landing in its editor, the M3 time picker writing a typed time, a day toggle changing the repeat summary, a cancelled system document pick changing nothing, back returning to a still-selected Alarms tab, and the delete confirmation removing the row. They have not been run: no device is attached to the machine this milestone was built on, so the gate compiles them and this paragraph says so.
M6 kept it for a second screen and for a foreground service, which is the
other place a project usually gives in. Every decision left the composables and
the service: TimerReadings (the app's one timer precedence, extracted from
M4's pill so the pill, the notification and the ring cannot disagree),
TimerExpiry (what is due, and what the single slot should hold),
TimerRingPolicy (the whole audio arbitration), TimerNotificationPolicy
(the subject, the count, the two actions, the alert-once rule), TimerPresets,
TimerDurationEntry (the keypad, as a state machine over a digit string) and
TimerListRows. TimerService holds no policy and no state beyond one
alreadyAlerted flag: it asks TimerEngine.serviceState(...) and actuates the
answer, and even the two delay amounts come from the engine, so the timings
are asserted in a JVM test.
Three new fakes complete the seam set in M3's shape: FakeTimerScheduler holds
the expiry slot as the value it currently holds, FakeTimerServiceHandle
records the up/down transitions in order, and FakeAlarmRingStatus is a
settable boolean behind a MutableStateFlow, so an arbitration test hands the
engine a true instead of building alarm state.
testing/TimerEngineHarness.kt is AlarmEngineHarness's arrangement for the
other engine: the real TimerEngine over the real TimerRepositoryImpl,
the fake DAO, the three fake seams, the real RebootRepair and a real DataStore
under a @TempDir, with a counting DataStore wrapper, so "the session anchor
is persisted once across three reads" is assertable without reaching into
the store.
Three more ArchitectureRulesTest rules: timer/TimerEngine.kt joins the
Android-free list; no file under ui/ uses TimerScheduler, TimerService,
TimerRingPolicy or AlarmManager (the mirror of the alarm rule: the screen
schedules nothing and decides no ring); and MediaPlayer appears only under
ring/ and data/ringtones/, which makes "reuses M3's audio path" mechanical:
a third audio path cannot be added without the build failing.
What was left to the device is four more instrumentation tests: the keypad and
the bottom sheet really composing and really writing, the typed digits
surviving a real activity recreation (the only thing that proves
rememberSaveable), one row's Pause leaving a second running row alone, and a
real back press from the timer editor landing on a still-selected Timers tab.
They have not been run, for the same reason M5's have not: no device is
attached to the machine this milestone was built on.
M8 kept it for a fourth screen, and it was the milestone most tempted to
break it, because ICU and Canvas both look like they need a device. They do
not. Everything ICU answers is behind ZoneNames, faked in a unit test.
Everything the face draws is DrawScope calls over angles from AnalogFace
and a fraction from DayNight, two pure objects with no collaborators, so the
geometry is asserted at every minute of the day without a pixel. Everything the
list and the picker decide is pure too: WorldClockRows, ZonePickerRows,
ZoneSearch, ZoneComparisons, ZoneCatalog and WorldClocks. And everything
the tab writes is a WorldClockViewModel method, tested over the real
WorldClockRepositoryImpl across FakeWorldClockDao and the real
SettingsPrefs over a real DataStore under a @TempDir.
One new fake and one new harness. FakeZoneNames is settable per method, counts
calls per method (which is what makes "the catalog is built once" and "the
display name is asked for once per DST phase" assertable), and carries a
failEverything flag that makes every call throw, which is what makes "the
directory never propagates an ICU failure" assertable. WorldClockHarness is
StopwatchEngineHarness's arrangement for a tab with no engine.
Three more ArchitectureRulesTest rules: android.icu is named only under
data/zones/, so the ICU seam stays one file; MaterialShapes and
androidx.graphics.shapes are named only under ui/worldclock/, which makes
PLAN.md §8's "not sprinkled everywhere" mechanical (the ring screen and timer
progress are sanctioned too, so widening the allowlist is a deliberate edit
rather than a drift); and no file under domain/ or ui/ uses
ZoneId.systemDefault() or TimeZone.getDefault() in code; the zone is a
parameter, from ZoneProvider. All three held retroactively for M0–M7's files.
Four more instrumentation tests: the hero face really composing and its
semantics really reaching TalkBack, the picker's real Dialog and real IME
really adding a city, and the Remove and Move up accessibility actions
really working the way TalkBack would drive them. They have not been run:
no device is attached to the machine this milestone was built on.
M9 kept it for a boundary, which is the last place a project usually gives
in, because "it is an Intent" sounds like it needs a device. It does not. The
door is split so that everything which decides is pure Kotlin under
domain/interop/ (AlarmClockRequests for every range, type and sentinel rule,
AlarmMatching for which alarms a search names, IntentExtras, InteropText,
InteropDeepLinks), and the Android half is two files that carry no policy:
one turns a Bundle into a Map<String, Any?>, one turns an outcome into an
Intent. AlarmClockHandler is the orchestrator and, like the three engines,
contains no android.* import, so the whole contract is asserted from a plain
JUnit test with hand-built inputs, including an intent hostile in every extra
at once.
One new harness, and it is deliberately not a composition of the existing
ones. AlarmEngineHarness, TimerEngineHarness, StopwatchEngineHarness and
WorldClockHarness each build a DataStore over the same file name under the
test's @TempDir, and DataStore refuses a second instance over a live file, so
composing two of them throws. InteropHarness builds one PrefStore, one
SettingsPrefs, the fake DAOs, the fake seams, the real AlarmEngine, the
real TimerEngine and the real repositories over them, and exposes the
handler built from those. FakeAlarmDao also grew an onDeleted hook the
harnesses wire to alarm_states, because a fake cannot infer ON DELETE CASCADE from a schema it does not have, and M9 is the first milestone that
deletes an alarm from inside the engine.
Four more ArchitectureRulesTest rules: interop/AlarmClockHandler.kt joins
the Android-free list; the platform's AlarmClock literals appear only in
domain/interop/AlarmClockContract.kt, so a second file cannot hardcode
"android.intent.action.SET_ALARM" and drift; alarm/android/AndroidAlarmScheduler.kt
never names AlarmRingActivity in code, which pins the show-intent fix (§11);
and no file under ui/ uses AlarmClockHandler, AlarmClockRequests or
IntentExtras: the screen parses no intent from another app.
And one new ManifestRulesTest, which reads app/src/main/AndroidManifest.xml
as a file the way ArchitectureRulesTest reads sources: exactly two exported
components, the permission on the door and not on MainActivity, all seven
actions present, both intent-filters present (one without a <data> element and
one with the clockula scheme), the door's recents/history/affinity attributes,
every receiver still unexported, and the requested-permission set unchanged. None of
the manifest fails a compiler, so it is where this milestone could be silently
wrong, and the test makes it fail here instead.
Five more instrumentation tests: our literals really being the platform's own
android.provider.AlarmClock constants (the one comparison only a device can
make), each of the seven actions really resolving to the door, a deeplinked
DISMISS_ALARM really matching the second filter, the chooser dialog really
composing and a tap really dismissing, and the door really reaching DESTROYED
without showing a window, plus two migration cases for v2 → v3. They have
not been run: no device is attached to the machine this milestone was built
on.
Stack: JUnit 5 + Truth + Turbine + kotlinx-coroutines-test, with
useJUnitPlatform() and isReturnDefaultValues = true. The JVM suite is
1 436 tests; 47 instrumentation tests compile in the gate.
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).
M8 adds one dependency coordinate: androidx.graphics:graphics-shapes,
pinned at 1.0.1, the version that already resolved transitively through
androidx.compose.material3. MaterialShapes lives in material3, but Morph
and RoundedPolygon are androidx.graphics.shapes, and the world clock's face
imports that package directly. A direct import on an undeclared transitive
dependency is how a Renovate bump breaks a build nobody declared. Pinning to
what already resolved means the dependency graph does not move in this
milestone.
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, snoozed and upcoming-alarm notifications |
FOREGROUND_SERVICE, FOREGROUND_SERVICE_SYSTEM_EXEMPTED |
the two ringing services: the alarm's, and (since M6) the timers' |
WAKE_LOCK |
keep the CPU up for the length of a ring |
VIBRATE |
an alarm and a timer both vibrate, and vibrate alone when no audio source opens |
ACCESS_NOTIFICATION_POLICY |
issue #1's opt-in bedtime mode. Install-time, and on its own it does nothing: it lists Clockula under Special app access › Do Not Disturb, which the user grants by hand. With that grant Clockula adds one Do Not Disturb rule of its own, which always lets alarms through (§18) |
M6 adds no permission at all. A timer never takes over the screen (no
full-screen intent, no ring activity, no showWhenLocked), because its user set
it minutes ago and is in the room, so the heads-up notification, the audio, the
pill's expired state and the tab's expired row are four ways to notice and none
of them seizes the device. The foreground-service, wake-lock, vibrate and
notification set above already covers the rest.
Neither of the two revocable ones can silence an alarm. A denied
POST_NOTIFICATIONS costs the user the notification and nothing else, because 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.
Both ringing services' 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. It is not mediaPlayback, because an alarm
is not the user's media session and it would owe the store a media justification,
and it is emphatically not shortService, which caps at about three minutes against a
ten-minute ring window.
All five 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.
M8 adds no permission and no manifest entry at all. ICU, java.time and
Compose drawing need none: the world clock schedules nothing, wakes nothing,
rings nothing and posts no notification.
M9 adds no <uses-permission> either. Its one new permission string is
android:permission on the new activity: a requirement the caller must
hold, never one Clockula requests. com.android.alarm.permission.SET_ALARM is
what AlarmClock's own documentation asks an implementation to demand; it is
protection-level normal, so any app that declares it is granted it at install,
which costs a legitimate caller nothing and keeps a drive-by startActivity
from an app that never declared an interest in alarms out.
interop/AlarmClockActivity carries the contract's filters rather than
MainActivity, because android:permission on an activity is checked against
every caller, the Launcher included: putting the filters on MainActivity
would mean either dropping the permission or making the app unlaunchable.
MainActivity's filter set is still exactly MAIN/LAUNCHER.
The door declares two <intent-filter> blocks, because with one the matching
is silently wrong: a filter with no <data> element matches only
intents whose data is null, and a filter that declares a scheme matches only
intents that have data. DISMISS_ALARM and DISMISS_TIMER may arrive either
way, so filter A carries all seven actions with no data element, and filter B
carries those two plus <data android:scheme="clockula"/>.
android.intent.category.VOICE is deliberately not declared: it advertises
the VoiceInteractor follow-on flows, which v1 does not implement (§10). The
activity is excludeFromRecents, noHistory, taskAffinity="" and themed
Theme.Clockula.Invisible (translucent, no title). It has no content view, so
nothing flashes on the way through.
Components: MainActivity, the exported and permission-guarded
interop.AlarmClockActivity, the non-exported CrashReportActivity and
AlarmRingActivity, three foreground services (AlarmRingService, M6's
TimerService, M7's StopwatchService), five non-widget receivers
(AlarmFireReceiver, AlarmActionReceiver, SystemEventReceiver,
TimerExpiryReceiver, TimerActionReceiver), AppCompat's locale metadata
holder service, and, since M10, three GlanceAppWidgetReceivers
(NextAlarmWidgetReceiver, TimerWidgetReceiver, ClockWidgetReceiver).
ManifestRulesTest pins the exported set exactly: the two above, plus the
three widget receivers, each with only the platform-required
APPWIDGET_UPDATE filter and no permission, since APPWIDGET_UPDATE is a
platform-delivered broadcast no other app can trigger by naming the action.
M10 adds no <uses-permission>.
The screen saver (issue #25) adds ui.screensaver.ClockulaDreamService,
exported with only the android.service.dreams.DreamService filter and
guarded by BIND_DREAM_SERVICE, which only the system holds; its
android.service.dream meta-data (res/xml/screen_saver_info.xml) names
ScreenSaverSettingsActivity, exported with no intent filter so the Settings
app can open it by name; and the unexported ScreenSaverActivity. No
<uses-permission> either: keeping the screen on is a window flag, not a
wake lock.
10. What is not built yet
| Not here | Milestone |
|---|---|
Reordering alarms by hand. The list is ordered by time of day (§13) and alarms has no sort_order column, so this would be a schema change for a preference the time order already answers |
not planned for v1 |
A per-alarm auto-silence duration. Every other per-alarm setting became an override picker in M5; "Silence after" (#17) is an app-wide setting only, because an override would need a new alarms column, a migration and an editor row for a value almost nobody sets per alarm |
not planned |
Voice-interaction follow-on flows: Activity.isVoiceInteraction, VoiceInteractor.CompleteVoiceRequest (publishing a deeplink to the alarm just created) and PickOptionRequest (disambiguating by voice). android.intent.category.VOICE is therefore not declared: advertising it while answering with a touch dialog would be a promise the app does not keep. Clockula accepts a clockula:// deeplink and publishes none |
not planned for v1 |
A "most closely matched" time search for DISMISS_ALARM. ALARM_SEARCH_MODE_TIME matches exactly: dismissing an alarm the caller did not name is a missed alarm, and there is no distance at which that becomes a good trade |
not planned for v1 |
A toast or any other confirmation on a SKIP_UI write. SKIP_UI means "bypass any intermediate UI", a voice assistant speaks its own confirmation, and the status-bar alarm icon setAlarmClock lights is the platform's own receipt (§17) |
not planned for v1 |
Surfacing delete_after_use in either editor. It is a property the contract sets, not a setting; an alarm the user edits keeps it, and that consequence is documented (§17) rather than papered over with a checkbox |
not planned for v1 |
Carrying EXTRA_MESSAGE into the timer setup panel when no length was given. There is no row to carry it on and the panel has no label field |
not planned for v1 |
Verifying a ringtone URI at the intent boundary. M5 locked "an unreadable sound is reported, never rewritten"; an unmounted card comes back, and the boundary opens no ContentResolver |
not planned for v1 |
| An unlimited-snooze option, per-alarm auto-silence | not planned for v1 |
The volume-button setting (#18) while the alarm rings only as a heads-up over another app, or with the screen turned off (the system keeps volume keys from a non-interactive device's windows). A background app sees no key events; the one route, a remote-playback MediaSession whose VolumeProvider takes the keys, is refused by MediaSessionRecord.canHandleVolumeKey on Android 13+ for an app with no MediaRouter2 routing session, loses to whichever app is in front or played last, and shows a cast-volume slider and a phantom media player while it works. AOSP DeskClock and BlackyHawky's Clock handle the keys in their alarm activity only, as AlarmRingActivity does |
not planned |
Reordering timers by hand. sort_order and TimerRepository.reorder exist and stay caller-less: the list is in storage order, M5 gave the same answer for alarms, and a drag surface would also mean abandoning the LazyColumn |
not planned for v1 |
Per-timer vibrate, volume-ramp or dismiss-challenge overrides. timers has exactly one nullable settings column, its ringtone; four more would be a schema change for settings the roadmap does not ask for |
not planned for v1 |
| A "stop all" for several expired timers. Each expired timer is acknowledged on its own | not planned for v1 |
A presets management screen (add/remove the quick-start durations themselves). M10's settings screen edits default_timer_ringtone_uri, but the preset list is still only editable from the two-gesture surface on the setup panel |
not planned for v1 |
| A dismiss challenge or a snooze for a timer. (A timer that ramps its volume landed with #22, as an app-wide timer setting.) | not planned for v1 |
Surfacing TimerSnapshot.anchorIsStale in the UI. The boot repair makes it vanishingly rare and the value is already honest; a row does not say "estimated" in v1 |
not planned for v1 |
| Lap export, sharing, deletion or editing, and any "previous run" history. A run's laps belong to the run that produced them; keeping old runs is a different feature with its own table | not planned for v1 |
| A Lap button on the live pill. The pill has two controls by design, and a third would rewrite a contract M4 asserted | not planned for v1 |
| A user-configurable lap cap or readout precision. Both are constants with reasons (999 laps, hundredths), and M10's settings screen is not asked to carry them | not planned for v1 |
| Reordering, grouping or filtering laps. The list is index order, newest first, and that is the whole of it | not planned for v1 |
| A countdown-to-start, split-lap alarms, or any sound or vibration from the stopwatch. It is silent, and a build rule says so (§8) | not planned for v1 |
An analog stopwatch face or a MaterialShapes showpiece on the Stopwatch tab. PLAN.md §8 sanctions the showpiece for the analog world-clock face, the ring screen and timer progress; a stopwatch has no progress to be a fraction of |
not planned for v1 |
Surfacing StopwatchReading.anchorIsStale in the UI, for the same reason the timers' is not: the repair makes it momentary, and the value displayed is already honest |
not planned for v1 |
A rename UI for world_clocks.label. The column is honoured on read (a row shows the label when it has one), and M10's backup now carries it on export/import, but there is still no way to write a label in-app |
not planned for v1 |
A per-city detail screen, a map, sunrise/sunset times or a day-length bar. DayNight is a stated convention for a shape morph, not an ephemeris (§16) |
not planned for v1 |
| Live times or long zone names in the zone picker. 450 rows of ICU long names is real work for a list the user scrolls past, and a ticking picker is a ticking picker | not planned for v1 |
Reordering world clocks by anything but sort_order: no grouping by continent, no sort-by-offset, no pinning. The list is the order the user put it in |
not planned for v1 |
MaterialShapes on the ring screen or timer progress. PLAN.md §8 sanctions both; neither is rebuilt in M8, and the build rule (§8) makes widening the allowlist a deliberate edit |
not planned for v1 |
| Screenshots and store listing polish | M11 |
Also absent by design: any seeded default data. There is no starter alarm and no home world clock on first run, so the empty states are real empty states.
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). The app ships one sound, "Timer expired", but only as a choice in the picker, not as a rung of the fallback chain.
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 the alarm editor. 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.
The "Alarm in …" chip (#19) is built on upcoming() too.
AlarmSetAnnouncer, an app-wide singleton, records only which alarm a user
action in the list or the editor just set; while a resumed screen shows it,
what it says is derived from upcoming(), through AlarmSetCountdown (pure,
in domain/alarm/): only a natural occurrence of an enabled alarm, rounded as
the row's countdown is, never a snooze or a ring. So an alarm switched off or
deleted by any path is never announced as set, and the AlarmClock intents,
which already get the status-bar icon, never reach it. The announcement is
state, standing until a screen has shown it to the end: pick → OK → back
disposes the editor's chip a moment after it appears, and the list
underneath picks it up. The editor announces a time pick at once and waits a
second for repeat-day taps to settle, so Mon–Fri is one chip; the settle,
and the editor's fire-time-moving writes with their reschedule, run in tap
order on the application scope, so leaving the editor at once neither loses
the announcement nor leaves a stored time unscheduled. An announcement older
than ten seconds on the monotonic clock is dropped unshown.
upcoming(refresh: Flow<Unit>) takes its re-read cadence as a parameter.
The resolver needs a now, and upcoming reads one inside a combine of the
two repository flows, so a countdown built on it would freeze until the next
database write. Making the cadence a parameter keeps PLAN.md §5's discipline
(time is a parameter, never an ambient read) without giving the engine a
Ticker and an Android-shaped dependency: the default, flowOf(Unit), means
"resolve once per repository emission" and leaves every existing caller alone,
while the alarms screen passes ticker.ticks(30.seconds). Re-resolving is still
read-only, and a test collects several refreshes and asserts alarm_states
recorded nothing.
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 the "Silence after" window ⇒
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 the "Silence after" window. That is a setting since #17 (Settings › Alarms: 1, 5, 10, 15, 20 or 25 minutes, or Never), 10 minutes by default, AOSP DeskClock's default and v1's fixed value, so an upgrade changes nothing. Snoozing keeps it open.
The window is read from ClockDefaults.alarmSilenceAfter everywhere a ring is
measured: the resolver's ringing check, the backstop onFire arms, and whether
a ring interrupted by a reboot is resumed. AlarmResolver.resolve takes it
without a default, so no caller can measure against a window the user did not
choose. How late a snooze may come due and still ring is the window or
AlarmRing.SNOOZE_GRACE (v1's ten minutes), whichever is longer: a short
"Silence after" must not make a promised snooze easier to lose to a reboot.
"Never" is an hour. The backstop is also what stops a phone left at home
from ringing until its battery is flat, what bounds the ring services' wake
locks, and what keeps a reboot hours later from resuming a ring nobody was
there for. So "Never" maps to SilenceAfter.HARD_STOP (60 minutes), longer
than any finite choice, and the settings hint says so in words. Stored values
go through SilenceAfter.sanitise on read, on write and from a backup: 60 or more
is "Never", so no value can make a ring outlast the hard stop, and a negative one is
the default rather than the clamp's floor, which would be "Never".
Changing the setting mid-ring applies at once. The settings screen writes it
through AlarmEngine.setAlarmSilenceAfter(), which stores it and, under the
same lock and uncancellably, measures the ring in progress against the new
window from when it started: past it, the cycle closes as missed through
closeCycleLocked; inside it, the backstop moves to the new end. A clock
change re-measures the ring the same way before rescheduling: the backstop is a
wall-clock registration, so a clock moved back re-arms it at most one window
from the moved clock, and one moved forward past the window closes it. A ring
whose start the clock has moved back behind is re-anchored to now and stored,
once, so a later re-measure cannot read the negative age as a whole window
left; a clock moved back mid-ring adds at most the time already rung, which
v1's fixed backstop did not bound at all. A backstop that fires while the ring
is still inside the window in force (one queued behind a raised setting)
re-arms instead of closing.
The timers' setting is written through TimerEngine.setTimerSilenceAfter() for
the same reason: under the engine's lock it first records a session that has
already run out of the old window as lapsed, even one no service pass saw go
quiet, so raising the setting cannot start it sounding again.
Whenever the resolver closes a ring (a clock jumped forward, a ringing alarm
switched off, a reboot outlasting the window), persist stops the ring service
and cancels the backstop: storage says nothing rings, so nothing may. Earlier
that path only cleared ringingSince and left the sound to the backstop. And a
cycle the resolver closes as missed (a ring past its window, or a snooze that
never fired) disables a one-shot alarm, exactly as closeCycleLocked
does; falling through would have armed it for the same time tomorrow.
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. Since M9 there is one branch on top of that: a transient alarm
(delete_after_use, set only on the contract's SKIP_UI create path) is
deleted rather than disabled, wherever a permanent one would be disabled.
persist's disableAlarm and closeCycleLocked both route through one private
disableOrDeleteLocked. The alarm_states foreign key cascades, so the ring
state goes with the row, and the existing !isRepeating guard means an alarm the
user later makes repeating is safe by construction (§17).
The engine's verbs, for a reader looking for the one to call: reschedule,
onFire, onAutoSilence, snooze(id, minutesOverride = null), dismiss,
dismissUpcoming, dismissUpcomingOccurrence (#16), onUpcomingWindow,
onBootCompleted, resumeRingingIfAny, ringSession and
the read-only upcoming. M9 added the last of the dismissals and the snooze's
parameter: dismissUpcoming(id) is the AlarmClock contract's dismissal and
the chooser dialog's, so the user's dismissal and an assistant's are the same
code. It closes a ring, or arms a repeating alarm's skip, or disables (or
deletes) a one-shot, and it takes the lock once. A caller doing find →
setEnabled → reschedule would race a fire broadcast between the read and the
write, which is the exact bug the lock exists for. minutesOverride is a
one-off, clamped to 1..60 and written nowhere: the contract says setting
EXTRA_ALARM_SNOOZE_DURATION does not change the default snooze duration, and a
test asserts both the stored row and the preferences file are untouched. It buys
no extra snooze either: a refused snooze still dismisses.
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. Its cycle has closed undismissed, so a displaced one-shot alarm is switched off (or deleted, if transient), as on every other close.
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.
Three 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 |
| upcoming check (#16) | setExactAndAllowWhileIdle, else setAndAllowWhileIdle |
wakes when the next upcoming-alarm window opens; see below |
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. One PendingIntent, one request code and one
independent cancel per slot. 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.
The upcoming-alarm notification (#16)
For someone already awake, an alarm about to ring can be dismissed without opening the app: a silent "Upcoming alarm" notification with a Dismiss, posted while the alarm is inside its window, and the same Dismiss as a chip on the alarm's row for whoever opens the app instead.
- The window is
UpcomingNotices.LEAD= two hours before a natural occurrence, AOSP DeskClock's own lead for its upcoming notification and what Google Clock is reported to use. The notice is silent and low-importance, so being early costs nothing. It is a value, not a setting: thealarm_upcomingchannel is the off switch. OnlyFireSource.OCCURRENCEqualifies: a snoozed alarm has its own notification with a Dismiss, and a ringing one belongs to the ring screen. - Kept true in one place. Every path that moves an alarm already ends in
rescheduleLocked, so that is where the notices are synced and the third slot is armed for the earliest window still to open, after the real registration. The check also wakes once a noticed occurrence's fire grace has run out (so a notice cannot outlive a fire that came late) and once an armed skip's has (so a skip dismissed in advance is spent on time, and the list stops saying "skipping next" about an occurrence already gone). Both wait out the grace rather than land on the occurrence itself: a fire for it still in flight is refused as too late by then, so the check never races it. The sync is wrapped so a notification failure costs nothing, and on the fire path it is deferred until the ring has started, so its binder calls never stand between the fire and the sound. The check's receiver just callsonUpcomingWindow(), which isreschedule(). - Dismiss is
dismissUpcoming, pinned to one occurrence.dismissUpcomingOccurrence(id, fireAt)re-resolves under the lock and acts only if the alarm's next fire is still that natural occurrence and still in the future; otherwise it isDismissOutcome.Staleand dismisses nothing. Without the pin a tap landing as the alarm starts would close the ring and get round its challenge. When it matches, it takesdismissUpcoming's own branches: a repeating alarm skips that occurrence, a one-shot is switched off (or deleted, if transient). A fire broadcast already in flight when a skip was armed isIgnoredbyonFire, which checks the armed skip watermark against the instant it carries. Only the skip: the handled-occurrence watermark is left to the resolver, which deliberately re-arms a handled occurrence that a clock moved backwards has put back in the future. A repeating alarm's resolution is persisted before the skip is armed, so an old skip that has gone by but was never spent cannot absorb the new one. One window is left open on purpose: a Dismiss whose handling straddles the occurrence by milliseconds, with another reschedule spending the skip before the fire already in flight is handled, can still let that fire ring. Closing it means keeping a skip tombstone apart from the flag, and each attempt to do so opened a worse window elsewhere (a skip switched off or re-armed inside the grace); a dismissed alarm ringing in a millisecond race costs less than either. - No dismiss challenge before the ring, as in Google Clock. The challenge
proves you are awake while an alarm sounds; before it, skipping is already one
unchallenged switch away in the editor. The notification's Dismiss does ask
for an unlock (
setAuthenticationRequired), so a locked phone cannot be talked out of its alarm by whoever picks it up. That flag needs Android 12; below it the notice isVISIBILITY_SECRET, kept off the secure lock screen. It is local-only, so it is never bridged to a watch, where the unlock would not apply. - Denied notifications cost the notice and nothing else; the row's chip
still works. A swipe is recorded as an
id@fireAtkey in a small SharedPreferences file through the notification's delete intent, which fires for the user's swipe and never for the app's own cancel, a reboot or a blocked channel. So a reschedule, even in a new process, does not bring back a notice the user got rid of, and one the system took away does come back.
The showIntent was wrong until M9. It is what the system opens when the
user taps the status-bar alarm icon or the lockscreen's next-alarm line, and it
pointed at the ring screen with FLAG_ACTIVITY_CLEAR_TASK, so tapping "my next
alarm" opened a ring screen for an alarm that was not ringing, which resolved
to Finished and closed itself. It now opens MainActivity with
AlarmIntents.ACTION_SHOW_ALARMS and NEW_TASK only: MainActivity is
singleTop, so an existing task is brought forward and onNewIntent selects the
Alarms tab, which is what "show me my next alarm" means. A build rule fails the
build if AndroidAlarmScheduler ever names AlarmRingActivity again (§8).
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, built as a chain of fallbacks. It
begins at AudioSourcePolicy.sourcesFor(ringtoneUri), a pure function a JVM
test can enumerate, and the silent sentinel resolves there to an empty source
list, which is what makes step 4 fire, so "silent" means "vibration
only" rather than "nothing at all":
- 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.
M6 shares this chain with the timer path. The MediaPlayer wrapper, the
USAGE_ALARM attributes, the transient focus request, the prepare-and-release
loop, the ramp stepper, the looped waveform, AudioSourcePolicy,
RingFallbackPolicy and VolumeRamp all moved to ring/ and domain/ring/
and are used verbatim by both, so "silent" still means "vibration only" for a
timer too. What the alarm keeps to itself is the full-screen intent, the
ring activity, the dismiss challenge and the snooze; what the timer
parameterises is its own ringtone, its own vibration and ramp settings (#22;
the ramp is zero seconds unless the user asks for one) and its own
"Silence after" setting. §14 has the rest, including which of the two wins the
one audio stream.
A date and a pause
Two optional fields shape the occurrence sequence without touching the
resolver's precedence (#21). Both are local dates, so "Thursday the 28th"
is the 28th of whatever zone the phone is in when it arrives, and both are
applied by AlarmOccurrences.sequence, the one place that knows about DST:
scheduledDate(one-shot only) is a floor: no candidate on an earlier local date. It is not a filter, so the sequence stays infinite. A date missed while the phone was off leaves the alarm behaving exactly as a missed plain one-shot does, ringing at its next time of day, and the snooze and skip paths, which call.firston the sequence, can never run dry. Ringing on the date closes the cycle like any one-shot's: the alarm switches off.pause(repeating only) is a hole: candidates whose local date falls inside it are dropped. It is jumped rather than walked, so a corrupt pause centuries long costs one step. "Skip next" still works on a paused alarm and skips the first ring after the pause; it stays the quick version of a pause.
Both clear themselves, and the rule is exact rather than approximate: a date or
a pause is cleared once it lies wholly before firstDateFor(now - fireGrace),
the earliest local date the sequence looks at. At that point it cannot change a
single candidate, so clearing it changes nothing but the row. Measuring from
now - fireGrace rather than from today is load-bearing: a pause ending at
midnight would otherwise be cleared at 00:00 and let its last day's 23:59
occurrence ring late through the grace window. The resolver reports
clearScheduledDate / clearPause on every branch, a disabled alarm included,
and the engine writes them as column-targeted UPDATEs conditional on the
stored value still being the one it resolved, so a pause the editor has just
picked is never cleared by a resolution of the old one, and no other column is
rewritten. Every editor write that can move a fire time (time, days, date,
pause) goes through AlarmEngine.onScheduleEdited, which drops a skip
watermark still in the future so the resolver re-arms it on the new sequence:
removing a pause must not leave the skip on the first ring after the old one,
adding a pause over the skipped day must not spend the skip on a day already
silent, and moving the time must not let the skipped day ring at the new time.
The surfaces never show a stale one in the meantime: AlarmSchedule shows a
date only while its occurrence is still ahead and a pause only while its last
day has not passed. The editor's date picker starts at AlarmSchedule.earliestDate,
the date of the alarm's next plain occurrence, so neither a past day nor
today-after-the-time-has-gone can be picked; moving the time earlier than now
on a date set for today drops the date. Picking a date switches the alarm on, since
a switched-off alarm would read "Scheduled for" and stay silent. Picking a repeat day clears the date
and clearing the last one clears the pause, as in Google Clock. The
AlarmClock contract cannot express either field, so SET_ALARM never
re-uses an alarm whose date or pause is still in force (§17).
12. The app shell
Four tabs, one host, and one surface that follows whatever is running.
Navigation
NavigationSuiteScaffold from material3-adaptive-navigation-suite, taking its
default navigationSuiteType: the M3 Expressive short navigation bar on a
compact width and the wide rail on medium and expanded ones. Taking the
default rather than writing if (compact) NavigationBar else NavigationRail is
deliberate: the default also answers the tabletop posture and the compact-height
case a hand-rolled branch quietly gets wrong.
The tabs are pages of one HorizontalPager (#2). Until then each tab was
its own NavHost route and the back stack was the source of truth for the
selected tab; the four tabs could only be tapped. Now they sit on a single
route, ShellNavigation.TABS_ROUTE, and the pager is the single source of
truth for the selected tab: its state is saveable, hoisted above the NavHost
so a tap from an editor can still move it, and the tab survives rotation and
process death with no mirror of ours to drift. The NavHost keeps only what is
pushed over the tabs: the two editors, the timer setup page, Settings. Each tab
is composed only while it is on screen (beyondViewportPageCount stays 0), keeps
its saveable state under its page key while off screen, and its ViewModel in the
tabs route's store.
Swiping is on only under a navigation bar: the bar is a horizontal row of
the same four tabs, so a sideways swipe maps onto it one to one. Beside a rail
(tablets, unfolded foldables) the tabs are stacked vertically and a sideways
swipe has no counterpart the user can see, so there the pager is tap-only. The
shell computes NavigationSuiteScaffoldDefaults.navigationSuiteType itself and
hands the same value to the scaffold, so the two cannot disagree.
The policy the shell follows is a pure function, ShellNavigation:
- a tab tap on the tabs slides the pager there from the neighbouring page, so a jump across two tabs does not compose and flash past the ones in between;
- a tab tap over a nested screen pops back to the tabs first and jumps, and re-selecting the tab under an editor closes the editor, which is the contract M5's editor relies on. A nested screen is no longer kept per tab and restored when its tab is re-selected: every one writes as the user goes (M5 D22), so popping one loses nothing;
- a tab an intent asks for (a notification, a widget, the exported door) is the
same pop and a jump: it always lands on the tab itself, never on whatever
nested screen was last open there. Widgets build that intent in one place,
openAppIntent, withCLEAR_TOP+SINGLE_TOPbesideNEW_TASK, so an activity left on top ofMainActivityin its task (a picker the editor opened) is finished and the intent reachesonNewIntentrather than the task merely being brought forward. The two clock widgets open the tab picked per instance in their config screen (ClockWidgetTapTarget, stored next to the style; Alarms when unset, #8); - back from any of the other three tabs returns to Alarms; back from Alarms
and from a null route leaves the app, and a nested screen's back belongs to
the
NavController, not to us.
Predictive back is the kit's Modifier.predictiveBack, gated on exactly that
policy: on Alarms the handler is off, so the gesture falls through to the
system and previews leaving the app, which is the truthful preview. Nested
screens use the kit's fadeThrough(), because they have no spatial relationship to the
tabs, and a slide would claim one.
The live pill
PLAN.md §9 asks for a running-state surface reachable from every tab. Its real
predicate is active, not running: pausing from the pill must not make the
pill vanish under the thumb that pressed it, or there is no way to resume or
stop from another tab, which is the flaw the pill exists to fix. So it shows for
RUNNING, PAUSED and EXPIRED, and its own Stop (which is reset(),
never delete()) is what removes it.
Precedence is total; first match wins:
| # | Subject |
|---|---|
| 1 | a timer that reads as expired right now (stored EXPIRED, or stored RUNNING whose snapshot has reached zero) |
| 2 | a timer that reads as running, the one with the smallest remaining |
| 3 | a timer that reads as paused |
| 4 | the stopwatch, when its state is not IDLE |
| 5 | otherwise no pill |
Ties inside a timer bucket break on sortOrder then id, the same "lower id
wins" rule the alarm engine's precedence already uses, so the app has one rule.
Timers outrank the stopwatch because a timer has a deadline: a missed timer costs
something, a missed stopwatch tick costs nothing.
M6 moved rows 1–3 into TimerReadings and rewrote LivePillSelector to call
it. The notification and the ring need the same answer ("which of several
running timers is this about"), and three copies of one comparator is how three
surfaces start disagreeing. LivePillSelectorTest passing unmodified is the
proof the extraction changed no behaviour; LivePillSelectorPrecedenceTest
cross-checks the two callers against each other. The pill keeps its own
three-valued LivePillMode, because it has a stopwatch to describe too, mapped
from TimerMode in one total when.
M6 also re-pointed the pill's timer actions at TimerEngine. M4 called
TimerRepository.pause/start/reset directly; after M6 there is an AlarmManager
registration and a foreground service that have to move with the state, so
pausing from the pill would otherwise leave the expiry slot pointing at a dead
deadline and the service posting a stale notification. That would self-heal,
since the next sweep would find nothing due, but it is wasteful and wrong.
M7 did the same for the stopwatch branches, for the same reason: after M7
there is a foreground service that has to move with the state, so pausing from
the pill would otherwise leave the shade showing a running stopwatch, and Stop
would leave the service up with nothing to show. The pill's own contract is
unchanged: it shows for RUNNING and PAUSED, never IDLE; Stop is reset() and
never delete(); and it gets no Lap button, because the pill has two
controls by design.
The mode and value come from TimerReadings / StopwatchReadings, both of them
over the stored row's snapshot rather than over the row, so §5's whole elapsed-realtime-versus-wall-clock story,
stale anchor and all, is honoured once. As a result, a running
timer that reaches zero flips the pill to EXPIRED immediately, without waiting
for the expiry sweep to write markExpired. The pill never counts into
negative time, and never reads 0:00 while claiming to run.
M4 noted that this two-writer case could not be demonstrated by hand. It can
now, and it is correct: the pill and the sweep both read through
Timer.snapshotAt, so the frame the pill shows before the write lands says
exactly what the row will say after it.
The pill is not a live region: at 1 Hz TalkBack would recite the countdown for as long as it ran. The readout carries a full sentence as its content description instead, read when focused and never announced unprompted.
The third time-shaped seam
Ticker joins WallClock, ElapsedRealtimeClock and ZoneProvider in
domain/time/. A readout has to advance without a repository write, and the
discipline of §5 is that time is a parameter: so "re-read the clocks now"
became an injected flow rather than a delay at a call site. It emits once
immediately and then every period (one second for the pill, since the pill
formats to whole seconds), so a fresh subscriber is never blank. RealTicker is
nothing but delay, which makes it testable under runTest's virtual clock.
alignedTicks(unit) is the same seam for a readout of whole wall-clock units:
after the immediate emission it waits WallBoundaries.untilNext(now, unit),
re-measured against WallClock after every wake, so it fires on the second or
minute boundary itself and a delay that ran long does not accumulate. A
clock set mid-wait is only caught up once that wait ends, so a caller on a
long cadence restarts the flow on ClockChanges, the system's own word that
the clock or zone moved. The world clock is its one caller (§16, "The
cadence"), and does exactly that, so a long
cadence never means a long wait for those.
The dismiss challenge, and why it cannot strand anyone
ChallengeGate is a pure state machine whose single boolean open means
"dismissing is permitted now". PLAN.md §4's "must never be able to strand the
user" is made executable rather than promised: the escape hatch becomes
available on either three wrong answers or sixty seconds of ringing,
whichever comes first, for every challenge kind, asserted as a parameterised
invariant over DismissChallenge, not as three happy paths.
Three more guarantees hold alongside it. Snooze is never gated: the gate protects
dismissal only. Auto-silence still ends the ring at its "Silence after" window regardless of the
gate, because that is the engine's, and the screen only follows the session to
Finished. And a wrong answer carries no lockout and no penalty timer; it
replaces the problem and that is all.
A completed hold is the dismissal, and a correct answer is the dismissal: no
"solve it, then press Dismiss again", because a half-awake person should not have
to discover a second step. The hold target also carries an accessibility
onClick that completes it outright: TalkBack cannot perform a press-and-hold,
and a challenge a screen reader cannot answer is precisely the stranding the
requirement forbids.
13. The alarms screen
The first tab with real content, and the milestone that gives the schema of §4 and the engine of §11 a face.
Two destinations in one flat host
The list stays on the ALARMS tab route; the editor is a sibling route in
the same NavHost, alarms/edit/{alarmId}, with a NavType.LongType
argument. Three properties fall straight out of §12's existing policy rather
than being re-invented: destinationOf("alarms/edit/7") is null, so the Alarms
tab stays highlighted while the editor is open; onBack of that route is
Exit, so the shell's predictive-back handler stands aside and the editor pops
itself; and a tab tap's popUpTo(start.route) pops the editor, which is the
right behaviour and exactly the contract §12 wrote down for this milestone.
Since the tabs became pager pages (#2) the list lives on the shared tabs route
instead, and the same three properties hold through the pager: it keeps Alarms
selected under the editor, the shell stands aside on a nested route's back, and
a tab tap over the editor pops it.
There is no "new alarm" sentinel id. The FAB creates the alarm (enabled, non-repeating, at the next whole hour, every override null) and then navigates to its id. One route shape, one load path, and no branch in the ViewModel for "the thing I am editing does not exist yet".
That decision is what settles a wording in PLAN.md §6 that would otherwise be
read too literally. §6 says a malformed SET_ALARM intent "opens the editor
pre-filled rather than writing anything". An editor that opens on nothing
does not exist in this app, and inventing one for M9 would overturn the rule
above. The sentence's force is "SKIP_UI never means skip validation": the
user must see the alarm rather than have one written behind their back, and
that is honoured exactly: an incomplete or malformed SET_ALARM creates the row
from the extras that were valid, at AlarmDefaults.nextWholeHour, enabled,
never transient, and lands the user in its editor with a time picker, a day
selector and a Delete under their thumb. Nothing is ever written and hidden,
which is what §6 forbids (§17).
"Which alarm?" — the chooser dialog
The AlarmClock contract says a DISMISS_ALARM search returning two or more
matches should show the results and let the user pick. M3's canonical component
for "pick one of a short list, or cancel" is a basic dialog containing a
list: AlertDialog at its defaults (surfaceContainerHigh, the 28dp corner,
headlineSmall title, tonal elevation 3), not a full-screen dialog (that is for
a whole task) and not a bottom sheet (this is a question, not a surface).
Rows are canonical M3 ListItems in a Column with verticalScroll, each
carrying the same three facts the Alarms row shows: the formatted time through
rememberAlarmTimeFormatter (the app's one formatter), the label as supporting
content and the repeat summary as the overline. The dialog and the list
behind it therefore agree. Each row sets
ListItemDefaults.colors(containerColor = Color.Transparent), because a
ListItem's default container is surface, which sits lower than the
dialog's own surfaceContainerHigh and would draw a visible lighter block per
row. There is one action button, Cancel, in confirmButton, where M3 puts a
lone action: tapping a row is the answer, the same rule M4 applied to the
dismiss challenge. Each row merges its descendants under one content description
naming the time, the label and what tapping does, and the 48dp target comes free
with ListItem.
DismissChooser.rowsFor(rows, ids) is pure and filters AlarmsViewModel's
existing AlarmRowState list, in its order (so there is no second source
and no second ticker), and the answer goes to
AlarmsViewModel.onDismissUpcoming(id), which is AlarmEngine.dismissUpcoming
(§11).
The row
A canonical two-line M3 ListItem through the kit's GroupedRow: the formatted
time with the label beside it in onSurfaceVariant, a summary of
repeat · next fire · skipping next, and a Switch. A disabled alarm is
dimmed, the kit's own answer for "present but switched off", which fades the
text to the M3 disabled emphasis and leaves the switch at full opacity. The
next alarm is marked with colorScheme.primary on its next-fire clause and
nothing else: PLAN.md §8's "Expressive ≠ big or bold", so the emphasis is a
colour rather than a third line or a larger type ramp.
The list is a LazyColumn inside CollapsingScaffold(scrollable = false),
which is the case that parameter exists for, and the FAB sits in the kit's new
floatingActionButton slot (floret-kit 0.6.0). Both the FAB and the list's
bottom contentPadding clear LocalLivePillInset, because the pill is
bottom-centre and the FAB bottom-end.
Ordered by time of day, not by next fire
AlarmEngine.upcoming() sorts by next fire, because its job is "which alarm is
earliest to register". That is the wrong order for a list: an 07:00 and a 22:00
alarm would swap places at 07:00, reordering rows under a thumb. So
AlarmListRows.from re-sorts by time.minutesOfDay then id, which is also
exactly what AlarmDao.observeAll emits, so the screen's order re-asserts the
storage order rather than inventing one. A disabled alarm keeps its place: it is
already dimmed, and sinking it would say the same thing twice while moving a row
the user did not move.
The next-fire line
NextFireFormat.labelFor(nextFire, now) returns data, not a string
(NotScheduled, Imminent, InMinutes, InHoursMinutes, InDaysHours), and
the wording lives in strings.xml, following RingUiState's precedent. Whole
units round up, the direction ClockFormat.countdown already rounds: "in 9h
12m" must mean at least nine hours and eleven-and-a-bit minutes, and an alarm
61 seconds out must not read "in 1m" when it is nearer two. InDaysHours
exists because a weekly alarm can be six days out.
The cadence is 30 seconds (AlarmsDefaults.NextFireTick): the readout's unit is
a minute, so half a unit bounds the displayed error, and the one-second cadence
the live pill needs would re-resolve every alarm sixty times for a readout that
can change once. "Ringing now" does not wait for a tick: it arrives as an
alarm_states write, which re-emits at once.
Every override is a picker, and its first option is "App default"
A per-alarm setting is a nullable override (§4), so the stored value is
ternary: absent, or a choice that happens to be false or 0. A Switch
cannot say "I have not chosen": toggling one would silently turn "follows your
default" into "explicitly on", with no way back. So all five overrides (vibrate,
gradual volume, snooze length, snooze limit, dismiss challenge) use the kit's
OptionPicker, whose first row is "App default (On)" and names what the default
currently is. Every option offered is inside ClockPrefs' clamps, so nothing
chosen can be clamped away on the next read, and snoozeLimit == 0 is labelled
"No snoozing", never "unlimited" (§11's "0 means infinite is a trap").
Each row's summary shows the effective value, prefixed while inherited, so the inheritance is visible without opening anything. There is no tri-state hack and no "reset this alarm" button to discover.
The ringtone picker
Bespoke over FullScreenPicker(scrollable = false) rather than OptionPicker,
because a device's tone list can be hundreds of rows. RingtonePicker.optionsFor
is pure and yields four kinds of row, de-duplicated by URI:
- App default, summarised with what it currently resolves to, or "Device default alarm sound" when the app default is itself null;
- Silent:
Ringtones.SILENT_URI,clockula://silent.nullalready means inherit, so silence needs a value of its own, and a private scheme is one no ContentProvider URI can collide with. It resolves to an empty audio source list, which forces vibration (§11); the label is "Silent" with the summary "Vibration only", so it is not a lie; - the alarm's own sound when the device does not list it: a SAF-picked file, or a URI another app wrote. Without this row the user's own selection would be invisible, and so uncheckable, in the picker that chose it;
- the sounds from
RingtoneCatalog.alarmTones(): the ones bundled in the APK first (BundledTones, today only "Timer expired"), then the device's alarm tones, title-sorted. "This device has no alarm sounds" is shown when every listed tone is a bundled one.
"Choose from files…" is an action row, not an option, so the pure list stays
a list of selectable states. A picked document's read grant is made persistent
through RingtoneCatalog.persistPickedDocument; if that fails the URI is
still stored (the fallback chain protects the ring) and the row discloses
"Clockula may lose access to this file later". A stored sound the catalog cannot
open right now is likewise reported, never rewritten: "unreadable now" and
"gone forever" are indistinguishable, an unmounted card comes back, and
discarding the user's choice silently would be the worse failure.
Previewing goes through RingtonePreviewer, a two-method seam over the same
USAGE_ALARM attributes the ring service uses, so a preview sounds like the
alarm will. One previewer, so a second preview stops the first; selecting an
option, closing the picker and onCleared() all stop it. Deliberately no audio
focus and no ramp: a preview is not an alarm.
A sound another app plays
"Choose from a music app…" is a second action row. It opens
MediaAppPickerScreen over the picker, with its own MediaAppPickerViewModel
over the MediaLibrary seam, so none of the three hosts (alarm editor, timer
editor, settings) gains a line beyond the URI it receives through onChoose.
The picker lists the apps that export Android's standard MediaBrowserService
(the manifest's one <queries> entry makes them visible; no permission is
involved), walks the chosen app's library, and offers "Resume where you left
off" at its root.
Storage is a URI, not a schema change. MediaSources encodes the choice as
clockula://media?pkg=…&svc=…&app=…&id=…&title=…, under the same private
scheme as the silent sentinel. It therefore lives in ringtone_uri, the
default-sound preferences and the backup unchanged. The interop door accepts
only content:// and android.resource:// (InteropLimits.RINGTONE_SCHEMES),
so no other app can make Clockula bind to a service of its choosing. The
encoded title is what the editor shows, so rendering never connects to the app.
Ring time. AudioSourcePolicy puts MEDIA_APP in front of the usual
defaults. RingAudioPlayer.start does not wait for the sound: it launches the
attempt and returns, so the ring services post their notification and start
vibration meanwhile. A generation counter, bumped by every start and
stop, lets work left in flight (a prepare, an app that answers late) see
that it is no longer wanted. onSilenced, called only for the live
generation, replaces the old boolean result as the trigger for forced
vibration.
Before connecting, MediaRouteCheck asks whether media would be heard.
The other app plays as media, not as an alarm, so it gets neither the alarm's
Do Not Disturb exemption nor its forced speaker route, and its session reports
"playing" either way. MediaRoutePolicy refuses when Do Not Disturb does not
let media through, or when the media route includes headphones or an external
device whose type does not say what it is. A classic Bluetooth (A2DP) device
is a speaker or headphones alike, and telling them apart would need
BLUETOOTH_CONNECT. LE Audio speakers report their own type and pass. A
refusal rings the ordinary chain.
MediaAppRing connects through connectMediaBrowser, the one wrapper shared
with the picker. If the app is already playing, it pauses it first, so that
"playing" afterwards means the request was honoured. It then calls
playFromMediaId (or play for resume) and waits for STATE_PLAYING. Every
wait is bounded (MediaRingPolicy: 5 s to connect, 2 s for the pause, 15 s to
start). Once playing, a watchdog checks once per second on two clocks:
stopped for 5 s, or not playing (buffering included) for 15 s. Either one,
or a route check that now fails, stops the media ring and starts the ordinary
chain unramped.
The one place Clockula moves a stream volume. The other app plays on the
media stream, which the user may have at zero. MediaAppRing therefore steps
the ramp into STREAM_MUSIC, aiming at the alarm stream's share of the media
range (MediaRingPolicy.mediaVolumeTarget, never below 1, fixed at start), and
writes only when the index changes. MediaRingRegistry is process-wide: it
saves the user's level when the first media ring starts and restores it when
the last one ends, so an alarm and a timer ringing through music apps at once
cannot save each other's raised volume. It also pauses an app only when no
other live ring still uses it. No audio focus is requested, because the app
requests its own. Dismissal pauses rather than stops, so the user's queue
stays where the alarm found it. A process death mid-ring leaves the media
volume where the ramp had it.
There is no preview for these rows. Auditioning another app's track at alarm volume from a settings screen would be rude, and the previewer ignores such a URI.
The rule a future reader will otherwise simplify away
A ringing alarm is dismissed through the engine before it is edited, disabled or deleted.
AlarmResolver's disabled branch clears ringingSince and the engine persists
that, but nothing in that path stops the ring service. The service and the
auto-silence backstop are single global slots (§11), touched only by
closeCycleLocked, and onAutoSilence returns early once ringingSince is
null. So switching off or deleting a ringing alarm from this screen would clear
its state and leave it sounding until the 15-minute wake-lock timeout, with
the backstop disarmed by its own guard. The list therefore calls
if (engine.ringSession()?.alarm?.id == alarmId) engine.dismiss(alarmId)
before setEnabled(id, false). Deleting is an engine verb of its own
(AlarmEngine.delete, below), which stops a ring itself but, from the list and
the editor, refuses a ringing alarm outright. The check reads storage under
the engine's own mutex, so it cannot race the fire it is asking about, and it is
deliberately conditional: dismissing unconditionally would also drop a
merely snoozed alarm's pending snooze, which is wrong for a label edit.
Since #17 the engine's persist also stops the ring service and cancels the
backstop whenever the resolver closes a ring (§11), so the disabled branch no
longer leaves a ring sounding on its own. The dismiss-first rule stays: it is
what closes the cycle as a dismissal (a one-shot alarm disabled, the snooze
notification cleared), and a deleted row never reaches the resolver at all.
Immediate apply
There is no Save button, no dirty state and no discard dialog: PLAN.md §11
names Google Clock as the interaction reference, and this is its model. The
editor edits a persisted row and every control writes at once. The label is
written on every keystroke through one conflating MutableStateFlow (which
writes the first value and the latest, in order, never interleaved), and
onCleared() re-launches the last value on the kit's @ApplicationScope, so
leaving the editor mid-word cannot lose the word. reschedule() is called only
after a change that can move a fire time (create, delete, enabled, time, repeat
days, skip-next), so fiddling with sound settings costs no AlarmManager traffic;
a snooze length cannot move a pending snooze, which is stored as an absolute
instant.
Skip-next-occurrence, built in M3 with no caller, is the editor's, and only for a repeating alarm. The resolver answers a one-shot-with-skip by disabling the alarm, and a switch labelled "skip" that silently switched the alarm off would be a lie; the user who wants that has the enable switch.
Delete, and the Undo
An alarm is deleted by swiping its card towards the end edge, by the card's
Delete custom accessibility action (the same verb for TalkBack and switch
access, where there is no swipe), or from the editor's top bar. None of them
asks first. A ringing card offers neither the swipe nor the action: it leads
to the ring screen, and a swipe must not become a way round its dismiss
challenge. The engine re-checks under its lock (delete(id, unlessRinging = true)),
so a swipe released just as the alarm fires, before the list has caught up,
deletes nothing and the card slides back. The editor's Delete passes the same
flag: with its confirmation gone, one tap there would otherwise do what the
swipe is kept from doing, so a refused tap opens the ring screen instead, where the alarm is dealt with. Each is followed by a chip on the list ("07:00 alarm deleted",
Undo), as in Google Clock. v1 asked before instead, on the argument that a
mistaken delete is found at 07:00 rather than now. But a dialog catches no more
than the chip does: both only catch the slip noticed in the moment, and a
confirmation the user taps through out of habit catches less. The Undo does it
without taxing every delete the user meant, so the editor's dialog went too
rather than leaving two delete paths with different safety nets.
AlarmEngine.delete(id) does the work under the engine's lock: it stops a
ringing alarm's service and auto-silence backstop (the global slots of §11),
clears a snoozed alarm's notification, deletes the row (the ring state
cascades) and re-resolves the slot. It returns a DeletedAlarm, the row and
its ring state as AlarmDeletion.snapshot decided, and AlarmEngine.restore
puts that back under the same id (alarms.id is AUTOINCREMENT, so no new
alarm can have taken it) and re-resolves, which is what re-registers it with
AlarmManager. The snapshot keeps every setting and the ring state as it was, so a
snoozed alarm comes back snoozed, notification included, and the handled-occurrence
watermark still stops a just-closed occurrence ringing twice. A ringing
alarm is the exception: deleting it was a dismissal in all but name, so it comes
back as a dismissal would have left it (cycle closed, a one-shot switched off)
and never rings again, and a ringing transient alarm, which that dismissal would
have deleted anyway, offers no Undo at all. A snooze that lapsed while the alarm
was gone is closed by the resolver on restore, and gets no notification. An
alarm deleted just before its time and restored just after it rings at once,
since the occurrence is still inside the fire grace window: Undo means "as if it
had not been deleted", and that alarm would have rung.
The pending deletion lives in AlarmUndo, a singleton, not in either
ViewModel. The editor is popped the moment its alarm is gone, so its ViewModel
is cleared before any chip could be answered; the list underneath reads the
deletion from the singleton and shows the chip (UndoChipBand). It
is cleared when the chip is answered or times out, when a newer deletion
replaces it, and when the screen is left with the chip unanswered (another
tab, Settings, the app closed): an Undo held back and offered later would
restore a snapshot from long ago, out of any context, and over a backup that
was restored meanwhile. A rotation is the one exception and shows the chip
again. A deletion offered while no list was on screen (the app sent home the
instant the editor's Delete was tapped) is dropped unshown if it is more than
30 seconds old by the time the list is back. Both lists share this as
UndoSlot. It is memory only, so process death drops it too.
The swipe is hand-rolled (ui/common/SwipeToDelete) rather than M3's
SwipeToDismissBox, because the tabs are pages of a HorizontalPager and the
box claims a horizontal drag in both directions, even one it has no anchor for.
Only the start-to-end drag is claimed; the end-to-start one is left unconsumed,
so a swipe across a card still changes tab. Start-to-end is also the pager's
"previous tab" direction, which from Alarms, the first tab, goes nowhere, so the
two gestures never want the same drag.
14. Timers
The section to read before changing anything timer-shaped. AlarmEngine and
TimerEngine are peers: neither drives the other, and the only thing they
share is one read-only question (below).
The shape
TimerEngine is the timers' analogue of §11's engine: plain Kotlin, no
android.* import ever, reaching the platform through three seams it does not
implement: TimerScheduler (one AlarmManager slot), TimerServiceHandle (the
foreground service) and AlarmRingStatus (read-only: "is an alarm ringing").
It owns every write the pure policies ask for, and every public entry point is
serialised on one non-reentrant Mutex, because they arrive from threads that
know nothing about each other: a broadcast on Dispatchers.Default, the
service's collector, a ViewModel, ClockulaApp's launch pass.
It is not driven by a Flow, for the same reason the alarm engine is not:
resolution writes state, so an engine that resynced on every repository emission
would re-trigger itself on its own writes. resync() is called explicitly.
The engine owns the lifecycle verbs (start, pause, reset, addTime,
delete, M9's dismissAllExpired/dismissExpired, plus
onExpiryDue/resync/onBootCompleted) because each of them
can move the slot, the service or the ring session, and every one of them is
reachable from a notification button with no ViewModel in sight. delete is on
the engine and not on a ViewModel-plus-resync path deliberately: deleting a
ringing thing and leaving the global ring slot sounding is a bug class M5
already met once, and the cheapest way not to have it again is to make the wrong
call impossible to write. Configuration edits (rename, setDuration,
setRingtoneUri) stay on TimerRepository and the editor calls them directly.
A card's corner delete asks nothing first, and the list's chip offers an
Undo. TimerEngine.delete returns the row as TimerDeletion.snapshot
decided, and TimerEngine.restore puts it back under its own id and place and
resyncs. A running timer comes back with its anchors, so it has kept counting
while it was gone, and one whose end passed in the meantime rings, as it would
have had it never been deleted. A timer that had already finished comes back
reset: deleting it silenced it, which is a dismissal, so it does not ring a
second time (a finished transient timer offers no Undo). The pending row is
TimersViewModel state, since both the delete and the chip are on the
list; the editor's delete keeps its confirmation.
TimerRepository deliberately has no generic edit(id, transform) the way
AlarmRepository does: a timer row carries three derived anchors whose
consistency is the whole of §5, and a caller handed a (Timer) -> Timer can
break them. setDuration is refused unless the timer is IDLE.
Expiry: one slot, two triggers, an idempotent sweep
There is one AlarmManager registration for every timer, holding the earliest
running deadline, and it carries no timer id. The fire is
TimerEngine.onExpiryDue(): mark every timer whose snapshot has reached zero
as EXPIRED, then re-register the next earliest. Three properties fall out, and
each is a test:
- a fire delivered late still expires everything that came due while it was delayed;
- a fire delivered early, or for a timer the user has since stopped, finds nothing due and writes nothing, so the trigger needs no grace window and no watermark of its own: the anchors are the watermark;
- two timers expiring in the same second are handled in one pass, with no race.
The base is ELAPSED_REALTIME_WAKEUP, mirroring the clock the domain
anchors on: alarms are RTC_WAKEUP, timers are ELAPSED_REALTIME_WAKEUP.
The match is required: a wall-clock registration moves when the user changes
the system time, so a TIME_SET would warp a running timer's expiry, which §5
forbids. With this base a TIME_SET needs no
re-registration at all, and a test asserts the slot's value and every stored row
are unchanged across a three-hour clock jump in each direction.
setExactAndAllowWhileIdle is tried first and setAndAllowWhileIdle is the
fallback. It does not go through SchedulingPolicy, whose
EXACT_ALARM_CLOCK branch names setAlarmClock, and a timer must never
populate getNextAlarmClock(), because that slot draws the status-bar alarm
icon and the lockscreen line and belongs to the user's next alarm. A late
timer is survivable and a silent one is not, so there is no third branch.
Known limitation, recorded rather than glossed: *AllowWhileIdle alarms are
rate-limited per app while the device is idle. Clockula holds USE_EXACT_ALARM,
which exempts it, but a device that refuses the grant could deliver a
back-to-back sequence of short timers late. The idempotent sweep is one
mitigation; the second is that the service, while alive, delays to the same
deadline and calls the same onExpiryDue(), so neither trigger has to be
reliable alone.
Both delay amounts come from the engine (TimerServiceState.expiresIn and
silenceIn), so the timings are asserted in a JVM test and the service only
ever does delay(d). Both are floored at TimerRing.WAKE_FLOOR (100 ms) so a
rounding error cannot spin the loop.
One foreground service, alive exactly while something is active
TimerService is a single systemExempted foreground service covering both
phases, the countdown and the ring. Its lifetime predicate is the live pill's
"active" (which is wider than "running"): it is up while any timer is RUNNING, PAUSED or
EXPIRED and down when every timer is IDLE or gone. PAUSED is in the
predicate for §12's reason: dropping the notification on pause would leave a
user who paused from the shade with no way to resume without opening the app.
The whole app has one "active" predicate.
There is one service because a timer expiring while another runs must not need a second one, and because the ring phase needs nothing the countdown phase does not already have.
The countdown holds no wake lock. A forty-five-minute timer keeping the CPU
awake is a battery bug; the AlarmManager slot is what wakes the device. The
ring holds one, with a fifteen-minute timeout, exactly as AlarmRingService
does.
The service is a readout and a control surface. The stored anchors and the
AlarmManager slot keep the time, and both survive the service's absence. So
TimerServiceHandle.sync wraps both the start and the stop in runCatching,
and the service holds no policy and no state of its own beyond one
alreadyAlerted flag. Its trigger flow is
combine(timers, alarmIsRinging, defaults) and deliberately excludes the
ring session's anchor, so the engine's own anchor write cannot re-trigger the
collector that caused it.
The audio arbitration: an alarm wins, and the timer's ring is deferred, not lost
Clockula never plays two USAGE_ALARM streams at once, and §11's ring service
and auto-silence backstop are single global slots. So a timer that expires while
an alarm is ringing is still marked EXPIRED (its data is truthful and the
user must learn the pasta is done) and its notification says so, but it does
not open audio. When the alarm's cycle closes, the timer starts sounding, and
nothing re-arms it: the decision is a pure function of current state rather
than an event.
TimerRingPolicy.decide(expired, defaults, alarmIsRinging, soundingSince, now)
alarmIsRinging arrives through one read-only seam, AlarmRingStatus, backed
by AlarmStateRepository.states(). The timer engine therefore never reaches
into alarm_states itself, never calls AlarmEngine, and never touches the
alarm's ring slot or its auto-silence registration; tests assert all of this. A test hands the
engine a boolean instead of building alarm state.
The reverse case is free and symmetric: an alarm firing over a sounding timer
flips the flag, the flow re-emits, and the timer's audio stops. AlarmEngine
writes ringingSince before it starts its own audio, so the flip leads the
sound.
One ring session for however many timers, and one window
- Expiry is a set. Every due timer is marked
EXPIRED. - The ring is one. The subject is the first expired timer in §12's order, and its ringtone is the one that plays.
- Stop acknowledges one timer. Stopping the subject resets that timer; if
another expired timer remains, the ring continues for the next subject. Each
timer is a separate thing the user set, and silently resetting all of them
because one was acknowledged destroys the information "which ones finished".
Two timers cost two taps, which is correct: there is still no "stop all"
in the UI, and M9 added no button. M9 did add
TimerEngine.dismissAllExpired(), reachable only fromACTION_DISMISS_TIMERwith no data URI: the rule above is about a thumb on a ring, where silently resetting both destroys the information "which one finished", while an app saying "dismiss all expired timers" has stated exactly which information it is discarding. Its siblingdismissExpired(id)answers aclockula://timer/{id}deeplink and resets the timer only if it reads EXPIRED and returns a silentfalseotherwise, since dismissing a running countdown because a caller referenced it would destroy a measurement. Both route through the same private reset, so both honourdelete_after_use(§17), and both take the engine's lock so the expiry slot, the service and the ring session move with the state. - A session is one window. It opens when the audio actually starts sounding and closes when the expired set returns to empty. A second timer expiring mid-session inherits the session's remaining window rather than buying a fresh window. That is the conservative direction, and the same rule as §11's "a reboot does not buy the alarm a fresh window". The anchor is the instant the audio opened rather than the instant of expiry, so a long alarm ring in front of it does not eat the timer's whole window.
- A lapsed window does not silence the next timer. A window that has run out
belongs to the timers the user chose to leave
EXPIRED; a timer reaching zero after it is a new event and opens a session of its own. The sweep is where that is decided, because the sweep is the only place that knows a timer has just come due: a non-empty due set drops a lapsed anchor, and the next pass opens a fresh window with its own heads-up. Inheriting stays the rule for a timer expiring inside a live window.
The window is the timer's own "Silence after" setting
(ClockDefaults.timerSilenceAfter, Settings › Timers, #17), ten minutes by
default: the same choices as the alarm's and the same one-hour "Never", but a
separate value, because coupling the two would make one number answer two
questions. TimerRingPolicy reads it from the defaults it already takes, so a
change applies on the service's next pass. A session that has gone quiet stays
quiet: on auto-silence the policy moves the stored anchor back past
SilenceAfter.HARD_STOP, so raising the setting afterwards cannot start it
ringing again. The service's one heads-up is keyed to the session's anchor
(Sound.openedAt) rather than to the window's end, which an edit can move
without making a new session. Unlike an alarm's, a timer's
auto-silence stops the noise and leaves the row EXPIRED. An auto-silenced
alarm is a missed alarm and its cycle closes; an auto-silenced timer has still
finished, and the user coming back must be able to see that and add a minute
to it. Auto-silence is decided before suppression, so an alarm ringing over
a window that has run out cannot hand it a fresh one.
A timer's ramp is ClockDefaults.timerVolumeRampSeconds, 0 unless the
user picks one in Settings > Timers (#22; it was the constant
TimerRing.RAMP_SECONDS before). The volume ramp exists so an alarm does not
jolt a sleeping person awake; a timer is set by someone awake who wants to know
now, and for most of them a gentle start is a quiet start, so the default
stays flat. VolumeRamp.levelAt(_, 0s) already returns 1f, so
"parameterised" means one different argument to the same player.
Vibration is ClockDefaults.timerVibrate, never the alarms' vibrate: an
alarm that should buzz the bed and a kitchen timer that should only beep are
different questions. Until #22 a timer vibrated exactly when the alarm
default did, so nobody's timers change on update: SettingsPrefs.settleTimerVibrate
copies the alarm default into the new key once, from its own coroutine at
startup, concurrent with the engines' pass rather than ahead of it, and from
then on the two move apart. The key's own default is true, the alarm
default's, for the moment before that lands (a timer that rings in that moment
on the first launch after the update may vibrate once); a failed copy is tried
again on the next launch. Fresh
installs therefore start with timer vibration on, like alarms; telling a fresh
install from an updated one reliably was more machinery than the default is
worth, and the switch is one tap away.
A ring restarted mid-session (handed to a second expired timer, back after an
alarm held it, a recreated service) picks its ramp up where the session is,
TimerRing.rampElapsed from the window's remaining time, rather than ramping
again from a whisper. Silent with vibration off is still not silence:
AudioSourcePolicy gives Silent no sources, and the player's onSilenced
starts the vibrator regardless of the setting.
The notification: one channel, one id, alert once
The subject is §12's; the rest are a count.
| Subject reads | Title | Body | Actions |
|---|---|---|---|
RUNNING |
the label, or "Timer" | the platform chronometer counting down | Pause · +1 min |
PAUSED |
the label | "Paused · 4:32" | Resume · Reset |
EXPIRED |
the label | "Finished" | Stop · +1 min |
plus "+2 more timers" when other active timers exist. Two actions at most, and they are the same pair the row offers for that mode, so the shade and the row share one vocabulary. This knowingly gives up per-timer controls in the shade for the timers that are not the subject. Those are one tap away in the app, and the alternative (a notification group with one entry per timer) multiplies ids, request codes and orphan-on-kill cases for a rare case.
setUsesChronometer(true) + setChronometerCountDown(true) + setWhen(base)
means the system renders the ticking, so the service posts once per state
change instead of once per second for forty-five minutes. The base is
timers.ends_at_wall_clock_millis, which makes it the single wall-clock value
in the whole timer path, with one honest consequence: a user changing the system
clock while a timer runs leaves that stored base (and so the notification's
readout) pointing at the wrong instant, while the timer itself does not move at
all. So SystemEventReceiver's TIME_SET/TIMEZONE_CHANGED branch calls
timerEngine.onSystemTimeOrZoneChanged(). It reschedules nothing; it only
re-anchors ends_at_wall_clock_millis from the monotonic anchor (§5) and
re-posts with a base that is true again. A running row with no wall-clock end (a
corrupt row) falls back to a static readout rather than a chronometer counting
to a wrong instant.
One channel (timers, IMPORTANCE_HIGH, sound and vibration off because the
service owns both), one id, and only the first post of a ring session may alert. So a
countdown update never heads-ups and never buzzes, an expiry heads-ups exactly
once, and there is no channel gymnastics and no second notification. Every
non-alerting post carries setSilent(true) as well as setOnlyAlertOnce(true),
because setOnlyAlertOnce speaks only about updates to a notification already
on screen. On a high-importance channel the first post of a countdown, and the
placeholder the service goes foreground with, would otherwise pop a banner. The
rule is a pure function (TimerNotificationState.alertOnce); the service owns
the flag that spends it and scopes it to the session's window, so it resets both
when the decision goes Silent and when a new window opens.
Every action is a PendingIntent.getBroadcast into TimerActionReceiver, in
the shape AlarmActionReceiver already has: goAsync(), @ApplicationScope,
one call into the engine, finish(). Deliberately not
PendingIntent.getService (a background foreground-service start can be
refused; a broadcast cannot) and not an activity (a control should not have to
open the app). Nothing is carried across but the timer id, which can be stale,
so every engine verb is guarded by the state it makes sense for, a mismatch
is a silent no-op, and the notification is re-posted from the truth afterwards.
Request codes use the base * 100_000 + (id % 100_000) stride
RingNotifications already uses, and a test asserts the union of alarm and
timer codes is distinct, because that collision is how one PendingIntent
silently eats another's extras.
Tapping the notification opens the Timers tab, through the smallest honest
deep link: the content intent carries TimerIntents.ACTION_SHOW_TIMERS,
MainActivity reads it in onCreate and onNewIntent into one
mutableStateOf, and ClockulaShell consumes it once by replaying
ShellNavigation.onTabSelected, the same command a tab tap produces.
startDestination stays Alarms: it is the popUpTo anchor and the root of
M4's asserted back policy, so changing it for a notification tap would rewrite
that policy. (Since #2 the shell replays ShellNavigation.onTabRequested
instead: a tap's pop, but a jump rather than a slide, onto the pager whose start
page is still Alarms; see §12.)
M9 finished that surface and widened it by one step. tabForAction gained
exactly one action, AlarmIntents.ACTION_SHOW_ALARMS: the platform's own
AlarmClock strings never reach MainActivity (the exported door translates
them into internal ones), so mapping them here would be dead code that looks
like a contract. And because three of the contract's outcomes are not "select a
tab", the shell's intent parameter grew from openTab: ClockulaDestination? to
openRequest: ShellRequest?, built by the pure
ShellNavigation.requestFor(action, alarmId, alarmIds):
ShellRequest |
What the shell does |
|---|---|
OpenTab(destination) |
the tab command a tab tap produces, unchanged |
OpenAlarmEditor(id) |
the Alarms tab command, then navigate(AlarmRoutes.editor(id)): the same two steps the FAB takes, so the back stack is the one M5 asserted |
ComposeTimer |
the Timers tab command, then the setup panel: the ModalBottomSheet over a non-empty list, and nothing extra over an empty one, where the panel is already the empty state |
ChooseAlarmToDismiss(ids) |
the Alarms tab command, then the chooser dialog (§13) |
The launching intent is read once: onCreate derives a request only when
savedInstanceState is null. setIntent keeps the intent, so re-deriving it
on every configuration change would replay stateful navigation: a rotation
would force the user back into the editor, re-open the timer setup sheet, or
re-ask the dismiss question they had just answered. A genuinely new intent
arrives through onNewIntent instead. Every extra is read inside a
runCatching, for the same reason IntentExtrasReader does: MainActivity is
exported, reading any extra unparcels the whole Bundle, and a Parcelable whose
class is not in our classloader would otherwise throw inside onCreate, a
launch crash that also feeds CrashReporter.isCrashLoop. The action itself
never unparcels, so an intent we cannot unpack still selects its tab.
requestFor degrades rather than throws: a missing or non-positive alarm id,
or an empty id list, falls back to OpenTab(ALARMS), because an intent that
reaches MainActivity malformed must still put the user somewhere sensible. An
unknown action is null and is never rescued by its extras. The chooser's
candidate ids live in ClockulaShell as a rememberSaveable { LongArray }, so
a rotation mid-question does not lose it. A process death does, which is
correct, because the intent that asked has been consumed.
Process death and reboot
- Process death costs nothing. The countdown lives in
ends_at_elapsed_realtimeand the AlarmManager registration survives the process.ClockulaApp.onCreatecallstimerEngine.onBootCompleted()after the alarm engine's pass, which sweeps, re-registers and re-syncs the service. If the service itself was killed, the expiry broadcast starts it again. - Reboot is §5's
RebootRepair, and M6 gave it a caller that rings:TimerEngine.onBootCompleted()callsrepairIfRebooted()inside its own lock, then sweeps, so a timer whose end passed during the reboot is expired by the repair and sounds when the device comes back. The boot-id gate makes the second call of a boot a no-op, so neither engine depends on the other's ordering. MY_PACKAGE_REPLACEDclears AlarmManager, so it callsresync().
The tab
A LazyColumn in storage order (sortOrder, then id), which is exactly what
the repository emits, so the screen re-asserts the order rather than inventing
one. An expired timer does not jump to the top: a row that moves under a
thumb is worse than a row the user has to look for, which is the answer M5 gave
for alarms. The pill and the notification pick a subject; the list does not
reorder for it.
Setting a timer is a keypad, a row of preset chips, the readout and one
Start. The same panel is hosted in a ModalBottomSheet over a
non-empty list and inline on an empty one, where it is the empty state, so
there is no "no timers yet" card to look at and then dismiss. This deliberately
departs from M5's create-then-navigate: an alarm is live the moment it exists, so creating it
first is honest, while a timer has a natural commit point and a row created by
an accidental FAB tap would be litter. There is still no sentinel id, because
the panel is not bound to a row at all.
The typed digits and the sheet's open flag live in the composable as
rememberSaveable, not in a ViewModel, so they survive rotation and process
death; the pure TimerDurationEntry is derived from the saved digit string, and
Start is guarded by a ViewModel re-entrancy flag. TimerDurationEntry shifts
digits in from the right and reads them as h*3600 + m*60 + s without
clamping minutes or seconds to 59, so 0:00:99 is 99 seconds. That matches
Google Clock's forgiving behaviour, and it is a pure function with obvious tests. canStart is false
outside 1 s…24 h.
Presets are app-wide quick-start durations in DataStore (§6), not a property
of one row: a timers row already is its own preset, because it persists and
reset returns it to its configured duration. The whole management surface is
two gestures on the setup panel: a Save chip when the typed duration is not
already one, and each chip's own remove affordance. The keypad starts empty;
there is no default timer length. (M2 built a default_timer_duration_millis
preference and M10 gave it a settings row, but nothing that creates a timer
ever read it, so it was removed. A backup file that still carries the key
imports with it ignored.)
A timer has exactly one per-setting override, its ringtone, because timers
has exactly one nullable settings column. Vibrate and the ramp come from
ClockDefaults' timer-only timerVibrate and timerVolumeRampSeconds (#22),
not the alarm defaults. The picker is M5's, moved to ui/common/, so the row inherits
all of its behaviour for free: "App default" naming what it resolves to, Silent
as an explicit sentinel that forces vibration, a SAF-picked file that is still
stored when its grant cannot be made persistent, and an unreadable sound
reported, never rewritten.
The expressive parts are LinearWavyProgressIndicator across each card (its
amplitude is the state: the default wave while running, flat while paused or
finished), a kit GroupedSurface per card with positionOf(index, count), a
ButtonGroup of FilledTonalButtons with the press-widening animateWidth,
and tertiaryContainer for an expired card, which is exactly the token the
live pill already uses for an expired timer, so the two surfaces agree. The
readout is headlineLarge and the setup panel's entry is displayMedium,
plain scale roles, because ui/theme/Type.kt says the big-readout ramp gets
settled against a working stopwatch in M7, not guessed at in the scaffolding.
15. The stopwatch
Read this before changing anything stopwatch-shaped.
The shape
StopwatchEngine is the third engine and a peer of the other two: plain Kotlin,
no android.* import ever (a build rule), reaching the platform through
one seam it does not implement, StopwatchServiceHandle. Every public entry
point is serialised on one non-reentrant Mutex, because they arrive from
threads that know nothing about each other: a notification-button broadcast,
the service's own collector, the tab's ViewModel, the shell's pill,
ClockulaApp's launch pass.
It is deliberately much smaller than TimerEngine, because the stopwatch has
less to own: no AlarmManager registration at all (nothing has a deadline),
no ring (nothing expires), no arbitration (it makes no sound), and one
record rather than N rows. It does have four verbs (start, pause,
lap, reset) reachable from the shade with no ViewModel in sight, which is
why they live on the engine and not on a screen. StopwatchRepository stays the
storage face and gained exactly one method, lapCount(); nothing above the
engine calls its lifecycle verbs any more, including ShellViewModel (§12).
Like the other two it is not driven by a Flow: resync() is called explicitly.
The service type, and why not systemExempted
specialUse, with a stopwatch subtype, and one new permission. The reasoning
is in §9. Of this milestone's decisions, it deserves the hardest look: the
easy answer would have the app tell the platform it is continuing alarm
functionality, which it is not.
The service is up while the run is RUNNING or PAUSED, and down when it
is IDLE. That is exactly the live pill's predicate and exactly
StopwatchReadings.isActive, so the shade, the pill and the tab cannot disagree
about whether there is a stopwatch. PAUSED is in it so a user who paused from
the shade still has a Resume there.
One consequence, accepted: after a reboot the repair leaves a running stopwatch paused, so the service comes back up with a paused notification nobody asked for. It is truthful (the stopwatch is paused and its laps are intact), and its own Reset clears it in one tap. Resetting automatically would destroy laps the user recorded, which is worse.
The chronometer base is derived and never stored
The running notification is the platform chronometer counting up:
setUsesChronometer(true) + setChronometerCountDown(false) + setWhen(base),
so the system renders the ticking and the service posts once per state change
rather than sixty times a second. The base is wallClock.now() - elapsed,
computed at post time and never written anywhere. That is how the
notification gets a wall-clock base without breaking §5's "StopwatchRun
carries no wall-clock field whatsoever", a rule a test asserts on the stored
bytes. The timer's equivalent base is a stored column and therefore needs
re-anchoring on a clock change; the stopwatch's needs nothing but a re-post,
which is all onSystemTimeChanged() does.
The shade's readout is therefore whole seconds. A paused body is frozen, so it
shows the full StopwatchFormat.precise value, since it is stable and the
precision is the point of having stopped. The channel is its own, stopwatch, at
IMPORTANCE_LOW with sound and vibration off: nothing here ever needs to
heads-up, because nothing here ever finishes. That is why there is no
alert-once rule, no alreadyAlerted flag and no session window anywhere in this
path. Notification id 1_003; actions are broadcasts into
StopwatchActionReceiver carrying no extras at all, because there is
exactly one stopwatch.
The readout is hundredths, and it is two strings
StopwatchFormat.readout returns a StopwatchReadout(major, hundredths), not
one string: the tab draws major at the hero scale and hundredths two ranks
down in onSurfaceVariant, baseline-aligned. A readout whose hundredths are the
same size as its seconds makes the fastest-changing glyphs the loudest ones and
drags the eye off the number that matters.
major is ClockFormat.elapsed, called rather than reimplemented, so the
stopwatch, the live pill and the timer row can never disagree about what a
duration reads, and the truncate-never-round rule is inherited rather than
restated. hundredths is (millis % 1000) / 10, zero-padded through
Locale.ROOT for the same glyph-column reason. Truncated, never rounded: a
stopwatch must never read ahead of reality. Hundredths and not tenths (too
coarse to time anything by hand) and not milliseconds (three digits that are
noise at 60 Hz).
StopwatchReadings: one reading, three callers
StopwatchReadings.of(run, elapsedRealtime) is the single pure reading, and
LivePillSelector, StopwatchNotificationPolicy and StopwatchRows all call
it. That is the same extraction M6 made for TimerReadings, for the same reason:
three copies of "what does this read right now" is how three surfaces start
disagreeing. LivePillSelectorTest passes unmodified, which shows the
extraction changed no behaviour. Its two rules are in §5: the mode comes from
the snapshot, and the value is floored at the last lap's total.
Best and worst
Decided in LapStats, because the roadmap left them open:
- Fewer than three recorded laps ⇒ no emphasis at all (
LapStats.MIN_LAPS, asserted as a constant so a change is deliberate). With one lap there is nothing to compare; with two, "best" and "worst" mark every row and say the same thing twice. - Ties go to the lowest lap index. "First to achieve it" is the defensible reading and it is deterministic.
- If the fastest and the slowest split are equal, neither exists. Every lap identical is not a run with a best and a worst in it.
- The in-progress lap is never emphasised and is never in
LapStats' input: it has not finished, so it cannot be the fastest. A lap can therefore never be both, which falls out of the rules rather than needing a guard.
Best is colorScheme.primary and worst is colorScheme.tertiary, both scheme
tokens. error is deliberately not used, because a slow lap is not a
failure. Colour is not the only channel: each row carries a content description
naming what it is, since a distinction a screen reader cannot hear is not a
distinction.
The tab
A fixed readout and its controls sit above a lap list, so scrolling a long
list cannot put Lap out of reach mid-run, and the list runs newest first,
which is Google Clock's order (PLAN.md §11). A lap row is a three-column table
(index · split · total) and therefore a bespoke Row rather than a
GroupedRow: ListItem has one trailing slot and a table has two figures.
The first row is the lap in progress: index laps.size + 1, cumulative the
current reading, split elapsed - lastLapCumulative floored at zero. That is
what stopwatch_last_lap_cumulative_millis has existed for since M2 with no
caller. It appears only once at least one lap has been recorded: with none,
its split and its cumulative equal the hero figure, so the tab would print the
same number three times. It is shown while paused too, frozen, because the user
stopped mid-lap and that is true.
The vocabulary is Start · Pause/Lap · Resume/Reset. An idle stopwatch offers one button: a Lap with nothing to lap and a Reset with nothing to reset are dead controls. Reset is not offered while running: the user pauses first, which is Google Clock again. The pill's Stop still resets from any state, which is M4's contract and unchanged. The word "Stop" is deliberately absent from this tab: in Clockula "Stop" already means "reset", and a Stop that paused would be the one inconsistency in the app's verbs.
The cadence is StopwatchDefaults.ReadoutTick (16 ms, about 60 Hz, the
display's own cadence), and the ticker is subscribed only while the stored
state is RUNNING. A paused or idle stopwatch therefore costs nothing, and the
flow is distinct-until-changed, which absorbs the one case where the record says
running and the reading does not move. The cadence is a Ticker parameter, never
a delay at a call site.
StopwatchLaps.MAX = 999. Beyond it the Lap button is disabled (carrying "Lap
limit reached" as its description) and StopwatchEngine.lap() returns null. An
unbounded table behind a button that can be held down is a storage leak the user
cannot see or clear except by resetting, and 999 keeps the index column three
digits wide, which is a layout fact as well as a limit. The guard is the
engine's, under its lock, over lapCount().
There is deliberately no MaterialShapes morph and no wavy progress. A stopwatch
has no progress (there is nothing to be a fraction of), and a decorative arc
would be a lie drawn to two decimal places.
The typography, settled here for the whole app
ui/theme/Type.kt carried this as a written promise from M0, and M7 settles it
as two things.
ClockulaTypography applies tabular figures (fontFeatureSettings = "tnum")
to the display, headline and title roles of the M3 default. That is the app-wide
half: every existing readout (TimerRow's headlineLarge, TimerSetupPanel's
displayMedium, the live pill's titleMedium) stops jittering as its digits
change, with no call-site change anywhere. Body and label roles keep
proportional figures, because prose is not a column of numbers.
ClockulaReadoutDefaults names the three roles a readout needs, in the M3
*Defaults shape (composable getters over MaterialTheme.typography, so a
theme change still flows): Hero (displayLarge, FontWeight.Medium),
HeroFraction (headlineMedium, about half the hero's size) and Figure
(bodyLarge with tabular figures, for a figure inside a table row, the one place
body-scale text is a column of numbers). No new font, no size outside the M3
scale, and no weight above Medium: PLAN.md §8's "Expressive ≠ big or bold"
applies hardest to the one screen most tempted to break it. A build rule keeps
fontFeatureSettings under ui/theme/ so it is configured once.
16. The world clock
The section to read before changing anything zone-shaped.
There is no engine, and there must not be one
AlarmEngine, TimerEngine and StopwatchEngine exist because each owns a
platform registration that has to move with stored state: an AlarmManager slot,
a foreground service, a ring session. A world clock owns none of them. It
schedules nothing, wakes nothing, rings nothing and posts no notification, so a
fourth engine would be three empty seams and a Mutex guarding nothing.
M8's shape is therefore the one §13/§14 describe for a screen: a pure state
builder (WorldClockRows), a Source that combines the repository, the prefs,
the directory, the clocks and the ticker into it, and a ViewModel that calls the
repository and SettingsPrefs directly. Every verb the tab has (add,
remove, reorder, setHomeZone) is a plain write with nothing to actuate
afterwards.
The ICU seam, and which half of tzdata is pure
The device's tzdata gives two different things, and they belong on two different sides of the boundary.
The ids are java.time.ZoneId.getAvailableZoneIds(): plain JVM, and
therefore assertable in a unit test with no fake at all. They arrive through the
existing ZoneProvider seam as available(): Set<String>, because a static
getAvailableZoneIds() at a call site is the same ambient read PLAN.md §5
bans for now(), and a test needs to hand over a small set.
The names (the exemplar city, the country and the long zone name) are
android.icu, which does not exist on the JVM test classpath. They go behind
ZoneNames in data/zones/, in exactly the shape RingtoneCatalog already
has. Every method is total and never throws: ICU on an unusual OEM build
must degrade a caption, not crash a tab, so IcuZoneNames wraps every body in
runCatching and reads a blank answer back as no answer. A build rule pins it:
android.icu appears only under data/zones/.
ZoneDirectory is the @Singleton that joins the two. It runs on
@IoDispatcher (§7), caches the catalog on the locale tag under one
Mutex, and builds lazily: the tab resolves one entry per stored clock
through entryFor, and only the picker ever calls entries(). Those entries
are memoised here too, on the same locale tag, so a per-app language change
re-resolves the cities; a cache above this seam would keep the rows in the old
language while the ICU zone name switched. The long zone name is memoised on
(locale, zoneId, DST phase), so a per-tick caller costs a map lookup and a tab
left open across a transition picks up "Summer Time" on the next tick instead of
going stale until the process restarts. Both memos are asked in batch
(entriesFor, displayNamesOf), so one tick costs two dispatches rather than
two per row.
The catalog: canonical, region-based ids, plus UTC
ZoneCatalog.pickableIds keeps an id iff its first segment is one of the ten
IANA regions and it is its own canonical id. The first rule drops
Etc/GMT+5, US/Pacific, SystemV/AST4 and EST5EDT, which are not city
ids, and a user picking "GMT+5" is picking a fixed offset that will be wrong in six months.
The second drops the backward aliases, so the picker offers Asia/Kolkata
and not also Asia/Calcutta, and the answer comes from the tzdata's own
canonicalisation through ICU rather than from a table of ours that would rot.
That applies PLAN.md §0's second commitment. An alias is only dropped when its
canonical id is itself available: ICU (com.android.i18n) and tzdata
(com.android.tzdata) are separately updatable APEX modules, and a device whose
tzdata still says Europe/Kiev while ICU already says Europe/Kyiv must not be
left with no pickable zone for Ukraine at all.
UTC is the one special case, kept by name: it passes neither rule, and a
clock app that cannot show UTC is missing the one zone every traveller and every
log file shares. It is kept only when the device actually knows it; nothing is
invented.
Search is word-prefix over folded text, not substring: NFD, combining marks
stripped, lowercased through Locale.ROOT, split on anything that is not a
letter or a digit. So "São Paulo" folds to "sao paulo", "york" and "new yor" and
"yor new" all find New York, "germany" finds Berlin through the country, and
"erl" does not find Berlin, because a three-letter query must not turn into forty
unrelated hits. Filtering preserves the catalog's own order rather than ranking,
because the catalog is already sorted by city and an alphabet everybody can
predict beats a relevance score nobody can.
Since M9 the folding itself lives in domain/text/TextFolding, and ZoneSearch
delegates to it: ALARM_SEARCH_MODE_LABEL has to fold the same way, and a
second copy of NFD-fold-and-lowercase is how "café" starts matching on one
surface and missing on the other. The matching is still different on purpose
(the picker is word-prefix, the label search is substring, §17).
ZoneSearchTest passes unmodified, which shows the extraction changed no
behaviour.
The offset and the day are read at the instant
ZoneComparisons.compare(home, other, at) reads both zones' offsets at at
and subtracts. Nothing is stored, nothing is cached, and no arithmetic is done
on a "standard offset":
offsetMinutes = other.offset(at) − home.offset(at), in minutes
dayDifference = DAYS.between(home local date, other local date)
That is the discipline AlarmOccurrences uses for alarms (§11): let java.time
answer the transition. Berlin↔Sydney is ten hours apart in January and
eight in July, and a subtraction of standard offsets would get both wrong.
The day comes from the two local dates, not from the offset: Berlin and
Honolulu are eleven hours apart and still on the same calendar day.
The labels are data, not strings, following NextFireFormat's precedent:
OffsetLabel.Same | Ahead(h, m) | Behind(h, m) and
DayLabel.YESTERDAY | TODAY | TOMORROW. The wording lives in strings.xml, and
h/m are non-negative magnitudes with the sign carried by the variant, so
Kathmandu is Ahead(4, 45) and not a signed 285 the translator has to
interpret. dayLabelFor clamps to ±1 to stay total; the real spread between the
extreme zones is 26 hours, so ±1 is the only outcome tzdata can reach.
Home
HomeZone.resolve(stored, deviceZone) is the whole rule: the stored zone when
the device still knows it, else the device's own. It is one pure function, so
the fallback is tested rather than implied, and ClockPrefs.homeZoneId already
normalises through Zones in both directions (§6), so a zone the tzdata later
dropped reads back as absent before resolve ever sees it.
Consequences, all deliberate. Nothing is seeded: a fresh install shows the
device's zone on the face and an empty list, and the face is not a stored row.
Home is set from this tab, through the top-bar action, because a preference
the user cannot reach is not handling; M10 may surface it a second time. The
home zone may also be added as a row, which is not prevented: add is
idempotent on zone_id, and that row simply reads "Same time as home".
The automatic local clock (#24)
Google Clock's "automatic home clock" adds a home row while you travel, because its hero is local time. Clockula's hero is home, so on a trip home is already on screen and the clock that goes missing is the local one. The feature is therefore turned round: while a home zone is stored and the device's zone is on a different UTC offset at this instant, the tab shows the device's zone as a temporary row between the face and the saved cities, labelled "Local time" and read against home like any other row.
LocalClock.zoneToShow is the whole rule, pure and tested. The comparison is
the offset now, as Google's is, not the zone id: Paris while home is Berlin
shows nothing, and Phoenix while home is Denver shows a row only in summer.
The row is hidden when a saved city is already on the device's offset, since
that city already shows the local time. Home that follows the device can never
differ from it, so with no stored home the rule is always null, and Settings ›
Clock says so under the switch rather than hiding it. A device set by UTC
offset rather than by city reports an id like Etc/GMT-9, whose last segment
has its sign reversed, so that zone is named by its real offset ("GMT+9")
instead, wherever a zone falls back to its id for a name (ZoneCatalog.cityFallbackFor).
The row is not stored. It is WorldClockUiState.Loaded.local, built per tick
by the source from the same zones.current() read the face uses, so a
TIMEZONE_CHANGED brings it in or takes it out within a tick with no receiver
(D2). It sits outside the ReorderableColumn, carries no move or remove
actions, has the reserved id WorldClockRows.LOCAL_ROW_ID (-1; Room ids
start at 1), and does not count towards WorldClocks.MAX. The switch is
automatic_local_clock, on by default and carried in the backup's settings.
The face, and why its morph is information
PLAN.md §8 sanctions a MaterialShapes morph on the analog face and warns it
must not be "sprinkled everywhere". M7 refused one on the stopwatch with the
sentence this has to answer: a stopwatch has no progress, and a decorative arc
would be a lie drawn to two decimal places.
So the morph here carries information. The dial is
Morph(MaterialShapes.Circle, MaterialShapes.Sunny) and its progress is
DayNight.fractionAt(localTime): a plain circle in the middle of the night
there, a sun at midday. "Is it the middle of the night where they are" is
precisely the question a world clock exists to answer, and answering it with
shape rather than a third line of text is what §8's "refinement comes from
shape, colour, space and motion" means.
DayNight is a stated convention rather than an ephemeris: 0 below 05:00, a linear
ramp to 1 at 07:00, 1 until 17:00, a linear ramp back to 0 at 19:00. It needs no
latitude, it has obvious tests, and the app never claims it is sunrise.
The rest is ordinary Canvas work over AnalogFace's pure angles: twelve hour
marks with the four quarters longer, the hour and minute hands (and a second
hand when seconds are on, below), a centre cap, all in scheme tokens. The morph is built once and the path re-derived into a reused
Path per frame through toPath(Morph, Float, Path), which is not
@Composable and is therefore callable from a DrawScope. M8 left the digital
time out from under the face on the grounds that an analog face that also
prints its own time is a face nobody looks at (D11). That is reversed: the
home time now sits under the face as a displaySmall readout in the app's
12/24-hour format, because the exact minute is what most visits to the tab are
for and a dial is the slow way to read it. The face keeps the day/night shape
digits cannot carry. With the time on screen as text, the face is hidden from
TalkBack, and the readout, "Home", the city and the zone name are one merged
node, so the time is read once. Neither is a live region, since that would recite
the clock for as long as the tab was open, which is the mistake M4 already
refused for the live pill (§12).
The cadence
Display time with seconds (Settings > Clock, world_clock_show_seconds,
off by default, #23) decides how often the tab wakes. Off, the face has an hour
and a minute hand, the readout under it and every row show hours and minutes,
and the tab ticks once a minute. On, the readout and the rows add seconds
in the same 12/24-hour and locale form (HH:mm:ss, or the locale's medium
time), the face gains its second hand, and the tab ticks once a second.
The second hand belongs to the switch rather than being always there: a hand
that steps every second is exactly what makes the tab wake every second, and
the point of the switch is that nobody pays for that without asking.
Both cadences are Ticker.alignedTicks (§12), at
WorldClockDefaults.tickFor(showSeconds), which is derived from the same
unitFor the readouts are truncated to, so a tick lands on the wall-clock boundary itself rather than
up to a period late; a free-running one-second delay could leave the second
hand a full second behind the device clock. This replaces M8's 200 ms
free-running tick, which got within a fifth of a second only by waking five
times as often as anything changed. HomeFaceState.time and
WorldClockRowState.time are truncated to the same unit the tab ticks in, and
the flow stays distinctUntilChanged, which now only absorbs the odd early
wake. The setting is read with the stored list and the home zone, so flipping
it switches the cadence at once through the same flatMapLatest.
A minute is too long to wait for TIME_SET or TIMEZONE_CHANGED, which can
arrive with the tab on screen (a network zone change on landing, split
screen). So ClockChanges (AndroidClockChanges, a receiver registered only
while the tab collects) restarts the tick: those two make the state re-read
the clock and the zone at once, and the restarted ticker measures its next
wait against the clock as it now reads. The signal is combined
with the stored list, the home zone and the setting outside the
flatMapLatest, so the receiver is registered once per subscription, not
once per list edit. Each broadcast costs one extra build, which
distinctUntilChanged drops when nothing moved. The screen also sends a
refresh on every resume, merged into the same signal. A per-app language
change recreates the activity but not the ViewModel and is announced by no
broadcast, so the city names would otherwise wait for the next minute to
change language. And an aligned delay has one gap of its own, a deep sleep
inside the five-second WhileSubscribed grace, which stretches the wait
because the main looper's clock stops while the device sleeps; the tab
cannot be seen again without a resume, which re-reads the clock.
TalkBack is given the time to the minute even with seconds on: the row's description and the home readout's semantics use the minute formatter, and the ticking text is kept out of the tree, because a focused node whose text changed every second would be re-announced every second.
Twenty-four cities, and a reorder a screen reader can reach
The kit's ReorderableColumn is not lazy and pins rows to a 64dp pitch, so the
list needs a bound: WorldClocks.MAX = 24, one per hour of the day, a list
a person can take in at a glance. Past it the FAB carries "Add up to 24 cities"
as its description, the same sentence is printed below the list where a sighted
user can read it, and the ViewModel refuses the add. (M3's
ExtendedFloatingActionButton has no enabled parameter to dim it with, and a
refusal only a screen reader can hear is a dead button to everybody else.) This
is the same shape as StopwatchLaps.MAX = 999 (§15): a constant with a reason,
asserted as a constant so a change is deliberate.
A drag-only reorder is inaccessible. TalkBack cannot press-and-drag and neither
can a keyboard, and M4 settled the principle when the hold challenge grew an
accessibility onClick: a challenge TalkBack cannot answer is exactly the
stranding the requirement forbids. So every row carries three custom
accessibility actions (Move up, Move down, Remove) alongside the
handle and the long-press. The row also merges its descendants under one
content description (the city, the time and the summary as one sentence)
rather than letting TalkBack read three child texts in child order; that is
also the only place the "no longer on your device" sentence is spoken. The arithmetic behind the first two is pure
(WorldClocks.moveUp/moveDown), a no-op at the end it is already at and for
an id the list does not hold, and always a permutation of its input; a move that
changes nothing writes nothing.
Remove is a long-press plus a confirmation that names the city, with
deliberately no undo chip. That was the trade M5 and M6 made too, and the
alarm and timer lists have since moved to an Undo (§13, §14); removing a city
still asks first. The pending id is rememberSaveable, so a rotation
mid-confirmation does not lose the question.
A row whose zone the device's tzdata no longer knows shows no time, no offset
and no day (WorldClockRowState carries all three as null, non-null
exactly when known is true) and says "This time zone is no longer on your
device" instead. The row is kept, never rewritten: §4 already decided that, and
inventing a time for a zone nobody can resolve would be worse still.
17. System interop
android.provider.AlarmClock is the contract every other app on the device
drives a clock app through: an assistant, an automation app, a watch companion,
a shell script. M9 answers all seven of its actions: SET_ALARM,
SET_TIMER, SHOW_ALARMS, SHOW_TIMERS, DISMISS_ALARM, SNOOZE_ALARM and
DISMISS_TIMER. This is the section to read before touching anything
intent-shaped.
The shape: one pure parser, one Android adapter, one exported door
another app / Assistant
│ android.intent.action.SET_ALARM + extras + maybe a data URI
▼
interop/AlarmClockActivity ← exported, permission-guarded, no window
│ (1) IntentExtrasReader.read(intent) Android → plain Map
│ (2) AlarmClockRequests.parse(...) PURE. the validator.
│ (3) AlarmClockHandler.handle(request) suspend, @ApplicationScope
│ (4) InteropLaunch.intentFor(outcome) PURE. what to show, if anything
▼
MainActivity (internal actions only) → ShellNavigation.requestFor → shell
Everything that decides is pure Kotlin under domain/interop/. The Android
half is two files that carry no policy. AlarmClockHandler is the orchestrator
and contains no android.* import ever, exactly as the three engines do.
The write never happens in the shell and never happens twice: the door
handles the intent, then hands MainActivity an internal action that is
idempotent navigation and nothing else. That is why a process death recreating
MainActivity with its original intent cannot re-run a SKIP_UI write, which
it would if MainActivity parsed SET_ALARM itself. The handler runs on the
injected @ApplicationScope with async, and the door awaits it in
lifecycleScope, so the write outlives a task swiped away mid-flight, while
the startActivity that follows only happens if the door is still alive. The
await is wrapped in runCatching: a caller's broken intent must not take the
app down mid-alarm.
A Bundle modelled honestly
Hostile input is more than "an out-of-range int". It is an Int where a String was
documented, a CharSequence that is not a String, a list with a null in it,
a key that is present and null. The platform's typed getters hide all of that
behind a default and cannot tell "absent" from "wrong type", which is the
distinction this milestone is about. So the parser's input is
IntentExtras, a value class over Map<String, Any?> whose four accessors are
total: absent or wrong type reads back as null. The adapter lifts
exactly the contract's eleven keys out of the Bundle with one documented
@Suppress("DEPRECATION") on Bundle.get (the only API that returns a
heterogeneous value with its type intact), so there is one copy of the type
rules, and it is the copy the tests run. string() accepts any CharSequence
(a SpannableString from another app is a legitimate message); int() accepts
an Int and nothing else, because a caller sending 7L for EXTRA_HOUR is
broken and guessing on its behalf is how an alarm ends up at the wrong hour.
The range rule
PLAN.md §6 says extras are "validated and clamped"; M6 locked the opposite for
timer presets, "dropped, never clamped". Both are right about different things,
and M9 needs one rule:
A value that decides when something will ring in the future is dropped when it is out of range. A value that modifies an action the user is taking right now is clamped.
| Extra | Range | Out of range |
|---|---|---|
EXTRA_HOUR |
0..23 | dropped ⇒ the spec is incomplete ⇒ the editor opens |
EXTRA_MINUTES |
0..59 | dropped ⇒ incomplete. Absent ⇒ 0, the contract's own default |
EXTRA_LENGTH |
1..86 400 s | dropped ⇒ the setup panel opens |
EXTRA_DAYS entry |
Calendar 1..7 | dropped, per entry, but see below |
EXTRA_RINGTONE |
a scheme we accept | dropped ⇒ inherit the app default |
EXTRA_ALARM_SNOOZE_DURATION |
1..60 min | clamped |
Clamping the hour would set an alarm for 23:00 that the caller asked to set for
25:00, enabled, and ringing at an hour nobody chose. Clamping the snooze is the
opposite case: the alarm is sounding now, the user asked for quiet, and
refusing a 1 000-minute snooze by leaving it ringing is strictly worse than
granting a 60-minute one. 1..60 is ClockPrefs' own clamp, so a snooze an intent
can ask for is always one the user could have chosen by hand.
EXTRA_DAYS has one more rule, and it is the same rule read from the other
side. Entries are dropped one by one, but a list the caller sent non-empty
and from which nothing survived makes the spec incomplete, so the editor
opens and the user sees it. What counts is what was sent, not what parsed:
[0, 99] and ["mon", "tue"] (the second is what
putStringArrayListExtra produces) are the same mistake, and treating either
as "the caller asked for a one-shot" turns a repeating alarm into an enabled
single alarm, which is a missed alarm next week. Only an explicitly empty
list, an absent one, or a value that is not a list at all is a one-shot.
IntentExtras.listSize exists for exactly this: intList filters by type, so
it cannot tell an empty list from a list of nothing but rubbish, and here the
two must not share an answer. A list sent as an IntArray counts as a list,
because putExtra(key, intArrayOf(...)) is what a caller writes when it does
not reach for putIntegerArrayListExtra.
Two more sanitisers. EXTRA_MESSAGE becomes a label: trimmed, stripped of every
character below a space (a newline in a one-line ListItem is a layout bug and
a \u0000 in a TEXT column is worse) and truncated to 256 characters.
It is truncated rather than dropped because half a label still names the alarm,
while half an hour does not. EXTRA_RINGTONE is accepted only if, after trimming, it is at
most 2 048 characters, carries no control character and begins with content://
or android.resource://; "silent" (exact, case-sensitive) maps to
Ringtones.SILENT_URI, which forces vibration, so a silent alarm set by another
app is still an alarm. Everything else (file://, http://, a bare path)
reads back as null, which in Clockula means inherit, which is exactly the
contract's own documented fallback. It is a drop, not a rejection: an unusable
sound must never stop an alarm from being set.
What each action does
| Action | Behaviour |
|---|---|
SET_ALARM, valid hour, SKIP_UI |
create-or-reuse the alarm, enabled, transient; no window |
SET_ALARM, valid hour |
the same, not transient, then the Alarms tab |
SET_ALARM, no valid hour |
create at nextWholeHour carrying every extra that was valid, enabled, never transient, then its editor (§13) |
SET_TIMER, valid length |
create-or-reuse a timer and start it ("this action always starts the timer"), with or without the Timers tab |
SET_TIMER, no valid length |
write nothing; the setup panel |
SHOW_ALARMS / SHOW_TIMERS |
the tab; no extra is read |
DISMISS_ALARM |
see below |
SNOOZE_ALARM |
the ringing alarm only, for the clamped minutes; nothing written if nothing is ringing |
DISMISS_TIMER |
with no data URI, every expired timer; with one, the timer clockula://timer/{id} names. A URI naming no timer of ours dismisses nothing and opens the Timers tab, because a mistyped id must not widen to "all of them", which for a transient timer means deleting them (§14) |
| anything else, or null | Unsupported: no write, no window, since opening the app because somebody aimed garbage at an exported activity would be a free way to take over the screen |
Reuse ("an identical alarm may be re-used") means time, repeat days, label, ringtone and vibrate all equal the sanitised request. Those are the five fields the extras can set, so a snooze override the user set by hand does not prevent it. Ties go to the lower id, the same rule the alarm engine's precedence already uses. A reused alarm is enabled and has any pending skip cleared, because leaving one armed would silently eat the occurrence just asked for, and it is never made transient, as the contract's own parenthesis says. An alarm with a scheduled date or a pause is never identical: the extras cannot express either, and re-using it would either keep the caller's alarm quiet until the pause ends or throw away a holiday the user set, so a new alarm is created instead. A timer is identical only if it is IDLE with the same duration and label: restarting somebody's running countdown is destructive.
DISMISS_ALARM: which alarm, and when to ask
| Source | Candidates |
|---|---|
data URI clockula://alarm/{id} |
that alarm, enabled or not |
ALARM_SEARCH_MODE_NEXT |
the ringing alarm if there is one, else the next to fire |
ALARM_SEARCH_MODE_ALL |
every enabled alarm |
ALARM_SEARCH_MODE_TIME |
every enabled alarm at exactly that time of day |
ALARM_SEARCH_MODE_LABEL |
every enabled alarm whose folded label contains the folded phrase |
| neither | every enabled alarm |
| a mode whose parameters are unusable | none |
| a data URI that names no alarm of ours | none |
Three deliberate narrowings, each because the alternative is worse. A data
URI that is present and unreadable is not the same as no URI: it names none,
rather than falling through to the search mode (or, with no mode given, to the
unspecified search's every enabled alarm). Otherwise a caller that named one
alarm and mistyped its id would dismiss alarms it never named, and with exactly
one enabled alarm that happens silently, below the two-match dialog. Time
matches exactly, never "nearest": the contract says "most closely matched", and
dismissing an alarm the caller did not name is a missed alarm, at any distance.
EXTRA_IS_PM is consulted only when the hour is ambiguous: 13..23 is a
24-hour reading and wins over a contradictory flag, 12 + AM is midnight and 12 +
PM is noon, and a 0..12 hour with the flag absent (the case the contract calls
ambiguous and says to ask about) carries both readings as candidates, so the
ambiguity only reaches the user when the data cannot settle it. Label search is
substring after folding rather than the zone picker's word-prefix, because "my
gym alarm" must find "Morning gym"; a blank phrase matches nothing, since a
needle that matched everything would dismiss every alarm in the app.
When to ask: ALL means "all of them", so it dismisses every match and never
asks; taken literally, the contract's "show the results" would make that mode
useless. Every other search with two or more matches returns
ChooseAlarmToDismiss and writes nothing; with one it dismisses; with none it
opens the Alarms tab and writes nothing. "Dismiss" is
AlarmEngine.dismissUpcoming (§11), which is also what the dialog calls.
delete_after_use
The contract, twice: a SKIP_UI alarm that is not repeating, and a SKIP_UI
timer, "should be removed after it has been dismissed". Without it, "wake me at
6:30" every morning leaves a disabled 06:30 row behind every single time. The
flag is set only on the SKIP_UI create path (never on reuse, never on the
incomplete/editor path), so only the voice/automation route can produce a
transient row, and it is honoured in two places, both inside an engine and both
under its lock (§11, §14). upcoming() stays read-only: collecting it over a
transient alarm deletes nothing.
One consequence, accepted rather than papered over: a user who edits an assistant-set alarm's label still has a transient alarm, and it still vanishes after it rings. That is Google Clock's behaviour too, the alarm is still "the one the assistant set", and the alternative (clearing the flag on any edit) means the editor has to know about a contract it otherwise never touches.
Deeplinks
Clockula accepts clockula://alarm/{id} and clockula://timer/{id}, parsed
strictly: exact lowercase scheme and host, one path segment of nothing but
digits, a positive Long, no query and no fragment, trimmed first because an
Intent's data may arrive from a shell command line. Anything else is null
and falls through to the search mode rather than being guessed at. It
publishes none in v1: publishing one requires the
VoiceInteractor.CompleteVoiceRequest flow that §10 puts out of scope. Reading
an id from an untrusted caller is safe: dismissing an alarm is reversible, the
caller already holds SET_ALARM, and every engine verb is guarded by the state
it makes sense for.
Next-alarm publishing, and no toast
setAlarmClock has drawn the status-bar alarm icon and the lockscreen line since
M3; M9 fixed what tapping them opens (§11). That icon is also why there is no
toast on a SKIP_UI write, where AOSP DeskClock has one: SKIP_UI means
"bypass any intermediate UI", a voice assistant speaks its own confirmation, the
app has shipped nine milestones with no toast anywhere, and formatting "Alarm set
for 7:00" would need a second, non-composable 12/24-hour formatter, the thing
M8 spent a milestone eliminating. The platform's own receipt is better than a
toast and costs nothing.
18. Bedtime
Issue #1: an opt-in Do Not Disturb mode, after Google Clock's Bedtime tab,
without a fifth tab. v1's four surfaces stay as they are; bedtime is a nested
destination (settings/bedtime) reached from a row on the settings hub, from
its own reminder notification, and from the mode's page in the system's
Settings.
Why a mode of Clockula's own
Digital Wellbeing's Bedtime mode is off limits. NotificationManagerService
refuses an AutomaticZenRule of TYPE_BEDTIME from any package but the one in
config_systemWellbeing, and no third-party app can trigger Wellbeing's mode or
read its schedule. What Android does offer an app with Do Not Disturb access
(ACCESS_NOTIFICATION_POLICY, granted by hand under Special app access) is an
automatic zen rule of its own:
- Android 15 and later: a mode, built with
AutomaticZenRule.Builder, typedTYPE_SCHEDULE_TIME, shown in Settings › Modes under Clockula's name and theic_bedtimeicon, with a trigger line ("23:00 to 07:00") and the publicZenDeviceEffectsthe screen offers: grayscale, dimmed wallpaper, dark theme and hiding the always-on display. The hidden effects (auto-brightness, tap/tilt to wake, doze, radios) are dropped byZenModeHelperwhen an app sets them, so the screen does not offer them. Manual invocation is allowed, so the user can also switch the mode on from Settings or Quick Settings. - Android 10 to 14 (minSdk 29): a plain Do Not Disturb schedule rule from
the seven-argument constructor that takes a
ZenPolicy. No type, no effects: the screen says so and hides the effect switches.
A rule needs an owner ConditionProviderService or a configuration activity.
Clockula uses MainActivity as the configuration activity: it is already
exported, so no new component is exposed. Settings opens it with the explicit
component, the rule id in NotificationManager.EXTRA_AUTOMATIC_RULE_ID /
ConditionProviderService.EXTRA_RULE_ID, and no action, so MainActivity
turns an action-less intent carrying either extra into the reminder's own
ACTION_SHOW_BEDTIME, and the shell opens the bedtime screen
(ShellRequest.OpenBedtime). There is no condition provider service:
Clockula reports the rule's state itself with setAutomaticZenRuleState.
The two modes coexist. A Pixel user who also uses Wellbeing's Bedtime sees two modes in Settings › Modes; the screen says so and suggests using one.
Alarms are never suppressed
The rule's ZenPolicy sets exactly two things, allowAlarms(true) and
allowMedia(true) (a sleep sound keeps playing), and leaves every other field
unset, so calls, messages and the rest follow the device's own Do Not Disturb
defaults. Clockula's alarms and timers play with USAGE_ALARM, so the
exemption applies. The rule is only rewritten when a field it carries changes
(the times, the effects, the switch; not the reminder), and a rewrite reuses
the rule's existing policy as long as that still allows alarms, so a user's
"who may interrupt" edits in Settings survive on Android 10 to 14 as well
(Android 15 keeps a user's edits over an app's updates by itself). An edit
that blocked alarms is the one thing a rewrite puts back. The self-check
(M10's SelfCheck) still reports total silence as a problem whatever caused it.
The engine
BedtimeEngine is plain Kotlin behind three seams, JVM-tested like the other
engines (ArchitectureRulesTest holds it to no android.* import):
BedtimeScheduler, one AlarmManager slot of its own. Its PendingIntent targetsBedtimeReceiverwith its own action and request code, so arming or cancelling it cannot replace or cancel an alarm's or a timer's registration. It isRTC_WAKEUP. Only the wake-up that ends the mode is allow-while-idle (setExactAndAllowWhileIdle, falling back tosetAndAllowWhileIdle), so Do Not Disturb cannot linger hours into the morning behind a doze deferral. That is one such fire a night, well inside the per-app allow-while-idle ration the timer's expiry slot also draws on (alarms themselves usesetAlarmClock, which is not rationed). Bedtime's start, the reminder and retries aresetExact(a ten-minutesetWindowwithout exact-alarm access), so they never spend that ration; in deep doze they wait for a maintenance window, which only delays a start in a phone nobody is using. It never usessetAlarmClock: that slot is the status bar's next alarm.BedtimeMode, the rule. Every call is guarded and a no-op without access.BedtimeNotifier, the reminder, on its ownbedtime_reminderchannel.
Every entry point is an idempotent pass over "where does the schedule stand
now", computed by BedtimeSchedule: a night's window runs from the bedtime on
one of the chosen nights (named by its evening, so a bedtime in the small hours, before 06:00, falls
on the next calendar day: "Thursday night" at 00:30 is early Friday, while a
day sleeper's 08:00 stays on the day picked) to the first wake time after it (an earlier wake time is
the next morning, decided on the local times so a bedtime in a spring-forward
gap cannot push a same-night wake time a day later; an equal one is no window
at all, since a day-long window on every night would be a Do Not Disturb that
never ends, and the screen says so), resolved from local times and the
zone like an alarm, so a DST night is as long as the wall clock says. The slot
is armed for the earliest of the current window's end, the next window's
reminder and the next window's start.
| Entry point | Called from | What it does |
|---|---|---|
update(transform) |
the bedtime screen, on the application scope so leaving the screen cannot cut a write short | stores the settings, rewrites the rule if a field it carries changed, and when the schedule or the rule changed sets the mode to what it says now (on at 23:30 for a 23:00 bedtime starts it at once; a reminder edit leaves a hand-switched mode alone), arms the slot. The screen's writes start undispatched, so they queue on the engine's fair lock in the order they were made. Off removes the rule, which ends the mode, cancels the slot and clears the reminder |
onSlotFired() |
BedtimeReceiver |
follows any boundary crossed, posts a due reminder, arms the next event. A reminder fire alone crosses no boundary, so it leaves a mode switched on by hand alone |
resync() |
ClockulaApp.onCreate (a force stop clears the slot), SystemEventReceiver (boot, app update, clock or zone change, exact-alarm permission) |
creates the rule if it is missing, follows any boundary crossed, arms the slot |
resync(refreshRule = true) |
Do Not Disturb access changing (the screen's ON_RESUME check) |
the same, but also rewrites the rule and re-asserts the mode's state |
resyncAfterRestore(previous) |
a backup restore that got as far as applying settings | compares what is stored now with what was stored before, field by field as an edit does, so restoring the same schedule, or one that differs only in its reminder, leaves a hand-switched mode alone |
"Follows any boundary crossed" is the one rule that lets a hand-switched mode
coexist with a schedule. The last state Clockula set is persisted
(bedtime_applied_active, device state, not in the JSON backup), and a pass
that is not an edit switches the mode only when the schedule now says
something different, or when the rule was just recreated (a new rule starts
off) or rewritten (Android 10 to 14 clear the condition on an update). Only
what the system accepted is recorded as applied. A refused state change is
remembered as owed (bedtime_state_owed) and a refused rule rewrite as stale
(bedtime_rule_stale), and every pass forces them until they land; meanwhile
the slot is pulled in to a retry fifteen minutes out, and a refused removal
when turning bedtime off keeps the slot armed the same way. The slot is armed
in a finally, so a throwing step (a DataStore write, a cancel) cannot leave
it unarmed. A launch in the afternoon leaves a mode the user turned
on alone; a launch after a force stop that slept through the wake-up time
turns it off. Each pass reads the clock once, so the mode's state and the
next slot always agree about which side of a boundary "now" is on. A posted
reminder is withdrawn by any pass once no coming bedtime is due within its
lead, so moving the schedule away from it does not leave a stale one, and its
title counts the minutes actually left, so a late fire does not promise the
full lead.
Every bedtime call on a shared path runs after the alarm, timer and
stopwatch passes and inside runCatching (in SystemEventReceiver, in its
finally, so a failure up there does not cost bedtime its pass either), so
nothing bedtime does can delay or break them. BedtimeReceiver and the
screen guard their calls too.
No stored rule id
NotificationManager.getAutomaticZenRules() returns only the calling app's
rules, and the rule's condition id (condition://<package>/bedtime) marks
Clockula's. So nothing about the rule is stored: a reinstall, a cleared app or
a restore onto another device finds the rule or creates it, and a stray second
one is removed. An update keeps the rule's own enabled switch, so a change on
Clockula's screen does not re-enable a rule the user disabled in Settings.
Access, granted and revoked
Do Not Disturb access cannot be requested with a dialog. The screen shows an
onboarding row while it is missing, deep-linking to
Settings.ACTION_NOTIFICATION_POLICY_ACCESS_SETTINGS, and re-reads access on
every ON_RESUME; a change found there runs resync(refreshRule = true) at
once (also when access is there but no rule exists yet, for a grant made while
the screen was closed), so coming back from granting it creates the mode.
The system's ACTION_NOTIFICATION_POLICY_ACCESS_GRANTED_CHANGED is not
listened for: it reaches runtime-registered receivers only, and the next
slot fire or launch picks a grant up anyway. Without access the schedule still runs for the
reminder. Revoking access in Settings removes the app's rules with it (that is
Settings' own confirmation dialog), and Clockula cannot remove a rule without
access, so a rule left by a revoke from outside Settings is the platform's.
Turning bedtime off removes the rule cleanly; the "last applied" state is
cleared only once the removal succeeded, so a refused removal is retried by the
next pass rather than leaving the mode on (an off schedule's cheap early return
still checks that no rule of Clockula's is left).
Backup
The settings travel in the backup's settings.bedtime object
(docs/BACKUP.md); a file from before issue #1 carries no bedtime and
keeps the device's current bedtime settings, and one with no nights at all
restores every night. BackupEngine.import hands the engine what was stored
before (resyncAfterRestore), after the replace or after rolling it back.