Records what changed and why: silencing is not handling (the reminder rows that stay scheduled and the recovery that re-posts them), the notice being an upgrade-only story, the reconcile trigger covering a grant made outside the app, the event's own calendar surviving in the form picker, and the snapshot cache being keyed on the pending set. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
270 lines
15 KiB
Markdown
270 lines
15 KiB
Markdown
# Design: One calendar-visibility model (reminders silently never fire)
|
|
|
|
Status: **implemented** on `fix/calendar-visibility-model` (on-device review owed)
|
|
Date: 2026-07-25, revised 2026-07-25 after code review
|
|
|
|
Built as specified, then revised in two places the first review found (see §4,
|
|
§6): the reconciliation no longer switches calendars *on*, and a calendar
|
|
switched off without `WRITE_CALENDAR` is kept app-side instead of being lost.
|
|
A second review pass found four more, all folded in below: the notice was armed
|
|
on fresh installs too (§4), a silenced reminder could be marked handled and lost
|
|
(§3), an event living in a switched-off calendar lost its calendar in the edit
|
|
form (§2), and the reconcile only ran for one of the two ways a permission can
|
|
be granted (§4). Other notes: `CalendarSource` gained a `syncsEvents` flag (read
|
|
for #76's "not synced" label); and the Settings hint string was reworded rather
|
|
than dropped — it described the retired app-only behaviour, so leaving it would
|
|
have been the one piece of false text on screen.
|
|
Tracking: Codeberg #75. Split out of this work: #76 (read-only / not-synced
|
|
communication), #77 (accounts merged by name).
|
|
|
|
## The bug
|
|
|
|
Reminders never arrive for a calendar that is hidden at system level, and nothing
|
|
in the app hints at it. The event shows up, its reminders are listed on the detail
|
|
screen, and no notification ever fires.
|
|
|
|
The cause is a design defect, not a device quirk: the app has **two** visibility
|
|
models. Calendula's own `disabledCalendarIds` decides what it shows, while the
|
|
system's `Calendars.VISIBLE` decides whether the provider schedules reminder alarms
|
|
at all. The app reads `VISIBLE` into `CalendarSource.isVisibleInSystem` and then
|
|
never uses it — `calendars()` queries with a `null` selection. So the two models can
|
|
disagree indefinitely, and when they do, reminder delivery dies quietly.
|
|
|
|
Not a regression (nothing in `data/reminders/` changed in 2.14/2.15) and not
|
|
Android-10-specific — the gate is identical on every supported version.
|
|
|
|
## Verified provider behaviour
|
|
|
|
Checked against AOSP source and empirically on device; details in the
|
|
`calendars-visible-flag-facts` note.
|
|
|
|
- AOSP `CalendarAlarmManager.scheduleNextAlarmLocked` selects reminders with
|
|
`WHERE Calendars.VISIBLE=1 AND … AND Reminders.METHOD=1`, and its cleanup
|
|
selector *deletes* `CalendarAlerts` rows whose event has `visible=0`. Identical in
|
|
the android10, android13 and android15 branches.
|
|
- `VISIBLE` is one of only three `Calendars` columns Android documents as "writable
|
|
by both an app and a sync adapter" (with `CALENDAR_DISPLAY_NAME` and
|
|
`SYNC_EVENTS`). Writing it is sanctioned, not a workaround.
|
|
- **`VISIBLE` is device-local.** Google's sync adapter neither pushes a local change
|
|
upstream nor overwrites one on a later sync — verified by flipping a Google
|
|
account's primary calendar, forcing the upload sync (the adapter cleared
|
|
`DIRTY`/`MUTATORS`) and observing no change on the web, and by forcing a full sync
|
|
carrying `sync_extra_get_settings=true` without the local value being reverted. The
|
|
adapter only seeds the initial value at calendar creation. Caveat: a negative
|
|
observation, so "no propagation seen where it would have been visible" rather than
|
|
proof of impossibility.
|
|
- `VISIBLE` and `SYNC_EVENTS` are independent. `visible=1, sync_events=0` is
|
|
reachable and persistent: a calendar that looks enabled everywhere and is
|
|
permanently empty, because its events are not on the device at all.
|
|
|
|
## Decision
|
|
|
|
Collapse to one model, the way Etar does it — the Settings → Calendars toggle *is*
|
|
`Calendars.VISIBLE`.
|
|
|
|
The two existing levels map onto it directly:
|
|
|
|
| Level | Before | After |
|
|
| --- | --- | --- |
|
|
| Filter sheet | `hiddenCalendarIds` (app-local) | unchanged |
|
|
| Settings → Calendars | `disabledCalendarIds` (app-local) | `Calendars.VISIBLE` |
|
|
|
|
The filter sheet stays a purely in-app declutter toggle and deliberately does **not**
|
|
suppress reminders — hiding a calendar from view is not the same as asking not to be
|
|
reminded. Settings-off does stop reminders, because the provider then schedules
|
|
nothing.
|
|
|
|
Rejected alternatives:
|
|
|
|
- **Own the reminder delivery (the Fossify model)** — schedule our own alarms so
|
|
`VISIBLE` stops mattering. Bigger, and it leaves two visibility models in place.
|
|
Kept as a possible later step; see Non-goals.
|
|
- **Warn about the mismatch** — a banner papering over two sources of truth rather
|
|
than removing one.
|
|
- **Have the toggle write `SYNC_EVENTS` too** — turning it off makes the provider
|
|
drop the calendar's local events, so an innocuous switch becomes destructive. Never
|
|
written.
|
|
|
|
No hint text and no confirmation dialog: the toggle now means "show this calendar",
|
|
which is what a user assumes, and the change does not leave the device.
|
|
|
|
## Work
|
|
|
|
### 1. Write path
|
|
|
|
- `CalendarDataSource.setCalendarVisible(id: Long, visible: Boolean)` — update
|
|
`ContentUris.withAppendedId(Calendars.CONTENT_URI, id)`, *not* the sync-adapter URI
|
|
(must work on synced calendars) and *not* a `WHERE _id IN (…)` batch. See the
|
|
gotcha below. Mirror in `FakeCalendarDataSource`; repository passthrough on `io`.
|
|
- No manual UI patching: the `ContentObserver` re-queries, and the provider re-runs
|
|
`checkNextAlarm()` itself when `VISIBLE` changes. The observer-driven invariant
|
|
holds for free.
|
|
|
|
> **Load-bearing gotcha.** `CalendarProvider2.updateInTransaction` returns early with
|
|
> a raw `mDb.update()` unless the selection is `_id=?` or starts with `_id=`
|
|
> (`:4281`). That early return skips the dirty marking, the `MUTATORS` stamp **and the
|
|
> `checkNextAlarm()` reschedule at `:4314-4322`** — i.e. it would silently skip the
|
|
> very reminder rescheduling this fix exists to trigger. Always address one calendar
|
|
> by appended id. A batch of per-row `ContentProviderOperation`s is fine (those
|
|
> generate `_id=n`, which the provider handles explicitly); a single IN-clause op is
|
|
> not.
|
|
|
|
### 2. Read path
|
|
|
|
- Settings → Calendars row state derives from `isVisibleInSystem`.
|
|
- Swap the display predicate from `id in disabledIds` to `!isVisibleInSystem` in
|
|
`CalendarRepositoryImpl.instances`/`searchEvents`, `EventEditViewModel` (form
|
|
picker), `ImportViewModel` (import picker), `SettingsViewModel` (the
|
|
per-calendar reminder overrides — a default reminder on a switched-off calendar
|
|
could never fire).
|
|
- `hiddenCalendarIds` keeps working exactly as it does today, unioned as before.
|
|
- Those lists are the *targets* a user may pick. An event that already lives in an
|
|
excluded calendar keeps it: `EventEditViewModel` adds the event's own calendar
|
|
back to the picker when it isn't among them (review — otherwise editing an event
|
|
in a calendar switched off on this device renders the row as the "no calendar"
|
|
error, with an enabled picker that turns any pick into a calendar *move*).
|
|
- The repository caches one `Calendars` read per provider tick, and that cache is
|
|
keyed on the pending set as well as the tick (review). An id leaves the pending
|
|
set the instant its `VISIBLE` write lands, but the `ContentObserver` that
|
|
invalidates the snapshot is dispatched through the main looper — reading the
|
|
new set against the old snapshot re-admits exactly the events being hidden
|
|
until it arrives.
|
|
|
|
### 3. Deletions
|
|
|
|
With `VISIBLE=0` the provider creates no alert rows, so there is nothing to suppress,
|
|
stash or recover. Remove:
|
|
|
|
- `postableAlerts` / `isForDisabledCalendar` (`EventReminderReceiver.kt:29-41`)
|
|
- `SuppressedReminderStore` and `CalendarsViewModel.recoverReminders`
|
|
|
|
The gate in `ReminderNotifier.post` stays (review): two paths reach it without a
|
|
provider alert row behind them — a snooze re-shown from our own exact alarm,
|
|
scheduled before the calendar was switched off, and a read-only install whose
|
|
switch lives in the pending set. One choke point covers both receivers.
|
|
|
|
**Silencing is not handling** (second review). "`VISIBLE = 0` ⇒ no alert rows"
|
|
holds only where the flag was actually written; where the switch lives in the
|
|
pending set the provider keeps creating and broadcasting rows. Marking those
|
|
`STATE_FIRED` with the rest loses them for good — `dueAlerts` only ever returns
|
|
`STATE_SCHEDULED`. So the receiver marks what it *posted*, plus what it silenced
|
|
for an event already over (`handledAlertIds`); a silenced alert for an event
|
|
still ahead stays scheduled, which makes the provider's own table the stash the
|
|
deleted `SuppressedReminderStore` used to be. `ReminderRecovery` re-posts them
|
|
when the calendar is switched back on, so recovery doesn't wait for the next
|
|
unrelated broadcast.
|
|
|
|
Net code reduction. Update the now-false KDoc on `CalendarPrefs` claiming the toggle
|
|
"never touches the system's VISIBLE/SYNC_EVENTS flags, so other calendar apps are
|
|
unaffected".
|
|
|
|
### 4. Reconciliation (hide-only, standing)
|
|
|
|
`CalendarVisibilityReconciler` runs on every launch, and again whenever
|
|
`RootScreen` comes up holding the calendar permission. That second trigger sat on
|
|
`PermissionViewModel.onGranted` at first, which only fires for the in-app request
|
|
— a permission granted on Android's own app-settings screen comes back through
|
|
`RootScreen`'s `ON_RESUME` and would have left the drain unrun for the session
|
|
(review). Settled runs cost two DataStore reads and return before the query. It
|
|
drains
|
|
`CalendarPrefs.pendingDisabledCalendarIds` — the retired `disabledCalendarIds`
|
|
key, re-read under a new name — into the provider:
|
|
|
|
- Pending **and** `visible=1` → write `visible=0`, then drop the id.
|
|
- Pending but already `visible=0`, or gone from the device → drop the id.
|
|
- Everything else untouched. Nothing is ever switched **on**.
|
|
|
|
Each id leaves the set as its own write lands, so a run that dies part-way
|
|
resumes where it stopped and can never re-apply a hide the user has since undone
|
|
by hand. The set empties itself; no separate "migration done" flag exists.
|
|
|
|
**Hide-only was the review's correction.** The first cut also switched calendars
|
|
on (enabled in-app + `visible=0` + `sync_events=1` → `visible=1`) to keep the
|
|
upgrade invisible. Two problems, both real:
|
|
|
|
- `VISIBLE` is device-wide. "Not disabled in Calendula" is the default for every
|
|
calendar, including ones the user deliberately hid in Google Calendar, Etar or
|
|
DAVx5 — those would pop back there *and* start firing reminders nobody asked
|
|
for, from a switch the user never touched.
|
|
- The `sync_events=1` guard doesn't hold. It is sound for sync-adapter accounts,
|
|
but an `ACCOUNT_TYPE_LOCAL` calendar another app created can sit at
|
|
`sync_events=0` while holding real device-local events, so the guard would have
|
|
hidden events it claimed could not exist.
|
|
|
|
The cost is that events from a system-hidden calendar stop appearing in
|
|
Calendula. That is confined to this app, visible and reversible in Settings →
|
|
Calendars, and announced: on the first run that finds such a calendar, the
|
|
reconciler arms a one-time dialog (`CalendarVisibilityNoticeDialog`) explaining
|
|
that visibility now follows the device and where to change it. The answer —
|
|
including "nothing to say" — is stored, so it can never resurface later.
|
|
|
|
**Upgrades only** (second review). The notice explains a change to behaviour the
|
|
user has seen before, and a device holding something hidden at system level is
|
|
the *norm* on a fresh install — a second account's calendars, "Holidays in …", a
|
|
subscribed calendar. Arming on that state alone put a changelog dialog in front
|
|
of first-run users. `firstInstallTime != lastUpdateTime` is the gate; a fresh
|
|
install retires the notice unshown, ahead of the permission check, so an app
|
|
update installed before the first grant can't make it look like an upgrade
|
|
afterwards.
|
|
|
|
### 4b. No `WRITE_CALENDAR`
|
|
|
|
Only `READ_CALENDAR` gates the app (`RootScreen`), and `PermissionScreen` says as
|
|
much: declining write keeps Calendula usable read-only. Those users can't have
|
|
the flag written for them, so the switch keeps working app-side —
|
|
`CalendarRepositoryImpl.setCalendarsVisible` writes the pending set instead — and
|
|
that set filters `instances`/`searchEvents` and gates `ReminderNotifier.post`
|
|
exactly as `VISIBLE` would. If `WRITE_CALENDAR` ever arrives, the reconciler
|
|
flushes it and the app-side copy disappears. This is the one place a second
|
|
visibility model still exists, and it exists only where the first one is
|
|
unwritable — and the one place the provider keeps creating alert rows for a
|
|
calendar the user switched off, which is why §3's silencing must stay reversible.
|
|
|
|
### 5. `sync_events = 0` rows
|
|
|
|
No toggle at all — flipping it cannot produce an event. The row still shows, labelled
|
|
"not synced", sorted to the bottom of its account group regardless of alphabetical
|
|
order, and dimmed using the screen's existing `dimmed`/`dimIf` convention. Consult
|
|
the `material-3` skill before designing the label. **Tracked in #76**, not here.
|
|
|
|
## Non-goals
|
|
|
|
The missed-broadcast class of failure: a reminder lost because the provider's
|
|
`EVENT_REMINDER` broadcast never arrives (OEM app-sleeping, an OEM-modified provider)
|
|
or because a competing calendar app marked the alert `FIRED` first. There is no
|
|
rescan, and `dueAlerts` only accepts `STATE_SCHEDULED`, so one missed broadcast loses
|
|
that reminder permanently.
|
|
|
|
If we take that on later, note that Etar's `AlarmScheduler` self-disables once a real
|
|
`EVENT_REMINDER` is ever seen (`PROVIDER_REMINDER_PREF_KEY`) — **that latch must not
|
|
be copied.** It assumes the only failure is "broadcast never arrives"; ours includes
|
|
"broadcast arrives fine for other calendars, but no alert row was ever created". Any
|
|
scheduler of ours must run unconditionally and dedupe by notification identity.
|
|
|
|
## Testing
|
|
|
|
All JVM-testable:
|
|
|
|
- the reconciliation as a pure function over `(calendars, pendingIds)`: hide,
|
|
settled (already off / gone), never a switch-on, and the notice predicate
|
|
- the display-predicate swap, and the pending set filtering alongside it
|
|
- the read-only path: no provider write, the choice parked in the pending set,
|
|
events filtered from it
|
|
- what a due alert may be marked as handled (`handledAlertIds`), including the
|
|
silenced-but-still-ahead row that must stay scheduled
|
|
- the flush race: writing `VISIBLE` and releasing the pending id without a
|
|
provider tick in between must not re-admit the calendar's events
|
|
- `FakeCalendarDataSource` assertions that `setCalendarVisible` addresses a single
|
|
calendar by id
|
|
- one `Calendars` query per provider tick, however many flows are collecting
|
|
|
|
On-device review before release (UI-touching). Local repro for the #75 shape: force a
|
|
test calendar to `visible=0` via the provider, confirm its reminders stop and that
|
|
the Settings toggle brings them back.
|
|
|
|
## Release
|
|
|
|
Behaviour change plus a migration and a device-wide-flag change, so this reads as a
|
|
minor (2.17.0) rather than a patch. New strings land in `values/strings.xml` only;
|
|
translations come back through Weblate.
|