feat(contacts): mirror contact special dates into local calendars (#15) #57

Merged
makiolaj merged 25 commits from feat/contact-special-dates into release/v2.13.0 2026-07-03 13:44:46 +00:00
Owner

Mirrors contacts' birthdays, anniversaries and other dates into local, per-type calendars ("Birthdays", "Anniversaries", "Other dates") that stay in sync as contacts change. Optional, opt-in under Settings → Contact special dates; the first feature to use READ_CONTACTS (requested only on enable), fully offline, contacts are read-only. Tracking: Codeberg #15 (builds on #14).

What's here

  • Idempotent, one-way sync engine (contacts → local calendars) keyed on a deterministic UID_2445, so re-syncs never duplicate and a user's own edits (reminders, notes, location) survive.
  • One managed calendar per type, identified by a durable CAL_SYNC2 marker; per-type colour, title template, calendar-wide reminder default, and show-source-year toggle.
  • Daily periodic reconcile + immediate run on enable/"Sync now" + a debounced foreground refresh (WorkManager). No ContentObserver.
  • Settings sub-page, event-editor field locking for managed events, and a paused/stalled banner when the permission is revoked.

Review + hardening

This branch was code-reviewed before merge; the fixes are included:

Correctness

  • Managed calendars are now sealed off from user edits: they carry CalendarSource.isManaged (from the durable marker, so it survives a backup restore), are excluded from the new-event calendar picker, and managed-event queries match only our own contact- UID prefix — so the sync can no longer delete a user event that landed in a managed calendar.
  • Editor/detail: managed-event saves are forced to the whole series (no scope-split rows the sync reverts), and Delete is hidden for managed events.
  • All-day reminder offsets are sampled at the upcoming occurrence instead of the ancient (1972) recurrence anchor, fixing a systematic wrong-hour fire.
  • Custom dates keep a per-date UID discriminator + deterministic read order, so two custom dates on one contact both mirror and can't ping-pong.
  • sync()/teardown() are serialized by a lifecycle mutex (no double-created calendars, no recreate-after-teardown); the foreground trigger has its own work name (can't cancel-and-swallow an enable sync); a revoked calendar permission parks the feature as stalled instead of retrying forever.

Cleanup

  • Shared event-insert values builder + reminder seeding; palette moved to data/calendar with named entries; one Context.hasContactsPermission(); deduped reminder-override decoding; on-convention dialog text input.

Testing

  • ./gradlew :app:testDebugUnitTest green (added cases for the next-occurrence offset sampling and the custom-date UID discriminator).
  • Built and verified on-device (Pixel 10, debug build 2.12.0).

release/v2.13.0 merged into the branch; conflicts (settings/editor imports, SettingsSection enum, the renamed colour palette) resolved.

🤖 Generated with Claude Code

Mirrors contacts' birthdays, anniversaries and other dates into local, per-type calendars ("Birthdays", "Anniversaries", "Other dates") that stay in sync as contacts change. Optional, opt-in under **Settings → Contact special dates**; the first feature to use `READ_CONTACTS` (requested only on enable), fully offline, contacts are read-only. Tracking: Codeberg #15 (builds on #14). ## What's here - Idempotent, one-way sync engine (contacts → local calendars) keyed on a deterministic `UID_2445`, so re-syncs never duplicate and a user's own edits (reminders, notes, location) survive. - One managed calendar per type, identified by a durable `CAL_SYNC2` marker; per-type colour, title template, calendar-wide reminder default, and show-source-year toggle. - Daily periodic reconcile + immediate run on enable/"Sync now" + a debounced foreground refresh (WorkManager). No `ContentObserver`. - Settings sub-page, event-editor field locking for managed events, and a paused/stalled banner when the permission is revoked. ## Review + hardening This branch was code-reviewed before merge; the fixes are included: **Correctness** - Managed calendars are now sealed off from user edits: they carry `CalendarSource.isManaged` (from the durable marker, so it survives a backup restore), are excluded from the new-event calendar picker, and managed-event queries match only our own `contact-` UID prefix — so the sync can no longer delete a user event that landed in a managed calendar. - Editor/detail: managed-event saves are forced to the whole series (no scope-split rows the sync reverts), and Delete is hidden for managed events. - All-day reminder offsets are sampled at the upcoming occurrence instead of the ancient (1972) recurrence anchor, fixing a systematic wrong-hour fire. - Custom dates keep a per-date UID discriminator + deterministic read order, so two custom dates on one contact both mirror and can't ping-pong. - `sync()`/`teardown()` are serialized by a lifecycle mutex (no double-created calendars, no recreate-after-teardown); the foreground trigger has its own work name (can't cancel-and-swallow an enable sync); a revoked calendar permission parks the feature as stalled instead of retrying forever. **Cleanup** - Shared event-insert values builder + reminder seeding; palette moved to `data/calendar` with named entries; one `Context.hasContactsPermission()`; deduped reminder-override decoding; on-convention dialog text input. ## Testing - `./gradlew :app:testDebugUnitTest` green (added cases for the next-occurrence offset sampling and the custom-date UID discriminator). - Built and verified on-device (Pixel 10, debug build 2.12.0). `release/v2.13.0` merged into the branch; conflicts (settings/editor imports, `SettingsSection` enum, the renamed colour palette) resolved. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
makiolaj added 25 commits 2026-07-03 13:42:57 +00:00
Capture the full design for surfacing contact birthdays/anniversaries/custom
dates as auto-updating local calendars (Codeberg #15), plus the #14
prerequisite (per-calendar multiple default reminders). One local calendar per
type to reuse existing per-calendar color/visibility/reminder infra; one-way
mirror with field-level managed events keyed on contact LOOKUP_KEY.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Codeberg #15, foundation for the contact special-dates calendars. Declares the
optional, feature-gated READ_CONTACTS permission (never requested at startup)
and adds the offline, read-only contacts reader.

- AndroidManifest: READ_CONTACTS with a comment documenting the opt-in/offline
  one-way-mirror contract.
- domain/contacts: SpecialDateType + ContactSpecialDate model and a pure
  parseContactEventDate covering full (yyyy-MM-dd), year-less (--MM-dd) and
  compact (yyyyMMdd) shapes, with Feb-29 handling via a leap anchor.
- data/contacts: ContactSpecialDatesDataSource querying ContactsContract.Data
  Event rows, split by TYPE, deduped per (contact, type); returns empty without
  the permission so sync can degrade to a stalled state. Hilt-bound.
- Unit tests for the date parser (full/year-less/compact/Feb-29/malformed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The special-dates mirror needs a durable, user-invisible per-event key so a
re-sync diffs instead of duplicating. Rather than Events.SYNC_DATA1 (which the
provider drops on a LOCAL calendar unless written via a sync-adapter URI), the
key is a deterministic UID_2445 (contact-<type>:<lookupKey>@calendula), written
and read through the normal event URIs the app already uses.

- createManagedCalendar: a normal local calendar plus a CAL_SYNC2 marker, so the
  mirror can re-adopt its calendars after a prefs wipe (findManagedCalendars).
- queryManagedEvents: reads back _ID/UID_2445/TITLE/DTSTART/RRULE (DELETED=0) as
  the existing side of the diff.
- insertManagedEvent: writes the deterministic UID and seeds reminder rows once.
- updateManagedFields: a bare, targeted column update — never reconciles
  reminders/attendees, so user-owned event data survives every sync.

Reuses toWriteTimes/toRfc2445Duration (all-day UTC-midnight DTSTART + P1D
DURATION for the FREQ=YEARLY row) and the existing deleteEvent for removals.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds the SettingsPrefs surface the mirror is configured and reconciled through:
master enable, per-type toggles (default all three on), per-type managed calendar
id (source of truth for the editor's managedCalendarIds and the sync targets),
editable per-type title template, show-age, and a run/stalled status plus a
foreground-sync debounce timestamp.

Calendar ids are stored per type under their own long key (no serialization/
escaping); templates likewise per type. SpecialDatesStatus/StalledReason model
the paused (permission-revoked) state for the settings banner.

Extends the test FakeCalendarDataSource with a stateful managed-event surface
(inserts reflected in later queries) so the sync engine can be exercised for
idempotency. Round-trip tests for every new pref.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The engine reconciles device contact dates into the per-type local calendars.
Each run is an idempotent diff keyed on the deterministic UID_2445:

- new contacts inserted (seeding reminders via resolveDefaultReminder, plus a
  useful per-calendar all-day default of on-the-day + a week before so birthdays
  get lead time out of the box);
- changed contacts get a targeted managed-column update (title/dtstart/rrule
  only) — reminders/location/notes are never re-touched, so user edits survive;
- removed contacts deleted.

Managed calendars are created/adopted/removed per enabled type (reconcileCalendars,
self-healing against a stored-id that no longer exists), all-day FREQ=YEARLY
events anchored at the birth year (or a leap anchor when year-less). Pure helpers
(uid, anchor, age snapshot, title templating) and the diff are extracted for
unit testing; a stateful fake exercises full-run idempotency and user-data
preservation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Mirrors the auto-backup infrastructure. A daily periodic WorkManager job plus an
immediate run on enable/"Sync now" and a debounced foreground run keep the mirror
fresh without a ContentObserver.

- SpecialDatesScheduler.apply/runNow + SpecialDatesSyncWorker (EntryPoint):
  respects the toggle, debounces foreground runs (4h), and parks the feature in a
  PermissionRevoked stalled state (recorded for the settings banner) instead of
  retrying forever when READ_CONTACTS is gone.
- CalendulaApp.onCreate reconciles the schedule on every launch.
- RootScreen ON_RESUME enqueues a debounced foreground sync, gated on the
  READ_CONTACTS grant so opted-out users never enqueue it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
When editing an event in a special-dates calendar, the title, all-day/date and
recurrence are locked (the sync overwrites them) while reminders, location, notes
and colour stay editable — the field-level "managed" contract from the design.

- EventEditUiState.isManaged (calendar id ∈ managedCalendarIds while editing),
  wired through the ViewModel's state combine.
- InlineTextField/InlineField gain an `enabled` flag (dims + goes read-only);
  ScheduleRow dims and disables its date/time taps; the all-day switch and the
  recurrence card are disabled.
- A one-line "Managed by …" hint under the title explains what's editable.

The calendar picker needs no change — it's already create-only, and managed
events are always edits.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A dedicated sub-page under Settings drives the mirror:

- master enable toggle — requests READ_CONTACTS contextually (only here, never at
  startup), runs an immediate sync on enable, and on disable confirms then tears
  the managed calendars down;
- per-type toggles (Birthdays / Anniversaries / Other), each confirm-guarded
  since turning one off deletes its calendar;
- an editable title template ({name}/{age}) and a Show-age toggle;
- a "Sync now" row with the last-run time, a paused/permission banner with a
  re-grant button, and a pointer to per-calendar colour/visibility/reminders.

Exposed via a separate SpecialDatesUiState flow (the main settings combine is
already at capacity); the ViewModel gained the engine/scheduler wiring.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- German translations for the special-dates sub-page, the managed-field editor
  hint, and the calendar/title strings.
- Store listing (en/de): a paragraph on the optional, on-device, one-way contact
  special-dates feature.
- Soften the calendar-permission rationale ("all it asks for up front") now that
  an optional contacts permission exists, without weakening the privacy promise.
- CHANGELOG entry for #15.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Records the UID_2445 (vs SYNC_DATA1) identity decision, the sync-time age
snapshot, and the seeded reminder default; checks off the #14/#15 task lists.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A FREQ=YEARLY event has one static title, so {age} is only a sync-time snapshot
and can look wrong on far-future occurrences. Keep it out of the default
templates ({name}'s birthday / anniversary), and when a user does add {age} in
the title-format editor, show a short disclaimer about the snapshot behaviour.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Managed calendars now treat reminders as a calendar-level setting instead of the
seed-once-per-event model, matching how these homogeneous birthday/anniversary
calendars are actually used.

- New per-type "Reminders" control in the Contact special dates section. Changing
  it persists the per-calendar all-day override (so new events match) AND
  re-applies the set to *all existing events* in that calendar
  (CalendarDataSource.applyManagedCalendarReminders → SpecialDatesSyncEngine
  .applyReminders), encoding each event's all-day offset from its own date.
- Settings → Notifications: the contact-date calendars no longer offer a
  per-calendar override row; they show a link that jumps to the Contact special
  dates section (managedCalendarIds now in SettingsUiState).

Tests cover applyReminders (override persisted + bulk-apply invoked, and None
clears). lint + unit tests + assembleDebug green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The "Title format" row was only editable for Birthdays; move it into each
per-type card so Anniversaries and Other dates get their own editable template
too (the dialog was already keyed by type). Each enabled type card now reads
toggle → title format → reminders.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A FREQ=YEARLY event has one static title, so a per-occurrence age is impossible
without heavy per-year exception rows. Replace {age} with {year} — the birth
year (or an anniversary's start year) — which is static and correct on every
occurrence, and shows everywhere (widgets, other apps, exports).

- renderSpecialDateTitle substitutes {year}; drop the age snapshot computation
  and the sync `today` parameter.
- Default templates become "{name}'s birthday ({year})" / "…anniversary ({year})".
- Rename the setting to "Show year" (prefs specialDatesShowYear) and remove the
  now-unneeded {age} snapshot disclaimer in the template editor.
- Update tests, CHANGELOG and the design-doc note.

lint + unit tests + assembleDebug green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
insertManagedEvent duplicated ~45 lines of insertEvent (ContentValues build
and the reminder-seeding loop). Both now go through buildEventInsertValues
in EventWriteMapper and a shared seedReminders, differing only in the UID
they stamp and the colour/attendee columns insertEvent layers on top.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
AndroidSpecialDatesCalendarSpec hardcoded three ARGB literals that its KDoc
claimed were 'picked from the shared palette' — a silent-drift hazard, and
referencing ui.common from data would invert layering. The palette now lives
in data/calendar as CalendarColorPalette with named hues; the swatch pickers
use CalendarColorPalette.all and the spec references Purple/Red/Blue.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The READ_CONTACTS check was written out three times (data source, RootScreen
resume trigger, settings screen). A single extension in the contacts package
mirrors the existing Context.hasCalendarPermission() precedent; the settings
call site follows in the settings cleanup commit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- choiceFor moves next to CalendarReminderOverride in SettingsPrefs and
  replaces the hand-rolled null/empty/else decoder in SettingsViewModel,
  so the picker and the special-dates rows can't drift apart.
- The special-dates template dialog used the codebase's only
  OutlinedTextField; it now follows the documented dialog convention
  (InlineTextField over a tonal surface, see DialogControls).
- reminderChoiceLabel drops the manual loop (and its wrong comment —
  map is inline, so composable calls are fine in its lambda).
- The blank-template fallback is shared with the sync engine via
  resolveTitleTemplate instead of being copy-pasted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- The worker no longer pre-checks specialDatesEnabled: the engine already
  gates on it, and the worker now consumes the previously-unreachable
  Disabled result instead (without recording a run).
- ReminderContext drops the timed defaults it could never use — managed
  events are always all-day — saving two DataStore reads per sync.
- createCalendar no longer persists the calendar id itself;
  reconcileCalendars is the single place ids are recorded (it always
  followed up with the same write).
- The title-template fallback uses the shared resolveTitleTemplate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Managed special-dates calendars were only recognised by an id cached in
preferences, and were offered as ordinary write targets — so a user could
create an event in 'Birthdays' (which the next sync then deleted), and after
a backup restore wiped the prefs the editor lock silently disappeared.

CalendarSource now carries isManaged, read from the durable CAL_SYNC2 marker
the data layer already stamps, so identity survives a restore. With it:
- the new-event calendar picker excludes managed calendars (no user events
  land in a calendar the sync owns);
- the editor lock keys off the marker, not a stored id;
- a managed event's save is forced to the whole series instead of parking in
  the scope dialog (a 'this/following' split creates rows the sync reverts);
- the detail sheet hides Delete for managed events (the sync would just
  resurrect them — a contact date is removed at its source).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two provider-level bugs in the managed-event data path:

- queryManagedEvents and applyManagedCalendarReminders matched every event in
  the calendar (UID_2445 IS NOT NULL / no filter). A stray user event there
  (e.g. an .ics import) was treated as 'existing but not desired' and deleted,
  or had its own reminders wiped and all-day-re-encoded. Both now match only
  our own mirror events (the 'contact-' UID prefix).

- All-day reminder offsets were sampled at the event's DTSTART, which for a
  year-less birthday is the 1972 leap anchor — a year whose timezone offset
  (pre-DST) differs from today's, skewing every modern occurrence by up to an
  hour. The offset is now sampled at the upcoming occurrence (nextYearlyOccurrence),
  leaving only the inherent ±1h DST drift.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The mirror keyed each event on (contact, type) only, and the read deduped on
that same pair from an unordered query. So a contact with two custom dates
('Wedding', 'Graduation') mirrored just one — and, the winner being
order-dependent, the single event could ping-pong between the two dates across
syncs. A Custom date's UID now carries a discriminator (its label, else its
month-day), the read dedups on the full UID, and the query is ordered by
Data._ID so a genuine duplicate resolves the same way every time.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- The periodic job and the immediate 'Sync now'/foreground runs had no mutual
  exclusion, so two overlapping reconciles could each see no managed calendar
  and both create one (duplicate 'Birthdays', doubled events); likewise a
  disable-teardown racing an in-flight sync got its calendars recreated right
  after deletion. A lifecycle Mutex now makes sync() and teardown() atomic, and
  sync() re-reads the enabled flag inside the lock so a teardown always wins.

- The foreground resume trigger shared a unique work name with enable/'Sync
  now' under ExistingWorkPolicy.REPLACE, so a debounced foreground enqueue could
  cancel-and-swallow a pending enable sync (feature on, no calendars for up to a
  day). It now uses its own work name.

- doWork() mapped every exception to retry(), so a revoked calendar permission
  retried with backoff forever and never surfaced. A SecurityException now parks
  the feature in the stalled state instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Merge remote-tracking branch 'origin/release/v2.13.0' into feat/contact-special-dates
All checks were successful
Translations / check (pull_request) Successful in 26s
CI / ci (pull_request) Successful in 5m19s
49d04bcee0
# Conflicts:
#	app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt
#	app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt
#	app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt
#	app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt
#	app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt
makiolaj merged commit 1ff41153e9 into release/v2.13.0 2026-07-03 13:44:46 +00:00
makiolaj deleted branch feat/contact-special-dates 2026-07-03 13:44:47 +00:00
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: makiolaj/calendula#57