fix(detail): derive series length from DURATION when DTEND is null (#48)

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/<id> 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 <noreply@anthropic.com>
This commit is contained in:
2026-07-12 12:55:05 +02:00
parent e6def9e5f7
commit cf9492c7ba
3 changed files with 41 additions and 11 deletions

View File

@@ -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/<id> 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)
}

View File

@@ -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
}
/**