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:
@@ -14,6 +14,7 @@ import de.jeanlucmakiola.calendula.domain.EventInstance
|
|||||||
import de.jeanlucmakiola.calendula.domain.EventStatus
|
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||||
import de.jeanlucmakiola.calendula.domain.Reminder
|
import de.jeanlucmakiola.calendula.domain.Reminder
|
||||||
import de.jeanlucmakiola.calendula.domain.ReminderMethod
|
import de.jeanlucmakiola.calendula.domain.ReminderMethod
|
||||||
|
import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
import java.time.ZoneOffset
|
import java.time.ZoneOffset
|
||||||
@@ -36,15 +37,18 @@ internal fun ColumnReader.toEventDetailCore(
|
|||||||
val begin = getLong(EventDetailProjection.IDX_DTSTART)
|
val begin = getLong(EventDetailProjection.IDX_DTSTART)
|
||||||
|
|
||||||
// Recurring events store DURATION instead of DTEND, so the series row's
|
// Recurring events store DURATION instead of DTEND, so the series row's
|
||||||
// DTEND is null. Keep the event (end == begin); callers that opened a
|
// DTEND is null — derive the length from DURATION (as SearchMapper and
|
||||||
// specific occurrence supply the real per-occurrence times from
|
// IcsExportMapper do). Callers that opened a specific occurrence overwrite
|
||||||
// CalendarContract.Instances. A present-but-backwards DTEND is malformed,
|
// both times with the per-occurrence values from CalendarContract.Instances;
|
||||||
// but dropping the row would make the event un-openable — the same trap as
|
// a caller that names no occurrence (a bare content://.../events/<id> VIEW
|
||||||
// the pre-1970 DTSTART bug above (issue #34): it would surface as the
|
// intent, issue #48) keeps this row's own times, so the length has to be
|
||||||
// generic error screen with no way to open the event and fix it. Clamp to a
|
// right here or the series renders zero-length. A present-but-backwards
|
||||||
// zero-length event instead (matching SearchMapper's coerceAtLeast).
|
// 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)) {
|
val end = if (isNull(EventDetailProjection.IDX_DTEND)) {
|
||||||
begin
|
begin + parseRfc2445DurationMillis(getString(EventDetailProjection.IDX_DURATION))
|
||||||
} else {
|
} else {
|
||||||
getLong(EventDetailProjection.IDX_DTEND).coerceAtLeast(begin)
|
getLong(EventDetailProjection.IDX_DTEND).coerceAtLeast(begin)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,6 +84,9 @@ internal object EventDetailProjection {
|
|||||||
CalendarContract.Events.EVENT_TIMEZONE,
|
CalendarContract.Events.EVENT_TIMEZONE,
|
||||||
CalendarContract.Events.SELF_ATTENDEE_STATUS,
|
CalendarContract.Events.SELF_ATTENDEE_STATUS,
|
||||||
CalendarContract.Events.EVENT_COLOR_KEY,
|
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
|
const val IDX_EVENT_ID = 0
|
||||||
@@ -104,6 +107,7 @@ internal object EventDetailProjection {
|
|||||||
const val IDX_EVENT_TIMEZONE = 15
|
const val IDX_EVENT_TIMEZONE = 15
|
||||||
const val IDX_SELF_ATTENDEE_STATUS = 16
|
const val IDX_SELF_ATTENDEE_STATUS = 16
|
||||||
const val IDX_EVENT_COLOR_KEY = 17
|
const val IDX_EVENT_COLOR_KEY = 17
|
||||||
|
const val IDX_DURATION = 18
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ class EventDetailMapperTest {
|
|||||||
accessLevel: Any? = null,
|
accessLevel: Any? = null,
|
||||||
timezone: String? = null,
|
timezone: String? = null,
|
||||||
selfStatus: Any? = null,
|
selfStatus: Any? = null,
|
||||||
|
duration: String? = null,
|
||||||
): MapColumnReader = MapColumnReader(
|
): MapColumnReader = MapColumnReader(
|
||||||
EventDetailProjection.IDX_EVENT_ID to eventId,
|
EventDetailProjection.IDX_EVENT_ID to eventId,
|
||||||
EventDetailProjection.IDX_TITLE to title,
|
EventDetailProjection.IDX_TITLE to title,
|
||||||
@@ -51,6 +52,7 @@ class EventDetailMapperTest {
|
|||||||
EventDetailProjection.IDX_EVENT_TIMEZONE to timezone,
|
EventDetailProjection.IDX_EVENT_TIMEZONE to timezone,
|
||||||
EventDetailProjection.IDX_SELF_ATTENDEE_STATUS to selfStatus,
|
EventDetailProjection.IDX_SELF_ATTENDEE_STATUS to selfStatus,
|
||||||
EventDetailProjection.IDX_EVENT_COLOR_KEY to eventColorKey,
|
EventDetailProjection.IDX_EVENT_COLOR_KEY to eventColorKey,
|
||||||
|
EventDetailProjection.IDX_DURATION to duration,
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun attendeeReader(
|
private fun attendeeReader(
|
||||||
@@ -134,12 +136,32 @@ class EventDetailMapperTest {
|
|||||||
fun `pre-1970 negative dtstart is kept, not dropped (issue #34)`() {
|
fun `pre-1970 negative dtstart is kept, not dropped (issue #34)`() {
|
||||||
// A yearly birthday/anniversary anchored before the epoch has a
|
// A yearly birthday/anniversary anchored before the epoch has a
|
||||||
// legitimately negative UTC epoch-millis DTSTART; recurring rows carry
|
// 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 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).isNotNull()
|
||||||
assertThat(detail!!.instance.start.toEpochMilliseconds()).isEqualTo(begin)
|
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/<id> 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
|
@Test
|
||||||
|
|||||||
Reference in New Issue
Block a user