fix: drop occurrences via EXDATE on calendars with no _sync_id (#47)

The cancelled-exception fix works on synced calendars but not local ones. A
cancelled exception only attaches to its parent through ORIGINAL_SYNC_ID; a
local event has no _sync_id, so the link never forms and the provider's
expansion of the *parent* collapses — every other occurrence disappears, which
is the original #47 corruption, just on a different calendar type. Verified
on-device both ways: a DAVx5 series survives a single-occurrence delete, the
same series on a LOCAL calendar vanishes entirely.

deleteOccurrence now branches on _sync_id. Synced events keep the (verified)
exception path. Events without one — local calendars, and synced events not yet
pushed — add the occurrence to the master's EXDATE, which needs no parent link
and is the canonical iCalendar way to drop one; a sync adapter carries it
upstream unchanged if the calendar later syncs.

Two provider quirks shape the write (both observed on a Pixel):
- An EXDATE-only update is not treated as a recurrence change: the expanded
  Instances rows are left alone, so the occurrence stays visible. The
  time/recurrence set has to ride along to force re-expansion.
- DTSTART alone is worse — the provider then recomputes lastDate as if the event
  were a single instance and collapses the series to its first occurrence.
  DTSTART + DURATION + RRULE + zone together re-expand it correctly.

This path is reached in normal use: Calendula's own contact special-date
calendars are local and hold all-day yearly series, so deleting one birthday
occurrence went through it. All-day series take the VALUE=DATE EXDATE form.

Adds pure buildOccurrenceExdateValues + JVM tests (timed, append, duplicate
fold, all-day).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-13 15:01:11 +02:00
parent cf9492c7ba
commit 4fea176e28
3 changed files with 176 additions and 8 deletions

View File

@@ -260,6 +260,77 @@ class EventWriteMapperTest {
.isEqualTo(CalendarContract.Events.STATUS_CANCELED)
}
// --- buildOccurrenceExdateValues ("delete only this event", no _sync_id) ---
@Test
fun `exdate drop excludes the occurrence and rewrites the recurrence set`() {
// 2026-07-15T08:00:00Z.
val values = buildOccurrenceExdateValues(
existingExdate = null,
occurrenceMillis = 1_784_102_400_000L,
dtStartMillis = 1_783_929_600_000L,
rrule = "FREQ=DAILY;COUNT=5",
duration = "PT1H",
timezone = "Europe/Berlin",
allDay = 0,
)
assertThat(values[CalendarContract.Events.EXDATE]).isEqualTo("20260715T080000Z")
// The whole time/recurrence set rides along: an EXDATE-only update is not
// treated as a recurrence change, so the provider would leave the expanded
// instances (and the occurrence) in place. DTSTART alone is worse — it
// makes the provider recompute lastDate as a single instance and collapse
// the series to its first occurrence.
assertThat(values[CalendarContract.Events.DTSTART]).isEqualTo(1_783_929_600_000L)
assertThat(values[CalendarContract.Events.RRULE]).isEqualTo("FREQ=DAILY;COUNT=5")
assertThat(values[CalendarContract.Events.DURATION]).isEqualTo("PT1H")
assertThat(values[CalendarContract.Events.EVENT_TIMEZONE]).isEqualTo("Europe/Berlin")
}
@Test
fun `exdate drop appends to an existing exdate list`() {
val values = buildOccurrenceExdateValues(
existingExdate = "20260714T080000Z",
occurrenceMillis = 1_784_102_400_000L,
dtStartMillis = 1_783_929_600_000L,
rrule = "FREQ=DAILY;COUNT=5",
duration = "PT1H",
timezone = "Europe/Berlin",
allDay = 0,
)
assertThat(values[CalendarContract.Events.EXDATE])
.isEqualTo("20260714T080000Z,20260715T080000Z")
}
@Test
fun `exdate drop folds away a repeated occurrence`() {
val values = buildOccurrenceExdateValues(
existingExdate = "20260715T080000Z",
occurrenceMillis = 1_784_102_400_000L,
dtStartMillis = 1_783_929_600_000L,
rrule = "FREQ=DAILY;COUNT=5",
duration = "PT1H",
timezone = "Europe/Berlin",
allDay = 0,
)
assertThat(values[CalendarContract.Events.EXDATE]).isEqualTo("20260715T080000Z")
}
@Test
fun `all-day exdate drop uses the date-only form`() {
// An all-day DTSTART sits at UTC midnight, so the date reads off UTC.
val values = buildOccurrenceExdateValues(
existingExdate = null,
occurrenceMillis = 1_784_073_600_000L, // 2026-07-15T00:00:00Z
dtStartMillis = 1_783_900_800_000L,
rrule = "FREQ=YEARLY",
duration = "P1D",
timezone = "UTC",
allDay = 1,
)
assertThat(values[CalendarContract.Events.EXDATE]).isEqualTo("20260715")
assertThat(values[CalendarContract.Events.ALL_DAY]).isEqualTo(1)
}
// --- per-event colour ---
@Test