From cf9492c7ba257cc259b950c2f3de74d6745fed76 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 12 Jul 2026 12:55:05 +0200 Subject: [PATCH] fix(detail): derive series length from DURATION when DTEND is null (#48) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A recurring series row carries DURATION, not DTEND, so EventDetailMapper's end == begin fallback rendered it zero-length. That was harmless while every caller supplied per-occurrence times from Instances, but the bare content://…/events/ VIEW intent added in #48 names no occurrence and keeps the row's own times — so a series opened from a third-party widget without begin/end extras showed as "10:00 – 10:00". Read DURATION in the detail projection and derive the end from it, the same way SearchMapper and IcsExportMapper already do. Co-Authored-By: Claude Opus 4.8 --- .../data/calendar/EventDetailMapper.kt | 20 +++++++------ .../calendula/data/calendar/Projections.kt | 4 +++ .../data/calendar/EventDetailMapperTest.kt | 28 +++++++++++++++++-- 3 files changed, 41 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventDetailMapper.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventDetailMapper.kt index bc4be53..3711c81 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventDetailMapper.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventDetailMapper.kt @@ -14,6 +14,7 @@ import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.EventStatus import de.jeanlucmakiola.calendula.domain.Reminder import de.jeanlucmakiola.calendula.domain.ReminderMethod +import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis import java.time.Instant import java.time.ZoneId import java.time.ZoneOffset @@ -36,15 +37,18 @@ internal fun ColumnReader.toEventDetailCore( val begin = getLong(EventDetailProjection.IDX_DTSTART) // Recurring events store DURATION instead of DTEND, so the series row's - // DTEND is null. Keep the event (end == begin); callers that opened a - // specific occurrence supply the real per-occurrence times from - // CalendarContract.Instances. A present-but-backwards DTEND is malformed, - // but dropping the row would make the event un-openable — the same trap as - // the pre-1970 DTSTART bug above (issue #34): it would surface as the - // generic error screen with no way to open the event and fix it. Clamp to a - // zero-length event instead (matching SearchMapper's coerceAtLeast). + // DTEND is null — derive the length from DURATION (as SearchMapper and + // IcsExportMapper do). Callers that opened a specific occurrence overwrite + // both times with the per-occurrence values from CalendarContract.Instances; + // a caller that names no occurrence (a bare content://.../events/ VIEW + // intent, issue #48) keeps this row's own times, so the length has to be + // right here or the series renders zero-length. A present-but-backwards + // DTEND is malformed, but dropping the row would make the event un-openable + // — the same trap as the pre-1970 DTSTART bug above (issue #34): it would + // surface as the generic error screen with no way to open the event and fix + // it. Clamp to a zero-length event instead (matching SearchMapper). val end = if (isNull(EventDetailProjection.IDX_DTEND)) { - begin + begin + parseRfc2445DurationMillis(getString(EventDetailProjection.IDX_DURATION)) } else { getLong(EventDetailProjection.IDX_DTEND).coerceAtLeast(begin) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/Projections.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/Projections.kt index 2066ac7..2771195 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/Projections.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/Projections.kt @@ -84,6 +84,9 @@ internal object EventDetailProjection { CalendarContract.Events.EVENT_TIMEZONE, CalendarContract.Events.SELF_ATTENDEE_STATUS, CalendarContract.Events.EVENT_COLOR_KEY, + // Recurring rows carry DURATION instead of DTEND; the detail screen + // needs it to render a series opened without a named occurrence. + CalendarContract.Events.DURATION, ) const val IDX_EVENT_ID = 0 @@ -104,6 +107,7 @@ internal object EventDetailProjection { const val IDX_EVENT_TIMEZONE = 15 const val IDX_SELF_ATTENDEE_STATUS = 16 const val IDX_EVENT_COLOR_KEY = 17 + const val IDX_DURATION = 18 } /** diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventDetailMapperTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventDetailMapperTest.kt index 299d2fe..36d397d 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventDetailMapperTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventDetailMapperTest.kt @@ -32,6 +32,7 @@ class EventDetailMapperTest { accessLevel: Any? = null, timezone: String? = null, selfStatus: Any? = null, + duration: String? = null, ): MapColumnReader = MapColumnReader( EventDetailProjection.IDX_EVENT_ID to eventId, EventDetailProjection.IDX_TITLE to title, @@ -51,6 +52,7 @@ class EventDetailMapperTest { EventDetailProjection.IDX_EVENT_TIMEZONE to timezone, EventDetailProjection.IDX_SELF_ATTENDEE_STATUS to selfStatus, EventDetailProjection.IDX_EVENT_COLOR_KEY to eventColorKey, + EventDetailProjection.IDX_DURATION to duration, ) private fun attendeeReader( @@ -134,12 +136,32 @@ class EventDetailMapperTest { fun `pre-1970 negative dtstart is kept, not dropped (issue #34)`() { // A yearly birthday/anniversary anchored before the epoch has a // legitimately negative UTC epoch-millis DTSTART; recurring rows carry - // no DTEND (they use DURATION), so it stays end == begin. + // no DTEND (they use DURATION), so the length comes from DURATION. val begin = -157_766_400_000L // 1965-01-01T00:00:00Z - val detail = detailReader(dtstart = begin, dtend = null).toDetail() + val detail = detailReader(dtstart = begin, dtend = null, duration = "P1D").toDetail() assertThat(detail).isNotNull() assertThat(detail!!.instance.start.toEpochMilliseconds()).isEqualTo(begin) - assertThat(detail.instance.end.toEpochMilliseconds()).isEqualTo(begin) + assertThat(detail.instance.end.toEpochMilliseconds()).isEqualTo(begin + 86_400_000L) + } + + @Test + fun `absent dtend takes its length from DURATION (issue #48)`() { + // A recurring series row has no DTEND. Opened without a named + // occurrence — a bare content://…/events/ VIEW intent — the row's + // own times are what render, so DURATION has to supply the length or + // the event shows as zero-length (10:00–10:00). + val begin = 1_000_000_000L + val detail = detailReader(dtstart = begin, dtend = null, duration = "PT1H").toDetail() + assertThat(detail).isNotNull() + assertThat(detail!!.instance.end.toEpochMilliseconds()).isEqualTo(begin + 3_600_000L) + } + + @Test + fun `absent dtend and absent DURATION stays zero-length`() { + val begin = 1_000_000_000L + val detail = detailReader(dtstart = begin, dtend = null, duration = null).toDetail() + assertThat(detail).isNotNull() + assertThat(detail!!.instance.end.toEpochMilliseconds()).isEqualTo(begin) } @Test