fix: cancel only the tapped occurrence on single-instance recurring delete (Codeberg #47) #68

Merged
makiolaj merged 1 commits from fix/recurring-single-delete into release/v2.14.1 2026-07-12 09:09:07 +00:00
3 changed files with 90 additions and 7 deletions

View File

@@ -492,7 +492,13 @@ class AndroidCalendarDataSource @Inject constructor(
return resolver.query( return resolver.query(
uri, uri,
InstanceProjection.COLUMNS, InstanceProjection.COLUMNS,
null, null, // Hide cancelled occurrences: "delete only this event" writes a
// cancelled exception for the one instance (#47). A NULL status is a
// normal, un-cancelled event, so it must survive the filter — a bare
// `!= CANCELED` would drop it (NULL != 2 is NULL, not true).
"${CalendarContract.Instances.STATUS} IS NULL OR " +
"${CalendarContract.Instances.STATUS} != ${CalendarContract.Events.STATUS_CANCELED}",
null,
CalendarContract.Instances.BEGIN + " ASC", CalendarContract.Instances.BEGIN + " ASC",
)?.use { c -> c.mapAllNotNull { CursorColumnReader(c).toEventInstance() } } ?: emptyList() )?.use { c -> c.mapAllNotNull { CursorColumnReader(c).toEventInstance() } } ?: emptyList()
} }
@@ -1183,15 +1189,25 @@ class AndroidCalendarDataSource @Inject constructor(
override fun deleteOccurrence(eventId: Long, beginMillis: Long) { override fun deleteOccurrence(eventId: Long, beginMillis: Long) {
// A cancelled exception row hides exactly this occurrence; the sync // A cancelled exception row hides exactly this occurrence; the sync
// adapter turns it into an EXDATE/cancelled VEVENT upstream. // adapter turns it into an EXDATE/cancelled VEVENT upstream. It has to
val values = ContentValues().apply { // carry the full time set (DTSTART + DURATION + zone), not just STATUS:
put(CalendarContract.Events.ORIGINAL_INSTANCE_TIME, beginMillis) // the provider only demotes the cloned exception to a single instance —
put(CalendarContract.Events.STATUS, CalendarContract.Events.STATUS_CANCELED) // clearing the inherited RRULE — when it can derive that instance from
} // those columns. A STATUS-only cancel left the RRULE standing and
// cancelled the whole series, wiping every other occurrence (#47), the
// same trap the edit path documents (Codeberg #16).
val row = querySeriesRow(eventId)
val values = buildOccurrenceCancelValues(
originalInstanceMillis = beginMillis,
dtStartMillis = beginMillis,
duration = row.duration,
timezone = row.timezone,
allDay = row.allDay,
)
val uri = ContentUris.withAppendedId( val uri = ContentUris.withAppendedId(
CalendarContract.Events.CONTENT_EXCEPTION_URI, eventId, CalendarContract.Events.CONTENT_EXCEPTION_URI, eventId,
) )
resolver.insert(uri, values) resolver.insert(uri, values.toContentValues())
?: throw WriteFailedException("cancel occurrence event id=$eventId begin=$beginMillis") ?: throw WriteFailedException("cancel occurrence event id=$eventId begin=$beginMillis")
} }

View File

@@ -182,6 +182,33 @@ internal fun buildOccurrenceExceptionValues(
putAll(eventColorColumns(form.colorKey, form.color)) putAll(eventColorColumns(form.colorKey, form.color))
} }
/**
* Column values for a *cancelled*-occurrence exception row ("delete only this
* event"): inserting them at `Events.CONTENT_EXCEPTION_URI/<id>` makes the
* provider clone the series row and cancel exactly this one instance.
*
* As with [buildOccurrenceExceptionValues], the occurrence must be anchored with
* DTSTART + DURATION so the provider derives a single instance and clears the
* inherited RRULE. A STATUS-only cancel skips that: the clone keeps the RRULE, so
* the *whole series* is cancelled and every other occurrence disappears
* (Codeberg #47). The occurrence's length/zone come straight from the series row
* — cancelling never changes them.
*/
internal fun buildOccurrenceCancelValues(
originalInstanceMillis: Long,
dtStartMillis: Long,
duration: String?,
timezone: String?,
allDay: Int,
): Map<String, Any?> = buildMap {
put(CalendarContract.Events.ORIGINAL_INSTANCE_TIME, originalInstanceMillis)
put(CalendarContract.Events.DTSTART, dtStartMillis)
put(CalendarContract.Events.DURATION, duration)
put(CalendarContract.Events.EVENT_TIMEZONE, timezone)
put(CalendarContract.Events.ALL_DAY, allDay)
put(CalendarContract.Events.STATUS, CalendarContract.Events.STATUS_CANCELED)
}
/** /**
* The `EVENT_COLOR` / `EVENT_COLOR_KEY` columns for a colour selection. A * The `EVENT_COLOR` / `EVENT_COLOR_KEY` columns for a colour selection. A
* [colorKey] writes the key alone (the provider derives `EVENT_COLOR` from the * [colorKey] writes the key alone (the provider derives `EVENT_COLOR` from the

View File

@@ -220,6 +220,46 @@ class EventWriteMapperTest {
assertThat(values).containsEntry(CalendarContract.Events.DESCRIPTION, null) assertThat(values).containsEntry(CalendarContract.Events.DESCRIPTION, null)
} }
// --- buildOccurrenceCancelValues ("delete only this event") ---
@Test
fun `occurrence cancel anchors a single instance and cancels only it`() {
val values = buildOccurrenceCancelValues(
originalInstanceMillis = 1_700_000_000_000L,
dtStartMillis = 1_700_000_000_000L,
duration = "P3600S",
timezone = "Europe/Berlin",
allDay = 0,
)
assertThat(values[CalendarContract.Events.ORIGINAL_INSTANCE_TIME])
.isEqualTo(1_700_000_000_000L)
// DTSTART + DURATION make the provider derive a single instance and drop
// the inherited RRULE, so only this occurrence is cancelled — not the
// whole series (#47). DTEND is never sent (the provider rejects it).
assertThat(values[CalendarContract.Events.DTSTART]).isEqualTo(1_700_000_000_000L)
assertThat(values[CalendarContract.Events.DURATION]).isEqualTo("P3600S")
assertThat(values[CalendarContract.Events.EVENT_TIMEZONE]).isEqualTo("Europe/Berlin")
assertThat(values[CalendarContract.Events.STATUS])
.isEqualTo(CalendarContract.Events.STATUS_CANCELED)
assertThat(values).doesNotContainKey(CalendarContract.Events.DTEND)
assertThat(values).doesNotContainKey(CalendarContract.Events.RRULE)
}
@Test
fun `all-day occurrence cancel keeps the all-day flag and utc zone`() {
val values = buildOccurrenceCancelValues(
originalInstanceMillis = 1_700_000_000_000L,
dtStartMillis = 1_700_000_000_000L,
duration = "P1D",
timezone = "UTC",
allDay = 1,
)
assertThat(values[CalendarContract.Events.ALL_DAY]).isEqualTo(1)
assertThat(values[CalendarContract.Events.EVENT_TIMEZONE]).isEqualTo("UTC")
assertThat(values[CalendarContract.Events.STATUS])
.isEqualTo(CalendarContract.Events.STATUS_CANCELED)
}
// --- per-event colour --- // --- per-event colour ---
@Test @Test