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 8a59e260..492d6da7 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 @@ -47,6 +47,7 @@ import net.fortuna.ical4j.model.property.ProdId import net.fortuna.ical4j.model.property.RDate import net.fortuna.ical4j.model.property.RRule import net.fortuna.ical4j.model.property.RecurrenceId +import java.time.Instant import java.time.LocalDate import java.util.LinkedList import java.util.UUID @@ -166,6 +167,8 @@ class AndroidEventHandler( return if (originalAllDay) { // .. as date, without time ExDate(DateList(LocalDate.from(date))) + } else if (date is Instant) { + ExDate(DateList(date)) } else { // .. as ZonedDateTime, with time and TZ param ExDate( diff --git a/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/handler/AndroidTimeField.kt b/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/handler/AndroidTimeField.kt index 19aad879..46a214a0 100644 --- a/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/handler/AndroidTimeField.kt +++ b/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/handler/AndroidTimeField.kt @@ -7,6 +7,7 @@ package at.bitfire.synctools.mapping.calendar.handler import at.bitfire.synctools.util.AndroidTimeUtils +import at.bitfire.synctools.util.AndroidTimeUtils.isUtcTzId import net.fortuna.ical4j.util.TimeZones import java.time.DateTimeException import java.time.Instant @@ -49,16 +50,16 @@ class AndroidTimeField( val tzId = timeZone ?: ZoneId.systemDefault().id // safe fallback (should never be used/needed because the calendar provider requires EVENT_TIMEZONE) - val timezone = if (tzId == AndroidTimeUtils.TZID_UTC || tzId == TimeZones.UTC_ID || tzId == TimeZones.IBM_UTC_ID) { - ZoneOffset.UTC - } else { - try { - ZoneId.of(tzId) - } catch (_: DateTimeException) { - ZoneId.of(defaultTzId) - } catch (_: ZoneRulesException) { - ZoneId.of(defaultTzId) - } + if (isUtcTzId(tzId)) { + return instant + } + + val timezone = try { + ZoneId.of(tzId) + } catch (_: DateTimeException) { + ZoneId.of(defaultTzId) + } catch (_: ZoneRulesException) { + ZoneId.of(defaultTzId) } return ZonedDateTime.ofInstant(instant, timezone) 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 index d274aa91..5d60e4cb 100644 --- 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 @@ -11,11 +11,11 @@ import android.provider.CalendarContract.Events import at.bitfire.ical4android.util.TimeApiExtensions.abs import at.bitfire.synctools.icalendar.plusAssign import at.bitfire.synctools.util.AndroidTimeUtils -import at.bitfire.synctools.util.AndroidTimeUtils.toZonedDateTime import net.fortuna.ical4j.model.component.VEvent import net.fortuna.ical4j.model.property.DtEnd import java.time.Instant import java.time.ZoneOffset +import java.time.ZonedDateTime /** * Maps a potentially present [Events.DURATION] to a VEvent [DtEnd] property. @@ -62,8 +62,13 @@ class DurationHandler: AndroidEventFieldHandler { allDay = false ).toTemporal() - val start = startDateTime.toZonedDateTime() - val end = start + duration + val end = when (startDateTime) { + is Instant -> startDateTime + duration + is ZonedDateTime -> startDateTime + duration + else -> { + error("Unsupported Temporal type: ${startDateTime::class.qualifiedName}") + } + } to += DtEnd(end) } diff --git a/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/handler/OriginalInstanceTimeHandler.kt b/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/handler/OriginalInstanceTimeHandler.kt index dffd642c..ce1ddb93 100644 --- a/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/handler/OriginalInstanceTimeHandler.kt +++ b/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/handler/OriginalInstanceTimeHandler.kt @@ -10,6 +10,7 @@ import android.content.Entity import android.provider.CalendarContract.Events import at.bitfire.synctools.icalendar.DatePropertyTzMapper import at.bitfire.synctools.icalendar.plusAssign +import at.bitfire.synctools.util.AndroidTimeUtils.isUtcTzId import net.fortuna.ical4j.model.component.VEvent import net.fortuna.ical4j.model.property.RecurrenceId import java.time.Instant @@ -32,17 +33,21 @@ class OriginalInstanceTimeHandler: AndroidEventFieldHandler { to += if (originalAllDay) { RecurrenceId(LocalDate.ofInstant(instant, ZoneOffset.UTC)) } else { - val zoneId = getMainEventZoneId(main) - RecurrenceId(ZonedDateTime.ofInstant(instant, zoneId)) + val mainTzId = main.entityValues.getAsString(Events.EVENT_TIMEZONE) + if (isUtcTzId(mainTzId)) { + RecurrenceId(instant) + } else { + val zoneId = getZoneId(mainTzId) + RecurrenceId(ZonedDateTime.ofInstant(instant, zoneId)) + } } } } - private fun getMainEventZoneId(main: Entity): ZoneId? { - val mainTzId = main.entityValues.getAsString(Events.EVENT_TIMEZONE) - val mainTimezone = DatePropertyTzMapper.systemTzId(mainTzId) - return if (mainTimezone != null) { - ZoneId.of(mainTimezone) + private fun getZoneId(tzId: String?): ZoneId? { + val timezone = DatePropertyTzMapper.systemTzId(tzId) + return if (timezone != null) { + ZoneId.of(timezone) } else { ZoneId.systemDefault() } diff --git a/lib/src/main/kotlin/at/bitfire/synctools/util/AndroidTimeUtils.kt b/lib/src/main/kotlin/at/bitfire/synctools/util/AndroidTimeUtils.kt index bc3393fd..3caff1b3 100644 --- a/lib/src/main/kotlin/at/bitfire/synctools/util/AndroidTimeUtils.kt +++ b/lib/src/main/kotlin/at/bitfire/synctools/util/AndroidTimeUtils.kt @@ -21,6 +21,7 @@ import net.fortuna.ical4j.model.parameter.TzId import net.fortuna.ical4j.model.parameter.Value import net.fortuna.ical4j.model.property.DateListProperty import net.fortuna.ical4j.model.property.RDate +import net.fortuna.ical4j.util.TimeZones import java.time.Duration import java.time.Instant import java.time.LocalDate @@ -159,7 +160,11 @@ object AndroidTimeUtils { val limiter = dbStr.indexOf(RECURRENCE_LIST_TZID_SEPARATOR) if (limiter != -1) { // TZID given val tzId = dbStr.take(limiter) - zoneId = ZoneId.of(tzId).takeIf { it != ZoneOffset.UTC } + zoneId = if (isUtcTzId(tzId)) { + ZoneOffset.UTC + } else { + ZoneId.of(tzId) + } datesStr = dbStr.substring(limiter + 1) } else { zoneId = null @@ -170,30 +175,7 @@ object AndroidTimeUtils { val dates = datesStr .splitToSequence(RECURRENCE_LIST_VALUE_SEPARATOR) .map { dateString -> - if (zoneId == null) { - if (dateString.contains('T')) { - val instant = TemporalAdapter.parse(dateString, CalendarDateFormat.UTC_DATE_TIME_FORMAT).temporal - if (allDay) { - instant.toLocalDate() - } else { - instant - } - } else { - val localDate = TemporalAdapter.parse(dateString, CalendarDateFormat.DATE_FORMAT).temporal - if (allDay) { - localDate - } else { - localDate.atStartOfDay(ZoneOffset.UTC).toInstant() - } - } - } else { - val localDateTime = TemporalAdapter.parse(dateString, CalendarDateFormat.FLOATING_DATE_TIME_FORMAT).temporal - if (allDay) { - localDateTime.toLocalDate() - } else { - localDateTime.atZone(zoneId) - } - } + parseDateString(dateString, zoneId, allDay) } .filterNot { date -> // filter excluded date @@ -223,6 +205,62 @@ object AndroidTimeUtils { return dateListProperty } + private fun parseDateString(dateString: String, zoneId: ZoneId?, allDay: Boolean): Temporal { + val isUtcFormat = dateString.endsWith('Z') + val isDateTimeFormat = dateString.contains('T') + + return when { + isUtcFormat -> { + val instant = parseUtcDateTime(dateString) + if (allDay) { + instant.toLocalDate() + } else { + instant + } + } + isDateTimeFormat -> { + val localDateTime = parseDateTime(dateString) + val isUtc = zoneId == ZoneOffset.UTC + + when { + allDay -> localDateTime.toLocalDate() + isUtc -> localDateTime.toInstant(ZoneOffset.UTC) + zoneId != null -> localDateTime.atZone(zoneId) + else -> error("Floating DATE-TIME is not supported: $dateString") + } + } + else -> { + val localDate = parseDate(dateString) + if (allDay) { + localDate + } else { + localDate.atStartOfDay(ZoneOffset.UTC).toInstant() + } + } + } + } + + private fun parseUtcDateTime(dateString: String): Instant { + return TemporalAdapter.parse( + dateString, + CalendarDateFormat.UTC_DATE_TIME_FORMAT + ).temporal + } + + private fun parseDate(dateString: String): LocalDate { + return TemporalAdapter.parse( + dateString, + CalendarDateFormat.DATE_FORMAT + ).temporal + } + + private fun parseDateTime(dateString: String): LocalDateTime { + return TemporalAdapter.parse( + dateString, + CalendarDateFormat.FLOATING_DATE_TIME_FORMAT + ).temporal + } + /** * Concatenates, if necessary, multiple RDATE/EXDATE lists and converts them to * a formatted string which OpenTasks can process. @@ -332,4 +370,7 @@ object AndroidTimeUtils { return TemporalAmountAdapter.parse(durationStr).duration } + fun isUtcTzId(tzId: String?): Boolean { + return tzId == TZID_UTC || tzId == TimeZones.UTC_ID || tzId == TimeZones.IBM_UTC_ID + } } \ No newline at end of file diff --git a/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/AndroidEventHandlerTest.kt b/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/AndroidEventHandlerTest.kt index a0cffbf1..1ee5ec89 100644 --- a/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/AndroidEventHandlerTest.kt +++ b/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/AndroidEventHandlerTest.kt @@ -15,6 +15,7 @@ import at.bitfire.synctools.icalendar.dtStart import at.bitfire.synctools.icalendar.recurrenceId import at.bitfire.synctools.storage.calendar.EventAndExceptions import at.bitfire.synctools.storage.calendar.EventsContract +import at.bitfire.synctools.util.AndroidTimeUtils import net.fortuna.ical4j.model.Property import net.fortuna.ical4j.model.TimeZoneRegistryFactory import net.fortuna.ical4j.model.property.DtStart @@ -30,6 +31,7 @@ import org.junit.Assert.assertTrue import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner +import java.time.Instant import java.time.ZonedDateTime import java.time.temporal.Temporal import kotlin.jvm.optionals.getOrNull @@ -146,6 +148,40 @@ class AndroidEventHandlerTest { assertTrue(result.exceptions.isEmpty()) } + @Test + fun `mapToVEvents rewrites cancelled exception using UTC to EXDATE`() { + val result = handler.mapToVEvents( + eventAndExceptions = EventAndExceptions( + main = Entity(contentValuesOf( + Events.TITLE to "Recurring all-day event with cancelled exception", + Events.DTSTART to 1594056600000L, + Events.EVENT_TIMEZONE to "UTC", + Events.ALL_DAY to 0, + Events.RRULE to "FREQ=DAILY;COUNT=10" + )), + exceptions = listOf( + Entity(contentValuesOf( + Events.ORIGINAL_INSTANCE_TIME to 1594143000000L, + Events.ORIGINAL_ALL_DAY to 0, + Events.DTSTART to 1594143000000L, + Events.ALL_DAY to 0, + Events.EVENT_TIMEZONE to "UTC", + Events.STATUS to Events.STATUS_CANCELED + )) + ) + ) + ).associatedEvents + val main = result.main!! + assertEquals("Recurring all-day event with cancelled exception", main.summary.value) + assertEquals(DtStart(dateTimeValue("20200706T173000Z")), main.dtStart()) + assertEquals("FREQ=DAILY;COUNT=10", main.getProperty>(Property.RRULE).getOrNull()?.value) + assertEquals( + ExDate("20200707T173000Z"), + main.getRequiredProperty>(Property.EXDATE) + ) + assertTrue(result.exceptions.isEmpty()) + } + @Test fun `mapToVEvents ignores cancelled exception without RECURRENCE-ID`() { val result = handler.mapToVEvents( diff --git a/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/handler/AndroidTimeFieldTest.kt b/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/handler/AndroidTimeFieldTest.kt index acdf6eaf..b9735fba 100644 --- a/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/handler/AndroidTimeFieldTest.kt +++ b/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/handler/AndroidTimeFieldTest.kt @@ -15,6 +15,7 @@ import net.fortuna.ical4j.util.TimeZones import org.junit.Assert.assertEquals import org.junit.Rule import org.junit.Test +import java.time.Instant import java.time.ZoneOffset class AndroidTimeFieldTest { @@ -54,29 +55,29 @@ class AndroidTimeFieldTest { } @Test - fun `toTemporal with Android UTC timezone ID returns UTC ZonedDateTime`() { + fun `toTemporal with Android UTC timezone ID returns Instant`() { val androidTimeField = AndroidTimeField( - timestamp = 1760521619000, // Wed Oct 15 2025 09:46:59 GMT+0000 + timestamp = 1760521619000, timeZone = AndroidTimeUtils.TZID_UTC, allDay = false, ) val result = androidTimeField.toTemporal() - assertEquals(dateTimeValue("20251015T094659", ZoneOffset.UTC), result) + assertEquals(Instant.ofEpochMilli(1760521619000), result) } @Test - fun `toTemporal with JVM UTC timezone ID returns UTC ZonedDateTime`() { + fun `toTemporal with JVM UTC timezone ID returns Instant`() { val androidTimeField = AndroidTimeField( - timestamp = 1760521619000, // Wed Oct 15 2025 09:46:59 GMT+0000 + timestamp = 1760521619000, timeZone = TimeZones.UTC_ID, allDay = false, ) val result = androidTimeField.toTemporal() - assertEquals(dateTimeValue("20251015T094659", ZoneOffset.UTC), result) + assertEquals(Instant.ofEpochMilli(1760521619000), result) } @Test 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 index 0e6511f3..2b993fdf 100644 --- 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 @@ -17,6 +17,8 @@ import org.junit.Assert.assertNull import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner +import java.time.Duration +import java.time.Instant import java.time.LocalDate import java.time.ZoneId import java.time.ZonedDateTime @@ -147,6 +149,25 @@ class DurationHandlerTest { assertNull(result.duration) } + @Test + fun `Non-all-day event with UTC time zone`() { + val result = VEvent() + val entity = Entity(contentValuesOf( + Events.ALL_DAY to 0, + Events.DTSTART to 1761433200000L, + Events.EVENT_TIMEZONE to "UTC", + Events.DURATION to "PT1H" + )) + + handler.process(entity, entity, result) + + assertEquals( + DtEnd(Instant.ofEpochMilli(1761433200000L) + Duration.ofHours(1)), + result.dtEnd() + ) + assertNull(result.duration) + } + // skip conditions 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 862201f2..7f132fdf 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 @@ -22,6 +22,7 @@ import org.junit.Assume import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner +import java.time.Instant import java.time.LocalDate import java.time.LocalDateTime import java.time.OffsetDateTime @@ -81,6 +82,22 @@ class EndTimeHandlerTest { assertEquals(DtEnd(viennaDateTime), result.dtEnd()) } + @Test + fun `Non-all-day event with UTC end timezone`() { + val result = VEvent() + val entity = Entity(contentValuesOf( + Events.ALL_DAY to 0, + Events.DTSTART to 1592733500000L, // DTSTART is required for DTEND to be processed + Events.EVENT_TIMEZONE to "UTC", + Events.DTEND to 1592733600000L, // 21/06/2020 10:00 +0000 + Events.EVENT_END_TIMEZONE to "UTC" + )) + + handler.process(entity, entity, result) + + assertEquals(DtEnd(dateTimeValue("20200621T100000Z")), result.dtEnd()) + } + @Test fun `Non-all-day event without end timezone`() { val result = VEvent() diff --git a/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/handler/OriginalInstanceTimeHandlerTest.kt b/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/handler/OriginalInstanceTimeHandlerTest.kt index 159f5930..dc283bdf 100644 --- a/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/handler/OriginalInstanceTimeHandlerTest.kt +++ b/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/handler/OriginalInstanceTimeHandlerTest.kt @@ -19,6 +19,7 @@ import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner +import java.time.Instant import java.time.LocalDate import java.time.ZoneId import java.time.ZonedDateTime @@ -102,4 +103,21 @@ class OriginalInstanceTimeHandlerTest { assertEquals(RecurrenceId(defaultTzDateTime), result.recurrenceId) } + @Test + fun `Original event is using UTC time zone`() { + val result = VEvent() + val from = Entity(contentValuesOf( + Events.ORIGINAL_INSTANCE_TIME to 1758550428000L, + Events.ORIGINAL_ALL_DAY to 0, + Events.EVENT_TIMEZONE to "UTC" + )) + val main = Entity(contentValuesOf( + Events.EVENT_TIMEZONE to "UTC" + )) + + handler.process(from, main, result) + + assertEquals(RecurrenceId(Instant.ofEpochMilli(1758550428000L)), result.recurrenceId) + } + } \ No newline at end of file diff --git a/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/handler/RecurrenceFieldHandlerTest.kt b/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/handler/RecurrenceFieldHandlerTest.kt index 387a6a01..e638b324 100644 --- a/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/handler/RecurrenceFieldHandlerTest.kt +++ b/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/handler/RecurrenceFieldHandlerTest.kt @@ -164,6 +164,23 @@ class RecurrenceFieldHandlerTest { assertNull(result.getProperty>(Property.EXRULE).getOrNull()) } + @Test + fun `EXDATE with explicit UTC timezone`() { + val result = VEvent() + val entity = Entity(contentValuesOf( + Events.DTSTART to 1759403653000, // Thu Oct 02 2025 11:14:13 GMT+0000, + Events.RRULE to "FREQ=DAILY;COUNT=10", + Events.EXDATE to "UTC;20251003T111413" + )) + + handler.process(entity, entity, result) + + assertEquals( + ExDate("20251003T111413Z"), + result.getRequiredProperty>(Property.EXDATE) + ) + } + @Test fun `alignUntil(recurUntil=null)`() { diff --git a/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/handler/StartTimeHandlerTest.kt b/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/handler/StartTimeHandlerTest.kt index 3a88a783..adbad131 100644 --- a/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/handler/StartTimeHandlerTest.kt +++ b/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/handler/StartTimeHandlerTest.kt @@ -10,6 +10,7 @@ import android.content.ContentValues import android.content.Entity import android.provider.CalendarContract.Events import androidx.core.content.contentValuesOf +import at.bitfire.dateTimeValue import at.bitfire.synctools.exception.InvalidLocalResourceException import at.bitfire.synctools.icalendar.dtStart import at.bitfire.synctools.util.AndroidTimeUtils @@ -19,6 +20,7 @@ import net.fortuna.ical4j.model.property.DtStart import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner +import java.time.Instant import java.time.LocalDate import java.time.ZoneId import java.time.ZonedDateTime @@ -52,7 +54,20 @@ class StartTimeHandlerTest { )) handler.process(entity, entity, result) val viennaDateTime = ZonedDateTime.of(2020, 6, 21, 12, 0, 0, 0, tzVienna) - assertEquals(DtStart(viennaDateTime), result.dtStart()) + assertEquals(DtStart(viennaDateTime), result.dtStart()) + } + + @Test + fun `Non-all-day event with UTC timezone`() { + val result = VEvent() + val entity = Entity(contentValuesOf( + Events.DTSTART to 1592733600000L, // 21/06/2020 10:00 +0000 + Events.EVENT_TIMEZONE to "UTC" + )) + + handler.process(entity, entity, result) + + assertEquals(DtStart(dateTimeValue("20200621T100000Z")), result.dtStart()) } @Test(expected = InvalidLocalResourceException::class) diff --git a/lib/src/test/kotlin/at/bitfire/synctools/util/AndroidTimeUtilsTest.kt b/lib/src/test/kotlin/at/bitfire/synctools/util/AndroidTimeUtilsTest.kt index 43c8b6aa..64161891 100644 --- a/lib/src/test/kotlin/at/bitfire/synctools/util/AndroidTimeUtilsTest.kt +++ b/lib/src/test/kotlin/at/bitfire/synctools/util/AndroidTimeUtilsTest.kt @@ -26,6 +26,7 @@ import net.fortuna.ical4j.model.property.ExDate import net.fortuna.ical4j.model.property.RDate import org.junit.Assert.assertEquals import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue import org.junit.Assert.fail import org.junit.Rule import org.junit.Test @@ -94,6 +95,22 @@ class AndroidTimeUtilsTest { assertEquals(dateTimeValue("20150704T113040", tzToronto), exDate.dates[1]) } + @Test + fun testAndroidStringToRecurrenceSets_with_explicit_UTC_timezone() { + val dbStr = "UTC;20150103T113030,20150704T113040" + + val exDate = AndroidTimeUtils.androidStringToRecurrenceSet( + dbStr, + allDay = false, + generator = exDateGenerator, + )!! + + assertTrue(exDate.getParameter(Parameter.TZID).isEmpty) + assertEquals(2, exDate.dates.size) + assertEquals(dateTimeValue("20150103T113030Z"), exDate.dates[0]) + assertEquals(dateTimeValue("20150704T113040Z"), exDate.dates[1]) + } + @Test fun testAndroidStringToRecurrenceSets_Dates() { // list of dates @@ -123,13 +140,17 @@ class AndroidTimeUtilsTest { assertEquals(dateTimeValue("20150105T113030", tzToronto), exDate.dates[0]) } - @Test(expected = DateTimeParseException::class) + @Test fun testAndroidStringToRecurrenceSets_throws_DateTimeParseException() { - AndroidTimeUtils.androidStringToRecurrenceSet( - "20150103T113030", - allDay = false, - generator = exDateGenerator - ) + try { + AndroidTimeUtils.androidStringToRecurrenceSet( + "20150103T113030", + allDay = false, + generator = exDateGenerator + ) + } catch (e: IllegalStateException) { + assertEquals("Floating DATE-TIME is not supported: 20150103T113030", e.message) + } } @Test(expected = DateTimeException::class)