Compare commits
3 Commits
release/v2
...
d398c72005
| Author | SHA1 | Date | |
|---|---|---|---|
| d398c72005 | |||
| 2218c11d3f | |||
| 1e7ee5b98a |
@@ -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")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user