docs: fold the second review pass into the visibility design

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>
This commit is contained in:
2026-07-25 21:34:24 +02:00
parent 009bd088e6
commit 0f70123804
2 changed files with 62 additions and 14 deletions

View File

@@ -171,7 +171,10 @@ the app has not been allowed to write yet (read-only permission grant, or a
pre-permission launch); `CalendarVisibilityReconciler` drains it entry by entry, pre-permission launch); `CalendarVisibilityReconciler` drains it entry by entry,
and until it does, the repository and `ReminderNotifier.post` honour it. That 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 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 switched off. Silencing is not handling: an alert the gate drops keeps its
`SCHEDULED` state while its event is still ahead (`handledAlertIds`), so
switching the calendar back on re-posts it (`ReminderRecovery`) instead of
losing it — the provider's own table is the stash. The drawer's filter sheet (`CalendarPrefs.hiddenCalendarIds`) is a
separate in-app declutter that never touches reminders. See separate in-app declutter that never touches reminders. See
`docs/design/calendar-visibility-model.md`. `docs/design/calendar-visibility-model.md`.
Deliberately absent until real devices prove it necessary: own alarm Deliberately absent until real devices prove it necessary: own alarm

View File

@@ -3,15 +3,17 @@
Status: **implemented** on `fix/calendar-visibility-model` (on-device review owed) Status: **implemented** on `fix/calendar-visibility-model` (on-device review owed)
Date: 2026-07-25, revised 2026-07-25 after code review 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): Built as specified, then revised in two places the first review found (see §4,
the reconciliation no longer switches calendars *on*, and a calendar switched §6): the reconciliation no longer switches calendars *on*, and a calendar
off without `WRITE_CALENDAR` is kept app-side instead of being lost. Other switched off without `WRITE_CALENDAR` is kept app-side instead of being lost.
notes: `CalendarSource` gained a `syncsEvents` flag (read for #76's "not synced" A second review pass found four more, all folded in below: the notice was armed
label); the reconcile runs from `CalendulaApp.onCreate` *and* from on fresh installs too (§4), a silenced reminder could be marked handled and lost
`PermissionViewModel.onGranted`, so a launch that starts without the calendar (§3), an event living in a switched-off calendar lost its calendar in the edit
permissions still catches up the moment they are granted; and the Settings hint form (§2), and the reconcile only ran for one of the two ways a permission can
string was reworded rather than dropped — it described the retired app-only be granted (§4). Other notes: `CalendarSource` gained a `syncsEvents` flag (read
behaviour, so leaving it would have been the one piece of false text on screen. 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 Tracking: Codeberg #75. Split out of this work: #76 (read-only / not-synced
communication), #77 (accounts merged by name). communication), #77 (accounts merged by name).
@@ -112,8 +114,21 @@ which is what a user assumes, and the change does not leave the device.
- Settings → Calendars row state derives from `isVisibleInSystem`. - Settings → Calendars row state derives from `isVisibleInSystem`.
- Swap the display predicate from `id in disabledIds` to `!isVisibleInSystem` in - Swap the display predicate from `id in disabledIds` to `!isVisibleInSystem` in
`CalendarRepositoryImpl.instances`/`searchEvents`, `EventEditViewModel` (form `CalendarRepositoryImpl.instances`/`searchEvents`, `EventEditViewModel` (form
picker), `ImportViewModel` (import picker). 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. - `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 ### 3. Deletions
@@ -128,14 +143,30 @@ 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 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. 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 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 "never touches the system's VISIBLE/SYNC_EVENTS flags, so other calendar apps are
unaffected". unaffected".
### 4. Reconciliation (hide-only, standing) ### 4. Reconciliation (hide-only, standing)
`CalendarVisibilityReconciler` runs on every launch and again when the permission `CalendarVisibilityReconciler` runs on every launch, and again whenever
screen grants the permissions. It drains `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` `CalendarPrefs.pendingDisabledCalendarIds` — the retired `disabledCalendarIds`
key, re-read under a new name — into the provider: key, re-read under a new name — into the provider:
@@ -167,6 +198,15 @@ reconciler arms a one-time dialog (`CalendarVisibilityNoticeDialog`) explaining
that visibility now follows the device and where to change it. The answer — 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. 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` ### 4b. No `WRITE_CALENDAR`
Only `READ_CALENDAR` gates the app (`RootScreen`), and `PermissionScreen` says as Only `READ_CALENDAR` gates the app (`RootScreen`), and `PermissionScreen` says as
@@ -177,7 +217,8 @@ that set filters `instances`/`searchEvents` and gates `ReminderNotifier.post`
exactly as `VISIBLE` would. If `WRITE_CALENDAR` ever arrives, the reconciler 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 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 visibility model still exists, and it exists only where the first one is
unwritable. 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 ### 5. `sync_events = 0` rows
@@ -209,6 +250,10 @@ All JVM-testable:
- the display-predicate swap, and the pending set filtering alongside it - 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, - the read-only path: no provider write, the choice parked in the pending set,
events filtered from it 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 - `FakeCalendarDataSource` assertions that `setCalendarVisible` addresses a single
calendar by id calendar by id
- one `Calendars` query per provider tick, however many flows are collecting - one `Calendars` query per provider tick, however many flows are collecting