Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,43 @@ class AndroidCalendarProviderBehaviorTest {
}


/**
* To make sure that's not a problem to insert an event with DTEND = DTSTART.
*/
@Test
fun testInsertEventWithDtEndEqualsDtStart() {
val values = contentValuesOf(
Events.CALENDAR_ID to calendar.id,
Events.DTSTART to 1759403653000, // Thu Oct 02 2025 11:14:13 GMT+0000
Events.DTEND to 1759403653000,
Events.TITLE to "Event with DTSTART = DTEND"
)
val id = calendar.addEvent(Entity(values))

// Google Calendar 2025.44.1-827414499-release correctly shows this event [2025/11/29]

val event2 = calendar.getEventRow(id)
assertContentValuesEqual(values, event2!!, onlyFieldsInExpected = true)
}

/**
* To make sure that's not a problem to insert an (invalid/useless) RRULE with UNTIL before the event's DTSTART.
*/
@Test
fun testInsertEventWithRRuleUntilBeforeDtStart() {
val values = contentValuesOf(
Events.CALENDAR_ID to calendar.id,
Events.DTSTART to 1759403653000, // Thu Oct 02 2025 11:14:13 GMT+0000
Events.DURATION to "PT1H",
Events.TITLE to "Event with useless RRULE",
Events.RRULE to "FREQ=DAILY;UNTIL=20251002T000000Z"
)
val id = calendar.addEvent(Entity(values))

val event2 = calendar.getEventRow(id)
assertContentValuesEqual(values, event2!!, onlyFieldsInExpected = true)
}

/**
* To verify that it's a problem to insert a recurring all-day event with a duration of zero seconds.
* See:
Expand Down Expand Up @@ -114,24 +151,6 @@ class AndroidCalendarProviderBehaviorTest {
assertContentValuesEqual(values, event2!!, onlyFieldsInExpected = true)
}

/**
* To make sure that's not a problem to insert an (invalid/useless) RRULE with UNTIL before the event's DTSTART.
*/
@Test
fun testInsertEventWithRRuleUntilBeforeDtStart() {
val values = contentValuesOf(
Events.CALENDAR_ID to calendar.id,
Events.DTSTART to 1759403653000, // Thu Oct 02 2025 11:14:13 GMT+0000
Events.DURATION to "PT1H",
Events.TITLE to "Event with useless RRULE",
Events.RRULE to "FREQ=DAILY;UNTIL=20251002T000000Z"
)
val id = calendar.addEvent(Entity(values))

val event2 = calendar.getEventRow(id)
assertContentValuesEqual(values, event2!!, onlyFieldsInExpected = true)
}


/**
* Reported as https://issuetracker.google.com/issues/446730408.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,21 @@ object TimeApiExtensions {

/***** Durations *****/

/**
* Returns the absolute (positive) temporal amount.
*/
fun TemporalAmount.abs(): TemporalAmount =
when (this) {
is Duration ->
this.abs()
is Period ->
if (this.isNegative)
this.negated()
else
this
else -> throw IllegalArgumentException("TemporalAmount must be Period or Duration")
}

fun TemporalAmount.toDuration(position: Instant): Duration =
when (this) {
is Duration -> this
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import android.content.Entity
import android.provider.CalendarContract.Events
import androidx.annotation.VisibleForTesting
import at.bitfire.ical4android.util.DateUtils
import at.bitfire.ical4android.util.TimeApiExtensions.abs
import at.bitfire.ical4android.util.TimeApiExtensions.toDuration
import at.bitfire.ical4android.util.TimeApiExtensions.toLocalDate
import at.bitfire.ical4android.util.TimeApiExtensions.toRfc5545Duration
Expand All @@ -18,7 +19,6 @@ import net.fortuna.ical4j.model.Property
import net.fortuna.ical4j.model.component.VEvent
import net.fortuna.ical4j.model.property.DtEnd
import net.fortuna.ical4j.model.property.DtStart
import net.fortuna.ical4j.model.property.Duration
import net.fortuna.ical4j.model.property.RDate
import net.fortuna.ical4j.model.property.RRule
import java.time.Period
Expand All @@ -42,8 +42,13 @@ class DurationBuilder: AndroidEntityBuilder {
}

val dtStart = from.requireDtStart()
val duration = from.duration

// calculate DURATION from DTEND - DTSTART, if necessary
val calculatedDuration = from.duration?.duration
?: calculateFromDtEnd(dtStart, from.endDate)

// use default duration, if necessary
val duration = calculatedDuration?.abs() // always use positive duration
?: defaultDuration(DateUtils.isDate(dtStart))

/* [RFC 5545 3.8.2.5]
Expand All @@ -54,7 +59,7 @@ class DurationBuilder: AndroidEntityBuilder {
so we wouldn't have to take care of that. However it expects seconds to be in "P<n>S" format,
whereas we provide an RFC 5545-compliant "PT<n>S", which causes the provider to crash:
https://github.com/bitfireAT/synctools/issues/144. So we must convert it ourselves to be on the safe side. */
val alignedDuration = alignWithDtStart(duration.duration, dtStart)
val alignedDuration = alignWithDtStart(duration, dtStart)

/* TemporalAmount can have months and years, but the RFC 5545 value must only contain weeks, days and time.
So we have to recalculate the months/years to days according to their position in the calendar.
Expand Down Expand Up @@ -90,7 +95,7 @@ class DurationBuilder: AndroidEntityBuilder {

} else {
// DTSTART is DATE-TIME
return if (amount is java.time.Period) {
return if (amount is Period) {
// amount is Period, change to Duration instead
amount.toDuration(dtStart.date.toInstant())
} else {
Expand All @@ -101,31 +106,29 @@ class DurationBuilder: AndroidEntityBuilder {
}

@VisibleForTesting
internal fun calculateFromDtEnd(dtStart: DtStart, dtEnd: DtEnd?): Duration? {
internal fun calculateFromDtEnd(dtStart: DtStart, dtEnd: DtEnd?): TemporalAmount? {
if (dtEnd == null)
return null

return if (DateUtils.isDateTime(dtStart) && DateUtils.isDateTime(dtEnd)) {
// DTSTART and DTEND are DATE-TIME → calculate difference between timestamps
val seconds = (dtEnd.date.time - dtStart.date.time) / 1000
Duration(java.time.Duration.ofSeconds(seconds))
java.time.Duration.ofSeconds(seconds)
} else {
// Either DTSTART or DTEND or both are DATE:
// - DTSTART and DTEND are DATE → DURATION is exact number of days (no time part)
// - DTSTART is DATE, DTEND is DATE-TIME → only use date part of DTEND → DURATION is exact number of days (no time part)
// - DTSTART is DATE-TIME, DTEND is DATE → amend DTEND with time of DTSTART → DURATION is exact number of days (no time part)
val startDate = dtStart.date.toLocalDate()
val endDate = dtEnd.date.toLocalDate()
Duration(Period.between(startDate, endDate))
Period.between(startDate, endDate)
}
}

private fun defaultDuration(allDay: Boolean): Duration =
Duration(
if (allDay)
Period.ofDays(1)
else
java.time.Duration.ZERO
)
private fun defaultDuration(allDay: Boolean): TemporalAmount =
if (allDay)
Period.ofDays(1)
else
java.time.Duration.ZERO

}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import android.content.Entity
import android.provider.CalendarContract.Events
import androidx.annotation.VisibleForTesting
import at.bitfire.ical4android.util.DateUtils
import at.bitfire.ical4android.util.TimeApiExtensions.abs
import at.bitfire.ical4android.util.TimeApiExtensions.toIcal4jDate
import at.bitfire.ical4android.util.TimeApiExtensions.toIcal4jDateTime
import at.bitfire.ical4android.util.TimeApiExtensions.toLocalDate
Expand Down Expand Up @@ -47,10 +48,22 @@ class EndTimeBuilder: AndroidEntityBuilder {
}

val dtStart = from.requireDtStart()
val dtEnd = from.endDate?.let { alignWithDtStart(it, dtStart = dtStart) }

// potentially calculate DTEND from DTSTART + DURATION, and always align with DTSTART value type
val calculatedDtEnd = from.getEndDate(/* don't let ical4j calculate DTEND from DURATION */ false)
?.let { alignWithDtStart(it, dtStart = dtStart) }
?: calculateFromDuration(dtStart, from.duration)

// ignore DTEND when not after DTSTART and use default duration, if necessary
val dtEnd = calculatedDtEnd
?.takeIf { it.date.toInstant() > dtStart.date.toInstant() } // only use DTEND if it's after DTSTART [1]
?: calculateFromDefault(dtStart)

/**
* [1] RFC 5545 3.8.2.2 Date-Time End:
* […] its value MUST be later in time than the value of the "DTSTART" property.
*/

// end time: UNIX timestamp
values.put(Events.DTEND, dtEnd.date.time)

Expand Down Expand Up @@ -127,7 +140,8 @@ class EndTimeBuilder: AndroidEntityBuilder {
if (duration == null)
return null

val dur = duration.duration
val dur = duration.duration.abs() // always take positive temporal amount

return if (DateUtils.isDate(dtStart)) {
// DTSTART is DATE
if (dur is Period) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

package at.bitfire.ical4android.util

import at.bitfire.ical4android.util.TimeApiExtensions.abs
import at.bitfire.ical4android.util.TimeApiExtensions.requireTimeZone
import at.bitfire.ical4android.util.TimeApiExtensions.toDuration
import at.bitfire.ical4android.util.TimeApiExtensions.toIcal4jDate
Expand Down Expand Up @@ -172,6 +173,39 @@ class TimeApiExtensionsTest {
}


@Test
fun testTemporalAmount_abs_Duration_negative() {
assertEquals(
Duration.ofMinutes(1),
Duration.ofMinutes(-1).abs()
)
}

@Test
fun testTemporalAmount_abs_Duration_positive() {
assertEquals(
Duration.ofDays(1),
Duration.ofDays(1).abs()
)
}

@Test
fun testTemporalAmount_abs_Period_negative() {
assertEquals(
Period.ofWeeks(1),
Period.ofWeeks(-1).abs()
)
}

@Test
fun testTemporalAmount_abs_Period_positive() {
assertEquals(
Period.ofDays(1),
Period.ofDays(1).abs()
)
}


@Test
fun testTemporalAmount_toDuration() {
assertEquals(Duration.ofHours(1), Duration.ofHours(1).toDuration(Instant.EPOCH))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,18 @@ class DurationBuilderTest {
assertEquals("P1W", result.entityValues.get(Events.DURATION))
}

@Test
fun `Recurring all-day event (with DTEND before START)`() {
val result = Entity(ContentValues())
val event = VEvent(propertyListOf(
DtStart(Date("20251017")),
DtEnd(Date("20251010")), // calculates to P-1W, which will be changed to P1W
RRule("FREQ=DAILY;COUNT=5")
))
builder.build(event, event, result)
assertEquals("P1W", result.entityValues.get(Events.DURATION))
}

@Test
fun `Recurring non-all-day event (with DTEND)`() {
val result = Entity(ContentValues())
Expand All @@ -119,6 +131,18 @@ class DurationBuilderTest {
assertEquals("P1DT1H1M1S", result.entityValues.get(Events.DURATION))
}

@Test
fun `Recurring non-all-day event (with DTEND before DTSTART)`() {
val result = Entity(ContentValues())
val event = VEvent(propertyListOf(
DtStart(DateTime("20251010T010203", tzVienna)),
DtEnd(DateTime("20251010T000203", tzVienna)), // calculates to PT-1H, will be rewritten to PT1H
RRule("FREQ=DAILY;COUNT=5")
))
builder.build(event, event, result)
assertEquals("PT1H", result.entityValues.get(Events.DURATION))
}

@Test
fun `Recurring all-day event (neither DURATION nor DTEND)`() {
val result = Entity(ContentValues())
Expand Down Expand Up @@ -186,7 +210,7 @@ class DurationBuilderTest {
DtEnd(Date("20240330"))
)
assertEquals(
Duration(Period.ofDays(2)),
Period.ofDays(2),
result
)
}
Expand All @@ -198,7 +222,7 @@ class DurationBuilderTest {
DtEnd(DateTime("20240330T123412", tzVienna))
)
assertEquals(
Duration(Period.ofDays(2)),
Period.ofDays(2),
result
)
}
Expand All @@ -210,7 +234,7 @@ class DurationBuilderTest {
DtEnd(Date("20240330"))
)
assertEquals(
Duration(Period.ofDays(2)),
Period.ofDays(2),
result
)
}
Expand All @@ -222,7 +246,7 @@ class DurationBuilderTest {
DtEnd(DateTime("20240728T010203Z")) // GMT+1 with DST → 2 hours difference
)
assertEquals(
Duration(java.time.Duration.ofHours(2)),
java.time.Duration.ofHours(2),
result
)
}
Expand Down
Loading