- allDayLeadDays no longer reads every multiple of 1440 at face value: our own offsets land on one whenever the all-day hour equals the zone's UTC offset (20:00 in New York), and a "1 day before" then fired on the day of the event. The encoded instant landing on the named hour now decides, and the display path decodes through the same function so screen and notification agree. - Scan after the reminders toggle, the all-day hour and a calendar-permission grant — the first cancels the alarm, the last arms none, so a re-enable used to stay silent until the daily worker. - Cap the Instances query window at a year past the lookahead; the longest offset comes from any row in the provider, including imported nonsense. - Give the reminder action intents a per-reminder data URI: PendingIntent equality ignores extras, so a request-code collision shared one alarm.
230 lines
12 KiB
Markdown
230 lines
12 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. Turning reminders off cancels the alarm
|
|
and granting the calendar permission arms none, so those transitions scan too —
|
|
without it, switching reminders back on would sit silent until the daily worker.
|
|
The window a scan reads is the 7-day lookahead plus the longest reminder offset
|
|
in the provider, capped at a year: that offset is whatever the largest row says,
|
|
including one imported from a stray `TRIGGER:-P100W`.
|
|
|
|
**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. Which day that is comes from the local date the encoded instant falls
|
|
on — except for a plain multiple of 1440, read at face value because a foreign
|
|
row means literal days from UTC midnight. The two collide where the all-day hour
|
|
equals the zone's UTC offset (20:00 in New York), and there the instant landing
|
|
on the named hour decides it is ours; the display path decodes through the same
|
|
function, so the screen and the notification agree. 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.
|