diff --git a/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/AndroidEventHandler.kt b/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/AndroidEventHandler.kt index 6889277d..e28023bb 100644 --- a/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/AndroidEventHandler.kt +++ b/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/AndroidEventHandler.kt @@ -17,7 +17,6 @@ import at.bitfire.synctools.mapping.calendar.handler.AvailabilityHandler import at.bitfire.synctools.mapping.calendar.handler.CategoriesHandler import at.bitfire.synctools.mapping.calendar.handler.ColorHandler import at.bitfire.synctools.mapping.calendar.handler.DescriptionHandler -import at.bitfire.synctools.mapping.calendar.handler.DurationHandler import at.bitfire.synctools.mapping.calendar.handler.EndTimeHandler import at.bitfire.synctools.mapping.calendar.handler.LocationHandler import at.bitfire.synctools.mapping.calendar.handler.OrganizerHandler @@ -67,7 +66,6 @@ class AndroidEventHandler( LocationHandler(), StartTimeHandler(tzRegistry), EndTimeHandler(tzRegistry), - DurationHandler(tzRegistry), RecurrenceFieldsHandler(tzRegistry), DescriptionHandler(), ColorHandler(), diff --git a/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/MappingUtil.kt b/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/MappingUtil.kt new file mode 100644 index 00000000..9a649430 --- /dev/null +++ b/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/MappingUtil.kt @@ -0,0 +1,96 @@ +/* + * This file is part of bitfireAT/synctools which is released under GPLv3. + * Copyright © All Contributors. See the LICENSE and AUTHOR files in the root directory for details. + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package at.bitfire.synctools.mapping.calendar + +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 +import at.bitfire.ical4android.util.TimeApiExtensions.toZonedDateTime +import net.fortuna.ical4j.model.DateTime +import net.fortuna.ical4j.model.property.DtEnd +import net.fortuna.ical4j.model.property.DtStart +import java.time.Duration +import java.time.LocalDate +import java.time.Period +import java.time.temporal.TemporalAmount + +/** + * Common methods mapping logic + */ +object MappingUtil { + + /** + * Chooses a DTEND value for the content provider when the iCalendar doesn't have a DTEND. + * + * RFC 5545 says the following about empty DTEND values: + * + * > For cases where a "VEVENT" calendar component specifies a "DTSTART" property with a DATE value type but no + * > "DTEND" nor "DURATION" property, the event's duration is taken to be one day. For cases where a "VEVENT" calendar + * > component specifies a "DTSTART" property with a DATE-TIME value type but no "DTEND" property, the event + * > ends on the same calendar date and time of day specified by the "DTSTART" property. + * + * In iCalendar, `DTEND` is non-inclusive at must be at a later time than `DTEND`. However in Android we can use + * the same value for both the `DTSTART` and the `DTEND` field, and so we use this to indicate a missing DTEND in + * the original iCalendar. + * + * @param dtStart start time to calculate end time from + * @return End time to use for content provider: + * + * - when [dtStart] is a `DATE`: [dtStart] + 1 day + * - when [dtStart] is a `DATE-TIME`: [dtStart] + */ + fun dtEndFromDefault(dtStart: DtStart): DtEnd = + if (DateUtils.isDate(dtStart)) { + // DATE → one day duration + val endDate: LocalDate = dtStart.date.toLocalDate().plusDays(1) + DtEnd(endDate.toIcal4jDate()) + } else { + // DATE-TIME → same as DTSTART to indicate there was no DTEND set + DtEnd(dtStart.value, dtStart.timeZone) + } + + /** + * 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 dtEndFromDuration(dtStart: DtStart, duration: TemporalAmount?): DtEnd? { + if (duration == null) + return null + + val dur = duration.abs() // always take positive temporal amount + + return if (DateUtils.isDate(dtStart)) { + // DTSTART is DATE + if (dur is Period) { + // date-based amount of time ("4 days") + val result = dtStart.date.toLocalDate() + dur + DtEnd(result.toIcal4jDate()) + } else if (dur is Duration) { + // time-based amount of time ("34 minutes") + val days = dur.toDays() + val result = dtStart.date.toLocalDate() + Period.ofDays(days.toInt()) + DtEnd(result.toIcal4jDate()) + } else + throw IllegalStateException() // TemporalAmount neither Period nor Duration + + } else { + // DTSTART is DATE-TIME + // We can add both date-based (Period) and time-based (Duration) amounts of time to an exact date/time. + val result = (dtStart.date as DateTime).toZonedDateTime() + dur + DtEnd(result.toIcal4jDateTime()) + } + } + +} \ No newline at end of file diff --git a/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/builder/EndTimeBuilder.kt b/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/builder/EndTimeBuilder.kt index 8e071a87..fc51b0bc 100644 --- a/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/builder/EndTimeBuilder.kt +++ b/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/builder/EndTimeBuilder.kt @@ -10,12 +10,12 @@ 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 import at.bitfire.ical4android.util.TimeApiExtensions.toZonedDateTime import at.bitfire.synctools.icalendar.requireDtStart +import at.bitfire.synctools.mapping.calendar.MappingUtil import at.bitfire.synctools.util.AndroidTimeUtils import net.fortuna.ical4j.model.DateTime import net.fortuna.ical4j.model.Property @@ -24,12 +24,8 @@ import net.fortuna.ical4j.model.property.DtEnd import net.fortuna.ical4j.model.property.DtStart import net.fortuna.ical4j.model.property.RDate import net.fortuna.ical4j.model.property.RRule -import java.time.Duration -import java.time.LocalDate -import java.time.Period import java.time.ZoneId import java.time.ZonedDateTime -import java.time.temporal.TemporalAmount class EndTimeBuilder: AndroidEntityBuilder { @@ -53,12 +49,12 @@ class EndTimeBuilder: AndroidEntityBuilder { // 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) + ?: MappingUtil.dtEndFromDuration(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) + ?: MappingUtil.dtEndFromDefault(dtStart) /** * [1] RFC 5545 3.8.2.2 Date-Time End: @@ -136,72 +132,4 @@ 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: TemporalAmount?): DtEnd? { - if (duration == null) - return null - - val dur = duration.abs() // always take positive temporal amount - - return if (DateUtils.isDate(dtStart)) { - // DTSTART is DATE - if (dur is Period) { - // date-based amount of time ("4 days") - val result = dtStart.date.toLocalDate() + dur - DtEnd(result.toIcal4jDate()) - } else if (dur is Duration) { - // time-based amount of time ("34 minutes") - val days = dur.toDays() - val result = dtStart.date.toLocalDate() + Period.ofDays(days.toInt()) - DtEnd(result.toIcal4jDate()) - } else - throw IllegalStateException() // TemporalAmount neither Period nor Duration - - } else { - // DTSTART is DATE-TIME - // We can add both date-based (Period) and time-based (Duration) amounts of time to an exact date/time. - val result = (dtStart.date as DateTime).toZonedDateTime() + dur - DtEnd(result.toIcal4jDateTime()) - } - } - - /** - * Chooses a DTEND value for the content provider when the iCalendar doesn't have a DTEND. - * - * RFC 5545 says the following about empty DTEND values: - * - * > For cases where a "VEVENT" calendar component specifies a "DTSTART" property with a DATE value type but no - * > "DTEND" nor "DURATION" property, the event's duration is taken to be one day. For cases where a "VEVENT" calendar - * > component specifies a "DTSTART" property with a DATE-TIME value type but no "DTEND" property, the event - * > ends on the same calendar date and time of day specified by the "DTSTART" property. - * - * In iCalendar, `DTEND` is non-inclusive at must be at a later time than `DTEND`. However in Android we can use - * the same value for both the `DTSTART` and the `DTEND` field, and so we use this to indicate a missing DTEND in - * the original iCalendar. - * - * @param dtStart start time to calculate end time from - * @return End time to use for content provider: - * - * - when [dtStart] is a `DATE`: [dtStart] + 1 day - * - when [dtStart] is a `DATE-TIME`: [dtStart] - */ - @VisibleForTesting - internal fun calculateFromDefault(dtStart: DtStart): DtEnd = - if (DateUtils.isDate(dtStart)) { - // DATE → one day duration - val endDate: LocalDate = dtStart.date.toLocalDate().plusDays(1) - DtEnd(endDate.toIcal4jDate()) - } else { - // DATE-TIME → same as DTSTART to indicate there was no DTEND set - DtEnd(dtStart.value, dtStart.timeZone) - } - } \ No newline at end of file diff --git a/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/handler/DurationHandler.kt b/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/handler/DurationHandler.kt deleted file mode 100644 index 294ab149..00000000 --- a/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/handler/DurationHandler.kt +++ /dev/null @@ -1,74 +0,0 @@ -/* - * This file is part of bitfireAT/synctools which is released under GPLv3. - * Copyright © All Contributors. See the LICENSE and AUTHOR files in the root directory for details. - * SPDX-License-Identifier: GPL-3.0-or-later - */ - -package at.bitfire.synctools.mapping.calendar.handler - -import android.content.Entity -import android.provider.CalendarContract.Events -import at.bitfire.ical4android.util.TimeApiExtensions.toIcal4jDate -import at.bitfire.ical4android.util.TimeApiExtensions.toIcal4jDateTime -import at.bitfire.ical4android.util.TimeApiExtensions.toZonedDateTime -import at.bitfire.synctools.util.AndroidTimeUtils -import net.fortuna.ical4j.model.DateTime -import net.fortuna.ical4j.model.TimeZoneRegistry -import net.fortuna.ical4j.model.component.VEvent -import net.fortuna.ical4j.model.property.DtEnd -import java.time.Duration -import java.time.Instant -import java.time.Period -import java.time.ZoneOffset - -class DurationHandler( - private val tzRegistry: TimeZoneRegistry -): AndroidEventFieldHandler { - - override fun process(from: Entity, main: Entity, to: VEvent) { - val values = from.entityValues - - /* Skip if: - - DTEND is set – we don't need to process DURATION anymore. - - DURATION is not set – then usually DTEND is set; however it's also OK to have neither DTEND nor DURATION in a VEVENT. */ - if (values.getAsLong(Events.DTEND) != null) - return - val durStr = values.getAsString(Events.DURATION) ?: return - val duration = AndroidTimeUtils.parseDuration(durStr) - - // Skip in case of zero or negative duration (analogous to DTEND being before DTSTART). - if ((duration is Duration && (duration.isZero || duration.isNegative)) || - (duration is Period && (duration.isZero || duration.isNegative))) - return - - /* Some servers have problems with DURATION. For maximum compatibility, we always generate DTEND instead of DURATION. - (After all, the constraint that non-recurring events have a DTEND while recurring events use DURATION is Android-specific.) - So we have to calculate DTEND from DTSTART and its timezone plus DURATION. */ - - val tsStart = values.getAsLong(Events.DTSTART) ?: return - val allDay = (values.getAsInteger(Events.ALL_DAY) ?: 0) != 0 - - if (allDay) { - val startTimeUTC = Instant.ofEpochMilli(tsStart).atOffset(ZoneOffset.UTC) - val endDate = (startTimeUTC + duration).toLocalDate() - - // DATE - to.properties += DtEnd(endDate.toIcal4jDate()) - - } else { - // DATE-TIME - val startDateTime = AndroidTimeField( - timestamp = tsStart, - timeZone = values.getAsString(Events.EVENT_TIMEZONE), - allDay = false, - tzRegistry = tzRegistry - ).asIcal4jDate() as DateTime - - val start = startDateTime.toZonedDateTime() - val end = start + duration - - to.properties += DtEnd(end.toIcal4jDateTime(tzRegistry)) - } - } - -} \ No newline at end of file diff --git a/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/handler/EndTimeHandler.kt b/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/handler/EndTimeHandler.kt index 9cf9d2b5..0fa03478 100644 --- a/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/handler/EndTimeHandler.kt +++ b/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/handler/EndTimeHandler.kt @@ -8,42 +8,57 @@ package at.bitfire.synctools.mapping.calendar.handler import android.content.Entity import android.provider.CalendarContract.Events +import at.bitfire.synctools.mapping.calendar.MappingUtil +import at.bitfire.synctools.util.AndroidTimeUtils import net.fortuna.ical4j.model.TimeZoneRegistry import net.fortuna.ical4j.model.component.VEvent import net.fortuna.ical4j.model.property.DtEnd -import java.util.logging.Logger +import net.fortuna.ical4j.model.property.DtStart class EndTimeHandler( private val tzRegistry: TimeZoneRegistry -): AndroidEventFieldHandler { - - private val logger - get() = Logger.getLogger(javaClass.name) +) : AndroidEventFieldHandler { override fun process(from: Entity, main: Entity, to: VEvent) { val values = from.entityValues + + // Skip if start is not set (end can not exist without start) + val tsStart = values.getAsLong(Events.DTSTART) ?: return + + // Get other values + val tsEnd: Long? = values.getAsLong(Events.DTEND) + val startTz = values.getAsString(Events.EVENT_TIMEZONE) + val endTz = values.getAsString(Events.EVENT_END_TIMEZONE) val allDay = (values.getAsInteger(Events.ALL_DAY) ?: 0) != 0 - // Skip if DTEND is not set – then usually DURATION is set; however it's also OK to have neither DTEND nor DURATION in a VEVENT. - val tsEnd = values.getAsLong(Events.DTEND) ?: return + // DTSTART from DATE or DATE-TIME according to allDay + val dtStart = DtStart(AndroidTimeField(tsStart, startTz, allDay, tzRegistry).asIcal4jDate()) - // Also skip if DTEND is not after DTSTART (not allowed in iCalendar) - val tsStart = values.getAsLong(Events.DTSTART) ?: return - if (tsEnd <= tsStart) { - logger.warning("Ignoring DTEND=$tsEnd that is not after DTSTART=$tsStart") - return + // Create duration + val duration = values.getAsString(Events.DURATION)?.let { durStr -> + AndroidTimeUtils.parseDuration(durStr) } - // DATE or DATE-TIME according to allDay - val end = AndroidTimeField( - timestamp = tsEnd, - timeZone = values.getAsString(Events.EVENT_END_TIMEZONE) - ?: values.getAsString(Events.EVENT_TIMEZONE), // if end timezone is not present, assume same as for start - allDay = allDay, - tzRegistry = tzRegistry - ).asIcal4jDate() + // Create end if it is set and after start + val end = tsEnd + ?.takeIf { it >= tsStart } // End is only useful to us, if after start + ?.let { + // DATE or DATE-TIME according to allDay + AndroidTimeField( + timestamp = tsEnd, + timeZone = endTz ?: startTz, // if end timezone is not present, assume same as for start + allDay = allDay, + tzRegistry = tzRegistry + ).asIcal4jDate() + } + + // Use the set end if possible, otherwise generate from start + duration if set. Last resort + // is to use a default value. + val dtEnd = end?.let { DtEnd(it) } + ?: MappingUtil.dtEndFromDuration(dtStart, duration) + ?: MappingUtil.dtEndFromDefault(dtStart) // for compatibility with iCloud. See davx5-ose#1859 - to.properties += DtEnd(end) + to.properties += dtEnd } } \ No newline at end of file diff --git a/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/MappingUtilTest.kt b/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/MappingUtilTest.kt new file mode 100644 index 00000000..c5c1b580 --- /dev/null +++ b/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/MappingUtilTest.kt @@ -0,0 +1,107 @@ +/* + * This file is part of bitfireAT/synctools which is released under GPLv3. + * Copyright © All Contributors. See the LICENSE and AUTHOR files in the root directory for details. + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package at.bitfire.synctools.mapping.calendar + +import at.bitfire.synctools.mapping.calendar.MappingUtil.dtEndFromDefault +import at.bitfire.synctools.mapping.calendar.MappingUtil.dtEndFromDuration +import net.fortuna.ical4j.model.Date +import net.fortuna.ical4j.model.DateTime +import net.fortuna.ical4j.model.TimeZoneRegistryFactory +import net.fortuna.ical4j.model.property.DtEnd +import net.fortuna.ical4j.model.property.DtStart +import org.junit.Assert.assertEquals +import org.junit.Test + +class MappingUtilTest { + + private val tzRegistry = TimeZoneRegistryFactory.getInstance().createRegistry() + private val tzVienna = tzRegistry.getTimeZone("Europe/Vienna") + + + // dtEndFromDefault + + @Test + fun `dtEndFromDefault (DATE)`() { + assertEquals( + DtEnd(Date("20251101")), + dtEndFromDefault(DtStart(Date("20251031"))) + ) + } + + @Test + fun `dtEndFromDefault (DATE-TIME)`() { + val time = DateTime("20251031T123466Z") + assertEquals( + DtEnd(time), + dtEndFromDefault(DtStart(time)) + ) + } + + + // dtEndFromDuration + + @Test + fun `dtEndFromDuration (dtStart=DATE, duration is date-based)`() { + val result = dtEndFromDuration( + DtStart(Date("20240228")), + java.time.Duration.ofDays(1) + ) + assertEquals( + DtEnd(Date("20240229")), // leap day + result + ) + } + + @Test + fun `dtEndFromDuration (dtStart=DATE, duration is time-based)`() { + val result = dtEndFromDuration( + DtStart(Date("20241231")), + java.time.Duration.ofHours(25) + ) + assertEquals( + DtEnd(Date("20250101")), + result + ) + } + + @Test + fun `dtEndFromDuration (dtStart=DATE-TIME, duration is date-based)`() { + val result = dtEndFromDuration( + DtStart(DateTime("20250101T045623", tzVienna)), + java.time.Duration.ofDays(1) + ) + assertEquals( + DtEnd(DateTime("20250102T045623", tzVienna)), + result + ) + } + + @Test + fun `dtEndFromDuration (dtStart=DATE-TIME, duration is time-based)`() { + val result = dtEndFromDuration( + DtStart(DateTime("20250101T045623", tzVienna)), + java.time.Duration.ofHours(25) + ) + assertEquals( + DtEnd(DateTime("20250102T055623", tzVienna)), + result + ) + } + + @Test + fun `dtEndFromDuration (dtStart=DATE-TIME, duration is time-based and negative)`() { + val result = dtEndFromDuration( + DtStart(DateTime("20250101T045623", tzVienna)), + java.time.Duration.ofHours(-25) + ) + assertEquals( + DtEnd(DateTime("20250102T055623", tzVienna)), + result + ) + } + +} \ No newline at end of file diff --git a/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/builder/EndTimeBuilderTest.kt b/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/builder/EndTimeBuilderTest.kt index 2afb2310..20c7e57e 100644 --- a/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/builder/EndTimeBuilderTest.kt +++ b/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/builder/EndTimeBuilderTest.kt @@ -245,83 +245,4 @@ class EndTimeBuilderTest { assertEquals(DtEnd(DateTime("20251007T010203Z")), result) } - - @Test - fun `calculateFromDefault (DATE)`() { - assertEquals( - DtEnd(Date("20251101")), - builder.calculateFromDefault(DtStart(Date("20251031"))) - ) - } - - @Test - fun `calculateFromDefault (DATE-TIME)`() { - val time = DateTime("20251031T123466Z") - assertEquals( - DtEnd(time), - builder.calculateFromDefault(DtStart(time)) - ) - } - - - @Test - fun `calculateFromDuration (dtStart=DATE, duration is date-based)`() { - val result = builder.calculateFromDuration( - DtStart(Date("20240228")), - java.time.Duration.ofDays(1) - ) - assertEquals( - DtEnd(Date("20240229")), // leap day - result - ) - } - - @Test - fun `calculateFromDuration (dtStart=DATE, duration is time-based)`() { - val result = builder.calculateFromDuration( - DtStart(Date("20241231")), - java.time.Duration.ofHours(25) - ) - assertEquals( - DtEnd(Date("20250101")), - result - ) - } - - @Test - fun `calculateFromDuration (dtStart=DATE-TIME, duration is date-based)`() { - val result = builder.calculateFromDuration( - DtStart(DateTime("20250101T045623", tzVienna)), - java.time.Duration.ofDays(1) - ) - assertEquals( - DtEnd(DateTime("20250102T045623", tzVienna)), - result - ) - } - - @Test - fun `calculateFromDuration (dtStart=DATE-TIME, duration is time-based)`() { - val result = builder.calculateFromDuration( - DtStart(DateTime("20250101T045623", tzVienna)), - java.time.Duration.ofHours(25) - ) - assertEquals( - DtEnd(DateTime("20250102T055623", tzVienna)), - result - ) - } - - @Test - fun `calculateFromDuration (dtStart=DATE-TIME, duration is time-based and negative)`() { - val result = builder.calculateFromDuration( - DtStart(DateTime("20250101T045623", tzVienna)), - java.time.Duration.ofHours(-25) - ) - assertEquals( - DtEnd(DateTime("20250102T055623", tzVienna)), - result - ) - } - } \ No newline at end of file diff --git a/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/handler/DurationHandlerTest.kt b/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/handler/DurationHandlerTest.kt deleted file mode 100644 index 7a70e7fa..00000000 --- a/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/handler/DurationHandlerTest.kt +++ /dev/null @@ -1,140 +0,0 @@ -/* - * This file is part of bitfireAT/synctools which is released under GPLv3. - * Copyright © All Contributors. See the LICENSE and AUTHOR files in the root directory for details. - * SPDX-License-Identifier: GPL-3.0-or-later - */ - -package at.bitfire.synctools.mapping.calendar.handler - -import android.content.Entity -import android.provider.CalendarContract.Events -import androidx.core.content.contentValuesOf -import junit.framework.TestCase.assertEquals -import net.fortuna.ical4j.model.Date -import net.fortuna.ical4j.model.DateTime -import net.fortuna.ical4j.model.TimeZoneRegistryFactory -import net.fortuna.ical4j.model.component.VEvent -import net.fortuna.ical4j.model.property.DtEnd -import org.junit.Assert.assertNull -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner - -@RunWith(RobolectricTestRunner::class) -class DurationHandlerTest { - - private val tzRegistry = TimeZoneRegistryFactory.getInstance().createRegistry() - private val tzVienna = tzRegistry.getTimeZone("Europe/Vienna")!! - - private val handler = DurationHandler(tzRegistry) - - // Note: When the calendar provider sets a non-null DURATION, it implies that the event is recurring. - - @Test - fun `All-day event with all-day duration`() { - val result = VEvent() - val entity = Entity(contentValuesOf( - Events.ALL_DAY to 1, - Events.DTSTART to 1592733600000L, // 21/06/2020 10:00 UTC - Events.DURATION to "P4D" - )) - handler.process(entity, entity, result) - assertEquals(DtEnd(Date("20200625")), result.endDate) - assertNull(result.duration) - } - - @Test - fun `All-day event with non-all-day duration`() { - val result = VEvent() - val entity = Entity(contentValuesOf( - Events.ALL_DAY to 1, - Events.DTSTART to 1760486400000L, // Wed Oct 15 2025 00:00:00 GMT+0000 - Events.DURATION to "PT24H" - )) - handler.process(entity, entity, result) - assertEquals(DtEnd(Date("20251016")), result.endDate) - assertNull(result.duration) - } - - @Test - fun `Non-all-day event with all-day duration`() { - val result = VEvent() - val entity = Entity(contentValuesOf( - Events.ALL_DAY to 0, - Events.DTSTART to 1761433200000L, // Sun Oct 26 2025 01:00:00 GMT+0200 - Events.EVENT_TIMEZONE to "Europe/Vienna", - Events.DURATION to "P1D", - )) - // DST transition at 03:00, clock is set back to 02:00 → P1D = PT25H - handler.process(entity, entity, result) - assertEquals(DtEnd(DateTime("20251027T010000", tzVienna)), result.endDate) - assertNull(result.duration) - } - - @Test - fun `Non-all-day event with non-all-day duration`() { - val result = VEvent() - val entity = Entity(contentValuesOf( - Events.ALL_DAY to 0, - Events.DTSTART to 1761433200000L, // Sun Oct 26 2025 01:00:00 GMT+0200 - Events.EVENT_TIMEZONE to "Europe/Vienna", - Events.DURATION to "P24H" - )) - // DST transition at 03:00, clock is set back to 02:00 → P1D = PT25H - handler.process(entity, entity, result) - assertEquals(DtEnd(DateTime("20251027T000000", tzVienna)), result.endDate) - assertNull(result.duration) - } - - - // skip conditions - - @Test - fun `Skip if DTSTART is not set`() { - val result = VEvent() - val entity = Entity(contentValuesOf( - Events.DURATION to "PT1H" - )) - handler.process(entity, entity, result) - assertNull(result.duration) - } - - @Test - fun `Skip if DURATION is negative all-day duration`() { - val result = VEvent() - val entity = Entity(contentValuesOf( - Events.DTSTART to 1761433200000L, // Sun Oct 26 2025 01:00:00 GMT+0200 - Events.EVENT_TIMEZONE to "Europe/Vienna", - Events.DURATION to "P-1D" - )) - handler.process(entity, entity, result) - assertNull(result.endDate) - assertNull(result.duration) - } - - @Test - fun `Skip if DURATION is zero non-all-day duration`() { - val result = VEvent() - val entity = Entity(contentValuesOf( - Events.DTSTART to 1761433200000L, // Sun Oct 26 2025 01:00:00 GMT+0200 - Events.EVENT_TIMEZONE to "Europe/Vienna", - Events.DURATION to "PT0S" - )) - handler.process(entity, entity, result) - assertNull(result.endDate) - assertNull(result.duration) - } - - @Test - fun `Skip if DURATION is not set`() { - val result = VEvent() - val entity = Entity(contentValuesOf( - Events.DTSTART to 1761433200000L, // Sun Oct 26 2025 01:00:00 GMT+0200 - Events.EVENT_TIMEZONE to "Europe/Vienna" - )) - handler.process(entity, entity, result) - assertNull(result.endDate) - assertNull(result.duration) - } - -} \ No newline at end of file diff --git a/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/handler/EndTimeHandlerTest.kt b/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/handler/EndTimeHandlerTest.kt index 6a9e6fa8..24b8347f 100644 --- a/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/handler/EndTimeHandlerTest.kt +++ b/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/handler/EndTimeHandlerTest.kt @@ -33,6 +33,8 @@ class EndTimeHandlerTest { // Note: When the calendar provider sets a non-null DTEND, it implies that the event is not recurring. + // DTEND without DURATION + @Test fun `All-day event`() { val result = VEvent() @@ -88,30 +90,134 @@ class EndTimeHandlerTest { assertEquals(defaultTz, (result.endDate?.date as? DateTime)?.timeZone) } - - // skip conditions - @Test - fun `Skip if DTEND is not after DTSTART`() { + fun `DTEND is not after DTSTART`() { + // We always need DTEND if DTSTART is present, because iCloud rejects otherwise + // See davx5-ose#1859 val result = VEvent() val entity = Entity(contentValuesOf( Events.DTSTART to 1592733500000L, - Events.DTEND to 1592733500000L + Events.DTEND to 1592733100000L )) handler.process(entity, entity, result) - assertNull(result.endDate) + // It's against the standard to have DTEND with the same value as DTSTART, but we do it for compatibility with iCloud + assertEquals(1592733500000L, result.endDate?.date?.time) } @Test - fun `Skip if DTEND is not set`() { + fun `DTEND is not set`() { + // We always need DTEND if DTSTART is present, because iCloud rejects otherwise + // See davx5-ose#1859 val result = VEvent() val entity = Entity(contentValuesOf( Events.DTSTART to 1592733500000L )) handler.process(entity, entity, result) - assertNull(result.endDate) + // It's against the standard to have DTEND with the same value as DTSTART, but we do it for compatibility with iCloud + assertEquals(1592733500000L, result.endDate?.date?.time) + } + + + // DTEND missing but DURATION present + + @Test + fun `All-day event with all-day duration`() { + val result = VEvent() + val entity = Entity(contentValuesOf( + Events.ALL_DAY to 1, + Events.DTSTART to 1592733600000L, // 21/06/2020 10:00 UTC + Events.DURATION to "P4D" + )) + handler.process(entity, entity, result) + assertEquals(DtEnd(Date("20200625")), result.endDate) + assertNull(result.duration) + } + + @Test + fun `All-day event with non-all-day duration`() { + val result = VEvent() + val entity = Entity(contentValuesOf( + Events.ALL_DAY to 1, + Events.DTSTART to 1760486400000L, // Wed Oct 15 2025 00:00:00 GMT+0000 + Events.DURATION to "PT24H" + )) + handler.process(entity, entity, result) + assertEquals(DtEnd(Date("20251016")), result.endDate) + assertNull(result.duration) + } + + @Test + fun `Non-all-day event with all-day duration`() { + val result = VEvent() + val entity = Entity(contentValuesOf( + Events.ALL_DAY to 0, + Events.DTSTART to 1761433200000L, // Sun Oct 26 2025 01:00:00 GMT+0200 + Events.EVENT_TIMEZONE to "Europe/Vienna", + Events.DURATION to "P1D", + )) + // DST transition at 03:00, clock is set back to 02:00 → P1D = PT25H + handler.process(entity, entity, result) + assertEquals(DtEnd(DateTime("20251027T010000", tzVienna)), result.endDate) + assertNull(result.duration) } + @Test + fun `Non-all-day event with non-all-day duration`() { + val result = VEvent() + val entity = Entity(contentValuesOf( + Events.ALL_DAY to 0, + Events.DTSTART to 1761433200000L, // Sun Oct 26 2025 01:00:00 GMT+0200 + Events.EVENT_TIMEZONE to "Europe/Vienna", + Events.DURATION to "P24H" + )) + // DST transition at 03:00, clock is set back to 02:00 → P1D = PT25H + handler.process(entity, entity, result) + assertEquals(DtEnd(DateTime("20251027T000000", tzVienna)), result.endDate) + assertNull(result.duration) + } + + @Test + fun `One day default if DURATION is negative all-day duration`() { + val result = VEvent() + val entity = Entity(contentValuesOf( + Events.DTSTART to 1761433200000L, // Sun Oct 26 2025 01:00:00 GMT+0200 + Events.EVENT_TIMEZONE to "Europe/Vienna", + Events.DURATION to "P-1D" + )) + handler.process(entity, entity, result) + + assertEquals(DtEnd(DateTime("20251027T010000", tzVienna)), result.endDate) + assertNull(result.duration) + } + + @Test + fun `Start time if DURATION is zero non-all-day duration`() { + val result = VEvent() + val entity = Entity(contentValuesOf( + Events.DTSTART to 1761433200000L, // Sun Oct 26 2025 01:00:00 GMT+0200 + Events.EVENT_TIMEZONE to "Europe/Vienna", + Events.DURATION to "PT0S" + )) + handler.process(entity, entity, result) + assertEquals(DtEnd(DateTime("20251026T010000", tzVienna)), result.endDate) + assertNull(result.duration) + } + + @Test + fun `Start time if DURATION is not set`() { + val result = VEvent() + val entity = Entity(contentValuesOf( + Events.DTSTART to 1761433200000L, // Sun Oct 26 2025 01:00:00 GMT+0200 + Events.EVENT_TIMEZONE to "Europe/Vienna" + )) + handler.process(entity, entity, result) + assertEquals(DtEnd(DateTime("20251026T010000", tzVienna)), result.endDate) + assertNull(result.duration) + } + + + // skip conditions + @Test fun `Skip if DTSTART is not set`() { val result = VEvent()