Files
calendula/docs/ARCHITECTURE.md
Jean-Luc Makiola a1f091a88b feat(reminders): schedule and fire reminders in-house (#75)
Delivery no longer waits to be told. The scan reads `Instances` and `Reminders`,
plans every reminder, posts what has come due and arms one exact alarm for the
next — replacing both halves of what the provider used to do for us.

Reacting to `EVENT_REMINDER` could not be made reliable, only more hopeful. An
app that reacts cannot distinguish "nothing was due" from "the broadcast never
came", and AOSP's own unbundled calendar carries three workarounds for OEM
providers that retarget it or write the alert row late. Etar's fallback was not
the answer either: it replaces the alarm but still reads `CalendarAlerts` for
what to show, and it disables itself the moment one real broadcast arrives —
useless against a broadcast that arrives with no row behind it.

Keeping the old receiver alongside was rejected for the same reason it looks
attractive: on a healthy device both paths fire, and there is no honest way to
suppress one without the latch we just ruled out. So the provider path goes —
`EventReminderReceiver`, `ReminderAlertStore`, `ReminderRecovery` and the
`CalendarAlerts` writes with it. Reminder delivery no longer needs WRITE_CALENDAR.

One alarm exists at a time, re-planned on every firing, so an edit needs no alarm
bookkeeping to stay in sync. Every trigger runs the same idempotent scan: the
alarm, boot and package-replace (both wipe pending alarms), clock and timezone
changes, a provider change while the app is up, launch, and a daily worker for a
device that drops the alarm with nothing to announce it. RECEIVE_BOOT_COMPLETED
is new and load-bearing — without it reminders stop dead after a restart.

`ReminderAlert.alertId` becomes `key`, derived from the reminder rather than a
row id that no longer exists, and the notification tag and PendingIntent request
codes ride on it, so a re-posted reminder still replaces itself.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 15:24:45 +02:00

219 lines
11 KiB
Markdown

# Architecture
Calendula is a single-activity Jetpack Compose app layered strictly on top
of Android's calendar provider. This document is the orientation tour: the
principles, the layers, and the three pipelines that are not obvious from
the package list (recurring writes, save conflicts, reminder delivery).
## Principles
1. **`CalendarContract` is the single source of truth.** No app database,
no caching layer, no sync code. Reads query the provider; writes go
straight back to it. Sync is DAVx5's / Google's / the system's job.
2. **Observer-driven UI.** A `ContentObserver` on the provider triggers
re-queries; every screen recomposes from fresh provider state. After a
write, nothing is patched by hand — the provider notifies, the views
refresh. This also covers external changes (sync) for free.
3. **JVM-first testing.** Everything between the UI and the
`ContentResolver` is shaped so it runs as a plain JUnit 5 test: pure
domain logic, cursor-free mappers, a `FakeCalendarDataSource` for
repository tests. Instrumented tests are a last resort.
4. **No network.** The app declares no `INTERNET` permission. Anything that
would need one is an explicit, documented product decision first
(see the roadmap's idea backlog).
## Layers
```mermaid
flowchart TD
subgraph UI ["ui/ — Compose screens + ViewModels"]
Screens["Month / Week / Day\nDetail / Edit / Settings\nPermission + Reminder onboarding"]
end
subgraph Data ["data/"]
Repo["CalendarRepository\n(interface + impl, Flow-based, io-dispatched)"]
DS["CalendarDataSource\n(interface + AndroidCalendarDataSource)"]
Prefs["SettingsPrefs / CalendarPrefs\n(DataStore)"]
Rem["reminders/\nReminderScanner + ReminderNotifier"]
end
Provider[("CalendarContract\n(system calendar provider)")]
Screens --> Repo
Screens --> Prefs
Repo --> DS
DS --> Provider
Provider -. "ContentObserver tick" .-> Repo
Provider -. "EVENT_REMINDER broadcast" .-> Rem
Rem --> Provider
```
- **`domain/`** — pure Kotlin, no Android imports: models
(`EventInstance`, `EventDetail`, `CalendarSource`, …), the `EventForm`
with validation, `SimpleRecurrence` (RRULE parse/render for the picker),
and `EditSnapshot` (conflict detection). All JVM-tested.
- **`data/calendar/`** — the provider seam. `AndroidCalendarDataSource`
owns every `ContentResolver` call; cursor parsing lives in mappers
(`InstanceMapper`, `EventDetailMapper`, `CalendarMapper`) that read
through a `ColumnReader` abstraction so tests feed them plain maps.
`EventWriteMapper` builds dirty-checked update value sets. `TimeBridge`
converts provider epoch millis ↔ `kotlin.time.Instant`.
- **`data/reminders/`** — the notification pipeline (see below). Kept out
of `data/calendar/` because the receiver needs neither the repository
nor its flows.
- **`data/prefs/`** — DataStore-backed settings (theme, week start, form
field defaults, reminders toggle) and small state (last-used calendar).
- **`ui/`** — one package per screen, each with Screen + ViewModel +
UiState. Shared pieces in `ui/common/` (OptionCard — the app's only
sanctioned selection-dialog style —, recurrence humanizer, FAB column,
drawer, transitions).
## Navigation
There is no navigation library. `MainActivity` hosts `RootScreen`, which
gates on the calendar permission and the one-time reminder onboarding, then
shows `CalendarHost`. `CalendarHost` holds the active view (month/week/day)
plus overlay state for detail, edit, and settings — full-screen overlays
driven by `AnimatedVisibility` with a *held-key* pattern: the last shown
key stays alive through the slide-out so content never flashes empty.
A tapped reminder notification routes through `MainActivity` (`singleTop` +
`onNewIntent`) as an external detail key that `CalendarHost` consumes
exactly like an event tap.
## Recurring writes
The provider's invariants drive the design (learned the hard way, verified
on-device — see plan 03):
- Recurring rows carry `RRULE` + `DURATION` (no `DTEND`); one-off rows
carry `DTEND`.
- *Only this event* → insert a **modified-occurrence exception** via
`CONTENT_EXCEPTION_URI` (the provider clones the series row, so empty
optionals are written as explicit NULLs).
- *This and following* → **series split**: insert the new event first (if
that fails the original is untouched), then truncate the original's
RRULE with `UNTIL`.
- Truncation updates must send the **complete time-column set**
(`DTSTART`/`DURATION`/`RRULE`/`ALL_DAY`/`EVENT_TIMEZONE`) — the provider
regenerates cached instances only from the values carried by the update
itself; an RRULE-only update leaves stale instances behind.
- `UNTIL` is written as the local end of the previous day expressed in
UTC, so zones ahead of UTC can't leak an extra occurrence.
- All-day events are normalised to UTC midnights with an exclusive end.
### Event time zones
`EventForm.timezone` is the zone its wall-clock times mean, and **null means
"the device zone at save time"** — not "no zone". The data layer resolves it in
`toWriteTimes` and always stamps a concrete `EVENT_TIMEZONE`, so an ordinary
event behaves exactly as it did before the field existed.
- A non-null value **pins** the event: it keeps tracking that zone's offset
across DST no matter where the device is. `toEditForm` only pins when the
stored zone differs from the device's, so the optional Time-zone field stays
hidden on ordinary events and reveals itself (via `populatedFields`) on
foreign-zone ones.
- A pinned event is prefilled **in its own zone**, so the form shows the
wall-clock the event means rather than the device's rendering of it.
- A zone change counts as a **time change** even with the wall-clock untouched
(same 09:00 elsewhere is a different instant), so `buildEventUpdateValues`
includes it in `timesChanged` and rewrites `DTSTART`.
- **All-day events never carry a zone.** They're date-anchored — the UTC
midnights above are an anchor, not a location — so the field is withheld from
the form entirely and `toWriteTimes` forces `"UTC"` regardless.
Still device-zone-relative, and knowingly so: `RRULE`'s `UNTIL` rendering and
`AllDayReminderEncoding`'s offset (see its KDoc).
## Save conflicts
No locking. `openForEdit` keeps an `EditSnapshot` — the prefilled form
*plus the raw Events-row times* (the form derives its times from the tapped
occurrence, so a remotely moved event would otherwise be invisible to it).
Right before writing, the event is re-read and snapshots compared: a
mismatch parks the save in an overwrite/discard dialog; a vanished event
informs and closes. Overwrite still writes only dirty fields, so external
changes to untouched fields survive either way. Fields the form cannot
write (attendees, status, reminder methods) are excluded so sync noise
can't fake a conflict.
## Reminder delivery
Calendula plans and fires its own reminders. It reads the offsets in
`Reminders` as data, works out when each occurrence's reminder is due, and holds
**one** exact alarm for the earliest one still ahead:
```mermaid
sequenceDiagram
participant T as Trigger (alarm / boot / time change / edit / launch / daily worker)
participant Sc as ReminderScanner
participant Src as ReminderInstanceSource
participant P as ReminderPlan (pure)
participant N as ReminderNotifier
participant A as ReminderAlarmScheduler
T->>Sc: scan()
Sc->>Src: occurrences(window) + reminderMinutes(ids)
Src-->>Sc: Instances ⋈ Reminders
Sc->>P: planReminders / scheduleReminders(watermark, now)
P-->>Sc: due + next alarm
Sc->>N: post(alert) — tag = reminder key
Sc->>A: scheduleScan(next)
```
**Why not the provider's broadcast.** It used to schedule the alarms, write the
`CalendarAlerts` rows and broadcast `EVENT_REMINDER`, and the app only reacted.
That chain holds on stock Android and demonstrably not everywhere: AOSP's own
unbundled calendar carries three separate workarounds for OEM providers that
retarget the broadcast or only write the alert row at alert time. A reacting app
cannot tell "nothing was due" from "the broadcast never came" (#75) — and the
reporter's silent events were in a calendar Calendula created itself, so
`VISIBLE` was never the cause there.
**The watermark replaces `CalendarAlerts.STATE`.** A scan posts the reminders
whose moment falls in `(lastScan, now]`, then moves the mark
(`ReminderStatePrefs`). Half-open, so a scan that runs twice cannot post twice,
while a scan that runs *late* still posts what the missed alarm owed — a reboot,
an app update or a doze window costs nothing. A first-ever scan claims the
present rather than the epoch, and a watermark left in the future by a clock
change is clamped. Every trigger runs the same idempotent `scan()`, so there is
no ordering between them to get wrong; `BOOT_COMPLETED` and `MY_PACKAGE_REPLACED`
matter because both wipe pending alarms.
**All-day reminders fire at the hour the setting names.** The stored offset is
not a plain lead time — `AllDayReminderEncoding` folds a wall-clock hour into it,
sampled against one date's UTC offset — so taking it at face value drifts by the
offset delta across a DST boundary, and rows from other apps carry no hour at
all. The offset is therefore read only for *which day* it means; the hour comes
from the global all-day reminder setting, recomposed against each occurrence's
own date. Timed reminders need none of this: `begin` is an absolute instant.
**One visibility model.** The scan only plans occurrences of calendars with
`Calendars.VISIBLE = 1`, and that flag *is* the app's on/off switch: Settings →
Calendars writes it (one calendar per update — `CalendarProvider2` skips its own
`checkNextAlarm()` reschedule for any selection that isn't `_id=`), and every
display predicate reads `CalendarSource.isVisibleInSystem`. The reconciliation
runs one way only: a calendar the user switched off in Calendula is switched off
in the provider, never the reverse — un-hiding one would reach into every other
calendar app on the device — and a one-time notice explains the calendars that
were already off. `CalendarPrefs.pendingDisabledCalendarIds` holds the switch-offs
the app has not been allowed to write yet (read-only permission grant, or a
pre-permission launch); `CalendarVisibilityReconciler` drains it entry by entry,
and until it does, the repository and `ReminderNotifier.post` honour it. That
gate also covers a snooze re-shown from our own alarm after its calendar was
switched off. The drawer's filter sheet (`CalendarPrefs.hiddenCalendarIds`) is a
separate in-app declutter that never touches reminders.
Deliberately absent: a fallback to the provider's `EVENT_REMINDER` broadcast.
Keeping both would double-post wherever the provider works, and Etar's way out —
a latch that disables its own scheduling once a real broadcast arrives — cannot
be copied, because our failure mode includes a broadcast that arrives with no
alert row behind it.
## Testing
JUnit 5 + Truth + Turbine on the JVM. The seams that make it work:
`CalendarDataSource` is faked (`FakeCalendarDataSource` records writes),
mappers parse `ColumnReader`/plain maps instead of cursors, domain logic
(recurrence, validation, snapshots, write-value building) is pure. CI
(Gitea Actions) runs `lint test assembleDebug` once per pull request; merging a
bumped `versionName` to `main` builds, signs, and publishes to the self-hosted
F-Droid repo and then mints the `vX.Y.Z` tag + release. See docs/RELEASING.md.