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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user