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>
15 KiB
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.scheduleNextAlarmLockedselects reminders withWHERE Calendars.VISIBLE=1 AND … AND Reminders.METHOD=1, and its cleanup selector deletesCalendarAlertsrows whose event hasvisible=0. Identical in the android10, android13 and android15 branches. VISIBLEis one of only threeCalendarscolumns Android documents as "writable by both an app and a sync adapter" (withCALENDAR_DISPLAY_NAMEandSYNC_EVENTS). Writing it is sanctioned, not a workaround.VISIBLEis 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 clearedDIRTY/MUTATORS) and observing no change on the web, and by forcing a full sync carryingsync_extra_get_settings=truewithout 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.VISIBLEandSYNC_EVENTSare independent.visible=1, sync_events=0is 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
VISIBLEstops 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_EVENTStoo — 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)— updateContentUris.withAppendedId(Calendars.CONTENT_URI, id), not the sync-adapter URI (must work on synced calendars) and not aWHERE _id IN (…)batch. See the gotcha below. Mirror inFakeCalendarDataSource; repository passthrough onio.- No manual UI patching: the
ContentObserverre-queries, and the provider re-runscheckNextAlarm()itself whenVISIBLEchanges. The observer-driven invariant holds for free.
Load-bearing gotcha.
CalendarProvider2.updateInTransactionreturns early with a rawmDb.update()unless the selection is_id=?or starts with_id=(:4281). That early return skips the dirty marking, theMUTATORSstamp and thecheckNextAlarm()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-rowContentProviderOperations 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 disabledIdsto!isVisibleInSysteminCalendarRepositoryImpl.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). hiddenCalendarIdskeeps 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:
EventEditViewModeladds 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
Calendarsread 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 itsVISIBLEwrite lands, but theContentObserverthat 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)SuppressedReminderStoreandCalendarsViewModel.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→ writevisible=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:
VISIBLEis 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=1guard doesn't hold. It is sound for sync-adapter accounts, but anACCOUNT_TYPE_LOCALcalendar another app created can sit atsync_events=0while 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
VISIBLEand releasing the pending id without a provider tick in between must not re-admit the calendar's events FakeCalendarDataSourceassertions thatsetCalendarVisibleaddresses a single calendar by id- one
Calendarsquery 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.