Code review of the one-visibility-model fix (#75) found the reconciliation reaching further than it should and the read-only case falling through it. The migration switched calendars *on* to keep the upgrade invisible, but "not disabled in Calendula" is the default for every calendar, including ones the user deliberately hid in Google Calendar, Etar or DAVx5 — those would reappear there and start firing reminders from a switch the user never touched. Its sync_events guard didn't hold either: an ACCOUNT_TYPE_LOCAL calendar another app created can sit at sync_events=0 while holding real device-local events. The reconcile now only hides, and a one-time notice explains that visibility follows the device and where to change it, instead of quietly rewriting other apps' state. Only READ_CALENDAR gates the app, so a read-only install could not write the flag at all: every calendar it had switched off came back with its events and its reminders, and the switch couldn't undo it. Those switch-offs are kept app-side now (the retired disabled-set key, re-read under a new name), folded into the visibility every consumer reads, and drained into the provider entry by entry once WRITE_CALENDAR arrives — which also makes a part-applied run resumable without re-applying a switch the user has since flipped by hand. Also: restore the ReminderNotifier.post gate, the one path a snooze re-shown from our own alarm passes; move the whole reconcile inside its try/catch, so a damaged preferences file can't crash the process at launch; share one Calendars query per provider tick across the flows that need it; and give the reworded Settings hint new keys, so five locales stop rendering the retired app-only wording. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
225 lines
12 KiB
Markdown
225 lines
12 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 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. Other
|
|
notes: `CalendarSource` gained a `syncsEvents` flag (read for #76's "not synced"
|
|
label); the reconcile runs from `CalendulaApp.onCreate` *and* from
|
|
`PermissionViewModel.onGranted`, so a launch that starts without the calendar
|
|
permissions still catches up the moment they are granted; 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).
|
|
- `hiddenCalendarIds` keeps working exactly as it does today, unioned as before.
|
|
|
|
### 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.
|
|
|
|
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 when the permission
|
|
screen grants the permissions. 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.
|
|
|
|
### 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.
|
|
|
|
### 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
|
|
- `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.
|