Merge recurring occurrence save fix (#338)

This commit is contained in:
2026-10-01 13:17:43 +02:00
4 changed files with 29 additions and 3 deletions
+2
View File
@@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
- **Multi-day events stay above shorter events.** Month and Week bars, Split day lists, and Agenda now place multi-day events before one-day all-day events, then timed events; declined events remain last ([#291]).
- **Editing one occurrence of a coloured Google series saves again.** Saving with "Only this event" kept you on the edit screen without saving anything ([#338]).
## [2.21.0] — 2026-09-23
@@ -1789,3 +1790,4 @@ automatically, with zero telemetry and no internet permission.
[#321]: https://codeberg.org/jlmakiola/calendula/issues/321
[#329]: https://codeberg.org/jlmakiola/calendula/issues/329
[#285]: https://codeberg.org/jlmakiola/calendula/issues/285
[#338]: https://codeberg.org/jlmakiola/calendula/issues/338
@@ -1261,6 +1261,13 @@ class AndroidCalendarDataSource @Inject constructor(
values.toContentValues(),
) ?: throw WriteFailedException("modify occurrence event id=$eventId begin=$beginMillis")
val exceptionId = ContentUris.parseId(uri)
form.colorKey?.let { key ->
resolver.update(
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, exceptionId),
ContentValues().apply { put(CalendarContract.Events.EVENT_COLOR_KEY, key) },
null, null,
)
}
// Whether the provider copied the parent's reminder rows is its
// business — reconciling against the actual rows handles both ways.
reconcileReminders(exceptionId, encodedReminders(form, allDayReminderTimeMinutes))
@@ -247,6 +247,10 @@ internal fun buildEventUpdateValues(
* DTSTART + DURATION itself, clearing the inherited RRULE in the process. This
* matches how AOSP Calendar/Etar write exceptions; sending DTEND is what made
* "only this event" fail on-device (Codeberg #16).
*
* A palette key is left out: the provider crashes on an exception insert that
* carries `EVENT_COLOR_KEY` (Codeberg #338), so the caller sets it with a plain
* update once the row exists.
*/
internal fun buildOccurrenceExceptionValues(
form: EventForm,
@@ -264,7 +268,7 @@ internal fun buildOccurrenceExceptionValues(
put(CalendarContract.Events.ACCESS_LEVEL, form.accessLevel.toProviderValue())
put(CalendarContract.Events.EVENT_LOCATION, form.location.trim().ifEmpty { null })
put(CalendarContract.Events.DESCRIPTION, form.description.trim().ifEmpty { null })
putAll(eventColorColumns(form.colorKey, form.color))
if (form.colorKey == null) putAll(eventColorColumns(null, form.color))
}
/**
@@ -1075,16 +1075,29 @@ class EventWriteMapperTest {
}
@Test
fun `occurrence exception carries the palette key`() {
fun `occurrence exception leaves the palette key out`() {
// The provider crashes on an exception insert carrying a colour key
// (Codeberg #338); the data source sets it with an update afterwards.
val values = buildOccurrenceExceptionValues(
form = form().copy(colorKey = "2", color = 0xFFE67C00.toInt()),
originalInstanceMillis = 0L,
zone = berlin,
)
assertThat(values).containsEntry(CalendarContract.Events.EVENT_COLOR_KEY, "2")
assertThat(values).doesNotContainKey(CalendarContract.Events.EVENT_COLOR_KEY)
assertThat(values).doesNotContainKey(CalendarContract.Events.EVENT_COLOR)
}
@Test
fun `occurrence exception with a custom colour writes it directly`() {
val values = buildOccurrenceExceptionValues(
form = form().copy(color = 0xFFE67C00.toInt()),
originalInstanceMillis = 0L,
zone = berlin,
)
assertThat(values).containsEntry(CalendarContract.Events.EVENT_COLOR, 0xFFE67C00.toInt())
assertThat(values).containsEntry(CalendarContract.Events.EVENT_COLOR_KEY, null)
}
@Test
fun `occurrence exception with no colour clears both columns`() {
val values = buildOccurrenceExceptionValues(