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,9 +19,9 @@ 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.Duration
import java.time.Period
import java.time.temporal.TemporalAmount

Expand All @@ -42,8 +43,13 @@ class DurationBuilder: AndroidEntityBuilder {
}

val dtStart = from.requireDtStart()
val duration = from.duration
?: calculateFromDtEnd(dtStart, from.endDate)

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

// 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 +60,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 All @@ -74,13 +80,13 @@ class DurationBuilder: AndroidEntityBuilder {
* @return Temporal amount that is
*
* - a [Period] (days/months/years that can't be represented by an exact number of seconds) when [dtStart] is a DATE, and
* - a [java.time.Duration] (exact time that can be represented by an exact number of seconds) when [dtStart] is a DATE-TIME.
* - a [Duration] (exact time that can be represented by an exact number of seconds) when [dtStart] is a DATE-TIME.
*/
@VisibleForTesting
internal fun alignWithDtStart(amount: TemporalAmount, dtStart: DtStart): TemporalAmount {
if (DateUtils.isDate(dtStart)) {
// DTSTART is DATE
return if (amount is java.time.Duration) {
return if (amount is Duration) {
// amount is Duration, change to Period of days instead
Period.ofDays(amount.toDays().toInt())
} else {
Expand All @@ -90,7 +96,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 @@ -100,32 +106,38 @@ class DurationBuilder: AndroidEntityBuilder {
}
}

/**
* Calculates the DURATION from DTEND - DTSTART, if possible.
*
* @param dtStart start date/date-time
* @param dtEnd (optional) end date/date-time (ignored if not after [dtStart])
*
* @return temporal amount ([Period] or [Duration]) or `null` if no valid end time was available
*/
@VisibleForTesting
internal fun calculateFromDtEnd(dtStart: DtStart, dtEnd: DtEnd?): Duration? {
if (dtEnd == null)
internal fun calculateFromDtEnd(dtStart: DtStart, dtEnd: DtEnd?): TemporalAmount? {
if (dtEnd == null || dtEnd.date.toInstant() <= dtStart.date.toInstant())
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))
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
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 All @@ -28,6 +29,7 @@ import java.time.LocalDate
import java.time.Period
import java.time.ZoneId
import java.time.ZonedDateTime
import java.time.temporal.TemporalAmount

class EndTimeBuilder: AndroidEntityBuilder {

Expand All @@ -47,10 +49,22 @@ class EndTimeBuilder: AndroidEntityBuilder {
}

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

// 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?.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 @@ -122,12 +136,21 @@ class EndTimeBuilder: AndroidEntityBuilder {
}
}

/**
* Calculates the DTEND from DTSTART + DURATION, if possible.
*
* @param dtStart start date/date-time
* @param duration (optional) duration
*
* @return end date/date-time (same value type as [dtStart]) or `null` if [duration] was not given
*/
@VisibleForTesting
internal fun calculateFromDuration(dtStart: DtStart, duration: net.fortuna.ical4j.model.property.Duration?): DtEnd? {
internal fun calculateFromDuration(dtStart: DtStart, duration: TemporalAmount?): DtEnd? {
if (duration == null)
return null

val dur = duration.duration
val dur = 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
Loading