Clockula can now turn on a Do Not Disturb mode of its own at bedtime and off at the wake-up time, on the nights the user picks, with an optional reminder before it starts. Digital Wellbeing's Bedtime mode can't be driven by a third-party app, so this is a separate rule: a typed mode with grayscale, dimmed wallpaper, dark theme and hidden always-on display on Android 15+, and a plain DND schedule rule on Android 10-14. The rule's policy always allows alarms (and media). BedtimeEngine owns one AlarmManager slot of its own, on its own receiver and request code, and every bedtime call on the boot, time-change and launch paths runs after the alarm and timer passes, guarded. The rule id is never stored: the platform lists an app's own rules, so restores and reinstalls find or recreate it. Turning bedtime off removes the rule. The screen walks the user through granting DND access and explains the Wellbeing overlap. Settings are part of the JSON backup; older files restore bedtime as off. Refs #1
380 lines
20 KiB
Markdown
380 lines
20 KiB
Markdown
# Clockula — implementation plan
|
|
|
|
> The design decisions and their rationale (the "why"). Locked unless
|
|
> explicitly overturned here. For the moving status view see
|
|
> [`ROADMAP.md`](ROADMAP.md); for the shape of the code as it actually stands,
|
|
> [`ARCHITECTURE.md`](ARCHITECTURE.md) (written from M2 onward).
|
|
|
|
A Material 3 Expressive **clock** app for Android: alarms, timers, stopwatch and
|
|
world clock. Third floret of the bloom, after
|
|
[Calendula](https://codeberg.org/jlmakiola/calendula) (calendar) and
|
|
[Agendula](https://codeberg.org/jlmakiola/agendula) (tasks).
|
|
|
|
Identifiers: `applicationId = de.jeanlucmakiola.clockula`, app name **Clockula**,
|
|
licence MIT, `minSdk 29` / `targetSdk 36` / `compileSdk 37`.
|
|
|
|
---
|
|
|
|
## 0. The thesis — and the honest asterisk
|
|
|
|
The family thesis is *a nice M3-Expressive front end over open backends, no
|
|
reinvented storage or sync*. Calendula fronts `CalendarContract`; Agendula speaks
|
|
the dmfs `TaskContract`.
|
|
|
|
**Clockula cannot do that, and pretending otherwise would be a lie.** There is no
|
|
open provider behind a clock. Alarms, timers and laps are the app's own data, and
|
|
Clockula is therefore the family's **first app with its own storage by design**,
|
|
not as a fallback posture.
|
|
|
|
So the open-standards commitment is *relocated*, not dropped. Clockula honours it
|
|
in three concrete places:
|
|
|
|
1. **The platform alarm contract.** Clockula implements `android.provider.AlarmClock`
|
|
in full (§6), so Assistant, automation apps and anything else that speaks the
|
|
standard intent vocabulary can drive it. That contract *is* the open interface
|
|
for clocks on Android, the same role `CalendarContract` plays for Calendula.
|
|
Being a good citizen of it is the thesis, applied.
|
|
2. **IANA tzdata.** World clocks are IANA zone IDs, not a bespoke city table (§5).
|
|
3. **Your data leaves whenever you want.** A documented, versioned JSON backup
|
|
format via SAF (§7). Data you can't take with you isn't really yours.
|
|
|
|
### The one rule that keeps this honest
|
|
|
|
Clockula owns storage, but **nothing above the data layer knows Room exists**.
|
|
The app talks to `AlarmRepository` / `TimerRepository` / `WorldClockRepository` /
|
|
`StopwatchRepository`; only the DAOs and one data source per aggregate touch Room
|
|
entities. Domain models are plain Kotlin. **Never let a Room entity or a
|
|
`@Query` string leak above the data layer**. That is the same discipline that let
|
|
Agendula swap its whole storage posture without touching the UI.
|
|
|
|
---
|
|
|
|
## 1. Locked decisions
|
|
|
|
| # | Decision | Why |
|
|
|---|---|---|
|
|
| 1 | **Four surfaces in v1**: alarms, timers, stopwatch, world clock. | The complete stock-clock feature set. Anything less reads as a toy next to the app it wants to replace. |
|
|
| 2 | **Home-screen widgets (next alarm, running timer, clock) are in v1, in M10.** No QS tile, no screensaver, no bedtime. | Widgets are the cheapest way to make the live pill's promise ("a running timer is never invisible") reach past the app itself, and M10 already touches every piece of state a widget reads. The other three stay out: v1 is already large, and none of them shares M10's machinery the way widgets do. |
|
|
| 3 | **Full `AlarmClock` intent contract** + next-alarm publishing via `setAlarmClock`. | §6. This is the thesis. |
|
|
| 4 | **Room + DataStore + JSON export.** | Repeat rules, per-alarm overrides and lap history want a real schema. Prefs stay DataStore. Backup is an open documented format. |
|
|
| 5 | **Maximum alarm reliability**, including a self-check diagnostics screen. | §4. An alarm that doesn't ring is a dead app. This is the whole value proposition, not a nice-to-have. |
|
|
| 6 | **Seed `#6B7A5C`** (muted sage). | §8. Completes the family rotation. |
|
|
| 7 | **Four tabs + a live pill.** | §9. Stock-familiar navigation; the pill fixes the stock clock's worst flaw. |
|
|
| 8 | **Codeberg-canonical from the first commit.** | §10. No Gitea-canonical phase to migrate out of later. |
|
|
| 9 | **floret-kit consumed from day one**, and Clockula pays the `core-prefs` + `core-di` extraction up front. | §3. The kit's own roadmap names a third app as the trigger. We are it. |
|
|
|
|
---
|
|
|
|
## 2. What transfers from the siblings
|
|
|
|
Agendula's layering is the template. Lift verbatim or near-verbatim:
|
|
|
|
| Area | From | Change for Clockula |
|
|
|---|---|---|
|
|
| Gradle setup, version catalog, `settings.gradle.kts`, repro rules | Agendula | Drop `:provider`; add Room + KSP |
|
|
| CI / release / translations / renovate pipeline | Agendula spine + Calendula's `play` job | §10 |
|
|
| F-Droid metadata, fastlane tree, issue/PR templates | Agendula | Reword |
|
|
| `core-crash` wiring, `core-locale` + `locales_config.xml` | Agendula | Reword |
|
|
| Theme structure (`ui/theme/Color.kt` + `Theme.kt` over `FloretExpressiveTheme`) | Agendula | Reseed to `#6B7A5C` |
|
|
| Settings composition from `GroupedSurface` / `GroupedRow` / `OptionPicker` | Agendula | Clock-specific domain |
|
|
| Export-via-SAF module shape (`data/export`, `DocumentFile`) | Agendula | JSON instead of iCalendar |
|
|
| Test stack: JUnit 5 + Truth + Turbine | Agendula | — |
|
|
|
|
**Deliberately not transferred:** Agendula's reminder *scheduler*. The kit
|
|
roadmap is explicit that schedulers stay app-local, and Clockula's needs
|
|
(exact alarms, full-screen intents, a ringing foreground service, DST-correct
|
|
recurrence) are a different animal from a due-date nudge.
|
|
|
|
---
|
|
|
|
## 3. floret-kit
|
|
|
|
Consumed as a git submodule + Gradle composite build, as the siblings do:
|
|
`includeBuild("floret-kit")`, `implementation("de.jeanlucmakiola.floret:<module>")`,
|
|
with a gitignored `clockula/floret-kit/local.properties` locally and
|
|
`ANDROID_HOME` in CI.
|
|
|
|
**Used from day one:** `identity` (theme factory + motion + predictive back),
|
|
`components` (`GroupedSurface`/`GroupedRow`, `CollapsingScaffold`, `OptionPicker`,
|
|
`OptionCard`, `DialogControls`, `AboutCard`, `LanguagePickerRow`, `pastelize`),
|
|
`core-locale`, `core-crash`, `core-time`.
|
|
|
|
**Extracted by us (M1):** `core-prefs` (`ThemeMode`, `dynamicColor`, the typed
|
|
DataStore wrapper, `toEnum()`) and `core-di` (`@IoDispatcher` + provider). These
|
|
are pure relocation with a known shape, and the kit roadmap defers them only
|
|
"until a third app makes the duplication hurt". Writing them a third time is that
|
|
moment. Clockula consumes the extracted modules; **migrating Calendula and
|
|
Agendula onto them is a separate follow-up**, tracked in the kit's roadmap, not
|
|
in this loop.
|
|
|
|
**Deliberately deferred:** `core-notification`. Clockula's notification needs
|
|
(full-screen intents, ringing services, media-session-grade audio focus) are far
|
|
heavier than either sibling's. Designing a shared API from one speculative
|
|
consumer would produce a leaky abstraction. Revisit after v1, when the real
|
|
surface is known.
|
|
|
|
---
|
|
|
|
## 4. The alarm engine — the hard part
|
|
|
|
Everything else in this app is a readout. This is the engineering.
|
|
|
|
### Scheduling
|
|
|
|
- An alarm stores a **local time-of-day + a repeat-day set**, never an absolute
|
|
instant. The next fire time is *resolved* against the device's current zone
|
|
every time it is scheduled.
|
|
- Re-resolve on: fire, snooze, edit, enable/disable, `BOOT_COMPLETED`,
|
|
`TIME_SET`, `TIMEZONE_CHANGED`, and `MY_PACKAGE_REPLACED`. DST transitions are
|
|
handled by this re-resolution, not by arithmetic on stored millis. A 02:30
|
|
alarm on a spring-forward night has a defined, tested behaviour.
|
|
- Scheduled with `AlarmManager.setAlarmClock()`, not `setExactAndAllowWhileIdle`.
|
|
`setAlarmClock` is the only variant the system treats as a user-visible alarm:
|
|
it is doze-exempt, and it populates `getNextAlarmClock()`, which is what draws
|
|
the status-bar alarm icon and the lockscreen line. Permission is
|
|
`USE_EXACT_ALARM` (install-granted for apps whose core function is an alarm
|
|
clock) with a `SCHEDULE_EXACT_ALARM` fallback path.
|
|
- Only the **single next** alarm is registered with `AlarmManager` at a time;
|
|
firing re-resolves and registers the following one. Registering all of them
|
|
invites drift and quota trouble.
|
|
|
|
### Ringing
|
|
|
|
`AlarmManager` → `BroadcastReceiver` → foreground service (which owns audio +
|
|
vibration and survives the receiver's 10-second window) → full-screen-intent
|
|
notification whose content intent is the ring activity.
|
|
|
|
- Full-screen intent on Android 14+ needs `USE_FULL_SCREEN_INTENT`; check
|
|
`canUseFullScreenIntent()` and deep-link to the grant screen when denied. A
|
|
denied FSI must degrade to a high-priority heads-up notification that still
|
|
rings, never to silence.
|
|
- Gradual volume ramp, per-alarm ringtone (system `RingtoneManager` URIs, so the
|
|
user's existing sounds work), vibration pattern, `AudioAttributes` with
|
|
`USAGE_ALARM` so DND's alarm exemption applies.
|
|
- Snooze with a configurable interval and a snooze limit; dismiss; an optional
|
|
dismiss challenge (maths / hold) that must never be able to strand the user.
|
|
- The ring activity shows over the lockscreen (`setShowWhenLocked`,
|
|
`setTurnScreenOn`) and holds a wake lock for the ring duration only.
|
|
- A ringing alarm survives process death and reboot mid-ring.
|
|
|
|
### Diagnosability (the self-check screen)
|
|
|
|
A "**Why might my alarm not ring?**" screen that reports actual device state, not
|
|
generic advice: exact-alarm permission, notification permission, full-screen-intent
|
|
permission, battery-optimisation exemption, **whether DND currently blocks alarms**
|
|
(read via `NotificationManager.getCurrentInterruptionFilter()`; the self-check never
|
|
changes DND itself, it only reports what the user already has. The one place Clockula
|
|
touches DND is the opt-in bedtime mode added after v1, which needs the user to grant
|
|
`ACCESS_NOTIFICATION_POLICY` by hand and always lets alarms through; see
|
|
`ARCHITECTURE.md` §18), whether the OEM has Clockula under an aggressive app-killer, and
|
|
the next scheduled fire time as the system sees it (`getNextAlarmClock()`), compared
|
|
against the next fire Clockula itself computed. Where possible each row deep-links to
|
|
the exact settings page. This screen is a v1 feature, not a support afterthought.
|
|
|
|
---
|
|
|
|
## 5. Data model
|
|
|
|
Room, schema exported and version-controlled, migrations tested from v1.
|
|
|
|
- **`alarms`**: id, hour, minute, label, enabled, repeat-day mask, ringtone URI,
|
|
vibrate, snooze minutes, snooze limit, volume-ramp seconds, dismiss challenge,
|
|
`skipNextOccurrence`, timestamps.
|
|
- **`timers`**: id, label, duration, state (idle/running/paused/expired), the
|
|
elapsed-realtime anchor it resolves against, ringtone, sort order. Multiple
|
|
concurrent timers are first-class.
|
|
- **`world_clocks`**: id, IANA zone ID, optional custom label, sort order.
|
|
- **`stopwatch_laps`**: id, index, split, cumulative. The stopwatch's running
|
|
state lives in DataStore (a single record), its laps in Room so they survive
|
|
process death.
|
|
|
|
Running timers and the stopwatch anchor on **`SystemClock.elapsedRealtime()`**,
|
|
never wall-clock: a user changing the time must not warp a running timer. Alarms
|
|
are the opposite: wall-clock by definition. This distinction is the single
|
|
easiest thing to get wrong in a clock app; it is tested both ways.
|
|
|
|
World clocks are IANA zone IDs, with display names and city labels resolved
|
|
through ICU (`android.icu`) so they localise with the app language. No bundled
|
|
city database to rot.
|
|
|
|
---
|
|
|
|
## 6. System interop — the `AlarmClock` contract
|
|
|
|
Clockula handles, in full:
|
|
|
|
| Intent | Behaviour |
|
|
|---|---|
|
|
| `ACTION_SET_ALARM` | `EXTRA_HOUR`, `EXTRA_MINUTES`, `EXTRA_MESSAGE`, `EXTRA_DAYS`, `EXTRA_RINGTONE`, `EXTRA_VIBRATE`, `EXTRA_SKIP_UI`. Honours `SKIP_UI` only when the extras fully specify the alarm. |
|
|
| `ACTION_SET_TIMER` | `EXTRA_LENGTH`, `EXTRA_MESSAGE`, `EXTRA_SKIP_UI`. |
|
|
| `ACTION_SHOW_ALARMS` / `ACTION_SHOW_TIMERS` | Deep-link to the tab. |
|
|
| `ACTION_DISMISS_ALARM` / `ACTION_SNOOZE_ALARM` | Act on the firing or next alarm, with the documented search-mode extras. |
|
|
|
|
Plus `getNextAlarmClock()` participation via `setAlarmClock`, so the system
|
|
status bar and lockscreen show Clockula's next alarm like any stock clock.
|
|
|
|
**Every extra is untrusted input from another app.** Hours, minutes, day sets,
|
|
lengths and URIs are validated and clamped at the intent boundary before they
|
|
reach a repository, and a malformed intent opens the editor pre-filled rather
|
|
than writing anything. `SKIP_UI` never means "skip validation".
|
|
|
|
---
|
|
|
|
## 7. Backup format
|
|
|
|
`clockula-backup-v1.json`, written to a user-chosen folder via SAF
|
|
(`DocumentFile`), mirroring Agendula's export module shape. Top-level
|
|
`{ "format": "clockula-backup", "version": 1, "exportedAt": …, "alarms": […],
|
|
"timers": […], "worldClocks": […], "settings": {…} }`. The schema is **documented
|
|
in the repo** (`docs/BACKUP.md`), versioned, and import is tolerant of unknown
|
|
fields (ignored, at any level) and strict about known ones (a malformed known
|
|
field rejects the whole file, so nothing is imported half-way). Ringtone URIs are
|
|
exported as-is with a documented fallback when the target device can't resolve
|
|
them.
|
|
|
|
Import is **whole-state replacement**, not a merge: the user is shown exactly
|
|
that before confirming. Alarm and timer records carry only configuration. Ring
|
|
state (`alarm_states`, a timer's running anchors) is never exported and never
|
|
touched by an import; an imported timer always lands `IDLE`. Record field names
|
|
mirror the Room column vocabulary (§5), so the schema, the exported Room schema
|
|
and the backup all read the same words.
|
|
|
|
---
|
|
|
|
## 8. Identity
|
|
|
|
Seed **`#6B7A5C`**, a muted sage/olive.
|
|
|
|
The family's seeds are one colour rotated: Calendula `#5C6B7A` (slate blue) →
|
|
Agendula `#7A5C6B` (warm mauve) → Clockula `#6B7A5C` (sage). Same three bytes,
|
|
third position. The cycle closes at three, which is a constraint worth keeping
|
|
rather than a limit to work around.
|
|
|
|
Structure follows the siblings: `FloretExpressiveTheme` from the kit's `identity`
|
|
module, dynamic colour on by default with the seed-derived scheme as the
|
|
fallback, hand-tuned light/dark `ColorScheme`s in `ui/theme/Color.kt`.
|
|
|
|
Design principles inherited from the family (non-negotiable):
|
|
- **Expressive ≠ big or bold.** Refinement comes from shape, colour, space and
|
|
motion, never from enlarging text or upping weight.
|
|
- Content lives **on surfaces**, not as bare text on the background.
|
|
- `MaterialShapes` / `androidx.graphics.shapes` morphs are for the genuinely
|
|
clock-specific surfaces (the analog face, the ring screen, timer progress),
|
|
not sprinkled everywhere.
|
|
|
|
A clock app is mostly one enormous number. That readout is where the design
|
|
lives, and it is the thing to get right before anything else.
|
|
|
|
---
|
|
|
|
## 9. Navigation
|
|
|
|
Four tabs (Alarm, Timer, Stopwatch, World clock) in an M3 navigation bar, with
|
|
a **live pill** floating above it whenever a timer or the stopwatch is running:
|
|
current value plus pause and stop, reachable from any tab.
|
|
|
|
This is the one deliberate departure from the stock model, and it exists because
|
|
the stock clock's worst flaw is that a running timer is invisible unless you are
|
|
standing on its tab.
|
|
|
|
Adaptive: navigation rail on wide layouts, per M3 adaptive guidance.
|
|
|
|
### Home-screen widgets (M10)
|
|
|
|
Three Jetpack Glance app widgets, each a read-only mirror of state that already
|
|
exists (no widget owns data): **next alarm** (day, time, label), **timer**
|
|
(the live pill's subject timer, same selection logic, with pause/resume/reset),
|
|
and **clock** (local time, growing to world clocks at larger sizes). Tapping a
|
|
widget opens the matching tab through the same `MainActivity` intents the
|
|
`AlarmClock` contract (§6) already uses. A widget is just another caller of
|
|
that door, not a second entry point. Refresh is event-driven (`updatePeriodMillis
|
|
= 0`): widgets update when the state they mirror changes, not on a timer.
|
|
|
|
---
|
|
|
|
## 10. Repository & pipeline
|
|
|
|
**Codeberg is canonical from the first commit**: git, issues, PRs and releases.
|
|
The self-hosted Gitea instance holds the secrets and runs the release job, and
|
|
push-mirrors *from* Codeberg. There is no Gitea-canonical phase to migrate out
|
|
of, which is the mistake Agendula had to unwind.
|
|
|
|
The pipeline is copied wholesale from the siblings:
|
|
|
|
- `.forgejo/workflows/ci.yaml`: contributor-facing, no secrets. Copy Agendula's,
|
|
adjusting `SKIP_RE`.
|
|
- `.forgejo/workflows/translations.yaml`: identical in both siblings; copy as-is.
|
|
- `.gitea/workflows/release.yaml`: **Agendula's spine** (Codeberg-canonical: pushes
|
|
the tag to Codeberg itself, pre-1.0 prerelease flag, and the Codeberg publish
|
|
step is *not* `continue-on-error`, after five Calendula-era releases reported
|
|
green while never publishing) **plus Calendula's `play` job** grafted on
|
|
unchanged. It runs last, isolated and `continue-on-error`, and skips cleanly until
|
|
`PLAY_SERVICE_ACCOUNT_JSON` exists. It therefore stays dormant through the
|
|
pre-1.0 run, which is the correct behaviour anyway.
|
|
- `.gitea/workflows/renovate.yml`: Agendula's, including the
|
|
`github.repository_owner == 'makiolaj'` guard.
|
|
- Issue templates, PR template, F-Droid metadata, fastlane tree.
|
|
|
|
Reproducibility rules carry over verbatim and are load-bearing: **no foojay
|
|
toolchain resolver anywhere** (not even in a comment), `vcsInfo { include = false }`
|
|
on release, `submodules: recursive` in checkout, and the repro guard scanning the
|
|
floret-kit submodule's Gradle scripts.
|
|
|
|
---
|
|
|
|
## 11. ClockMaster — how we use it, and the licence line
|
|
|
|
[PranshulGG/ClockMaster](https://github.com/PranshulGG/ClockMaster) is an
|
|
archived, unmaintained **Apache-2.0** Material Expressive clock app. It is a
|
|
useful reference for the parts of an alarm app that are tedious to rediscover.
|
|
|
|
**Apache-2.0 code cannot be relicensed to MIT.** So:
|
|
|
|
- **Default posture: read it, don't copy it.** Understanding how it schedules and
|
|
how its services are wired is free; retyping our own implementation from that
|
|
understanding keeps Clockula cleanly MIT.
|
|
- **Where we genuinely lift code** (realistically only intricate scheduling or
|
|
service-lifecycle edge cases), that file keeps its Apache-2.0 header and
|
|
attribution, is listed in `docs/PROVENANCE.md`, and the Apache text ships in
|
|
`licenses/`. This is exactly the pattern Agendula already uses for the vendored
|
|
dmfs provider, so it is a known, working arrangement rather than a new risk.
|
|
- Every such decision is made **consciously and recorded**, never by drifting
|
|
into a copy-paste.
|
|
|
|
**Worth studying:** `helpers/alarmHelper.kt` (scheduling), `receiver/alarmReceiver.kt`,
|
|
`services/alarmService.kt` + `TimerAlarmService.kt` (ring/foreground lifecycle),
|
|
`helpers/alarmSoundPicker.kt` (`RingtoneManager` plumbing), the timezone
|
|
DAO/repository, and the Room entity shapes as a sanity check on §5.
|
|
|
|
**Deliberately not taken:**
|
|
- Its **always-on foreground service** (`AlarmAlwaysForegroundService`). Keeping a
|
|
permanent service alive to make alarms reliable is a battery-cost workaround;
|
|
`setAlarmClock` is the supported mechanism and does not need it.
|
|
- Its **Pomodoro** feature, which is out of scope.
|
|
- Its **settings/tile component vocabulary** (`ui/components/tiles/*`,
|
|
`SettingTile`). floret-kit's `GroupedRow` / `OptionCard` / `OptionPicker` are
|
|
our equivalents and keep the family coherent.
|
|
- **MaterialKolor + a colour-picker dependency**. The kit's `identity` module
|
|
owns theming.
|
|
- Its **no-Hilt, manual-ViewModel structure** and `minSdk 24`.
|
|
|
|
For interaction detail (snooze behaviour, the ring screen, timer presentation,
|
|
what the stopwatch does with laps), **Google Clock is the reference**, not
|
|
ClockMaster.
|
|
|
|
---
|
|
|
|
## 12. Testing posture
|
|
|
|
JUnit 5 + Truth + Turbine, matching Agendula. Non-negotiable unit coverage:
|
|
|
|
- Next-fire resolution across DST spring-forward and fall-back, every repeat-day
|
|
configuration, `skipNextOccurrence`, and snooze interaction with the next
|
|
occurrence.
|
|
- Elapsed-realtime vs wall-clock behaviour for timers and the stopwatch under a
|
|
simulated user clock change.
|
|
- Intent-extra validation at the `AlarmClock` boundary, including hostile input.
|
|
- Backup round-trip: export → import → identical state, plus forward-compatible
|
|
handling of unknown fields.
|
|
- Room migrations, from v1 onward.
|