From e2463b07d88ec9a6942d5727277d70ebe35c4b7a Mon Sep 17 00:00:00 2001 From: cketti Date: Thu, 16 Apr 2026 17:51:37 +0200 Subject: [PATCH 1/7] Change `AndroidTimeField` to emit `Instant` when using UTC as time zone This will generate e.g. `DTSTART:20260416T180000Z` instead of `DTSTART;TZID=Z:20260416T180000Z` --- .../calendar/handler/AndroidTimeField.kt | 20 +++++++++---------- .../calendar/handler/AndroidTimeFieldTest.kt | 13 ++++++------ .../calendar/handler/EndTimeHandlerTest.kt | 17 ++++++++++++++++ .../calendar/handler/StartTimeHandlerTest.kt | 17 +++++++++++++++- 4 files changed, 50 insertions(+), 17 deletions(-) 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 19aad8799..590adff69 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 @@ -49,16 +49,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 (tzId == AndroidTimeUtils.TZID_UTC || tzId == TimeZones.UTC_ID || tzId == TimeZones.IBM_UTC_ID) { + 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/test/kotlin/at/bitfire/synctools/mapping/calendar/handler/AndroidTimeFieldTest.kt b/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/handler/AndroidTimeFieldTest.kt index acdf6eaf0..b9735fbac 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/EndTimeHandlerTest.kt b/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/handler/EndTimeHandlerTest.kt index 862201f2d..7f132fdf7 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/StartTimeHandlerTest.kt b/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/handler/StartTimeHandlerTest.kt index 3a88a783c..adbad1316 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) From c013b2ac9708ed8dca04a840942431f4588a0eaa Mon Sep 17 00:00:00 2001 From: cketti Date: Thu, 16 Apr 2026 18:21:05 +0200 Subject: [PATCH 2/7] Extract code to parse date strings to separate methods --- .../synctools/util/AndroidTimeUtils.kt | 73 +++++++++++++------ 1 file changed, 49 insertions(+), 24 deletions(-) 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 bc3393fdf..592b7d874 100644 --- a/lib/src/main/kotlin/at/bitfire/synctools/util/AndroidTimeUtils.kt +++ b/lib/src/main/kotlin/at/bitfire/synctools/util/AndroidTimeUtils.kt @@ -170,30 +170,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 +200,54 @@ object AndroidTimeUtils { return dateListProperty } + private fun parseDateString(dateString: String, zoneId: ZoneId?, allDay: Boolean): Temporal { + return if (zoneId == null) { + if (dateString.contains('T')) { + val instant = parseUtcDateTime(dateString) + if (allDay) { + instant.toLocalDate() + } else { + instant + } + } else { + val localDate = parseDate(dateString) + if (allDay) { + localDate + } else { + localDate.atStartOfDay(ZoneOffset.UTC).toInstant() + } + } + } else { + val localDateTime = parseDateTime(dateString) + if (allDay) { + localDateTime.toLocalDate() + } else { + localDateTime.atZone(zoneId) + } + } + } + + 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. From cdbd67814ad1abfd15f20e1304e36d574e32743b Mon Sep 17 00:00:00 2001 From: cketti Date: Thu, 16 Apr 2026 18:26:23 +0200 Subject: [PATCH 3/7] Add helper to check if TZID is UTC --- .../synctools/mapping/calendar/handler/AndroidTimeField.kt | 3 ++- .../main/kotlin/at/bitfire/synctools/util/AndroidTimeUtils.kt | 4 ++++ 2 files changed, 6 insertions(+), 1 deletion(-) 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 590adff69..46a214a06 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,7 +50,7 @@ class AndroidTimeField( val tzId = timeZone ?: ZoneId.systemDefault().id // safe fallback (should never be used/needed because the calendar provider requires EVENT_TIMEZONE) - if (tzId == AndroidTimeUtils.TZID_UTC || tzId == TimeZones.UTC_ID || tzId == TimeZones.IBM_UTC_ID) { + if (isUtcTzId(tzId)) { return instant } 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 592b7d874..a034131e0 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 @@ -357,4 +358,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 From 5db7003befeba8918392fa7ea8e0ac78d1c0cdb5 Mon Sep 17 00:00:00 2001 From: cketti Date: Thu, 16 Apr 2026 19:00:18 +0200 Subject: [PATCH 4/7] =?UTF-8?q?Update=20`AndroidTimeUtils.androidStringToR?= =?UTF-8?q?ecurrenceSet()`=20to=20properly=20support=20`UTC;=E2=80=A6`=20v?= =?UTF-8?q?alues?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../synctools/util/AndroidTimeUtils.kt | 34 +++++++++++++------ .../handler/RecurrenceFieldHandlerTest.kt | 17 ++++++++++ .../synctools/util/AndroidTimeUtilsTest.kt | 33 ++++++++++++++---- 3 files changed, 67 insertions(+), 17 deletions(-) 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 a034131e0..64810efc6 100644 --- a/lib/src/main/kotlin/at/bitfire/synctools/util/AndroidTimeUtils.kt +++ b/lib/src/main/kotlin/at/bitfire/synctools/util/AndroidTimeUtils.kt @@ -160,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 @@ -202,15 +206,30 @@ object AndroidTimeUtils { } private fun parseDateString(dateString: String, zoneId: ZoneId?, allDay: Boolean): Temporal { - return if (zoneId == null) { - if (dateString.contains('T')) { + val isUtcFormat = dateString.endsWith('Z') + val isDateTimeFormat = dateString.contains('T') + + return when { + isUtcFormat -> { val instant = parseUtcDateTime(dateString) if (allDay) { instant.toLocalDate() } else { instant } - } else { + } + 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 @@ -218,13 +237,6 @@ object AndroidTimeUtils { localDate.atStartOfDay(ZoneOffset.UTC).toInstant() } } - } else { - val localDateTime = parseDateTime(dateString) - if (allDay) { - localDateTime.toLocalDate() - } else { - localDateTime.atZone(zoneId) - } } } 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 387a6a01c..e638b3245 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/util/AndroidTimeUtilsTest.kt b/lib/src/test/kotlin/at/bitfire/synctools/util/AndroidTimeUtilsTest.kt index 43c8b6aab..64161891c 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) From 81c8252453a89db2885efff3b7d660dd4e5ca63b Mon Sep 17 00:00:00 2001 From: cketti Date: Thu, 16 Apr 2026 19:50:54 +0200 Subject: [PATCH 5/7] Add support for UTC to `OriginalInstanceTimeHandler` --- .../handler/OriginalInstanceTimeHandler.kt | 19 ++++++++++++------- .../synctools/util/AndroidTimeUtils.kt | 2 +- .../OriginalInstanceTimeHandlerTest.kt | 18 ++++++++++++++++++ 3 files changed, 31 insertions(+), 8 deletions(-) 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 dffd642c8..ce1ddb93f 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 64810efc6..3caff1b35 100644 --- a/lib/src/main/kotlin/at/bitfire/synctools/util/AndroidTimeUtils.kt +++ b/lib/src/main/kotlin/at/bitfire/synctools/util/AndroidTimeUtils.kt @@ -370,7 +370,7 @@ object AndroidTimeUtils { return TemporalAmountAdapter.parse(durationStr).duration } - fun isUtcTzId(tzId: String): Boolean { + 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/handler/OriginalInstanceTimeHandlerTest.kt b/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/handler/OriginalInstanceTimeHandlerTest.kt index 159f5930e..dc283bdf5 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 From 0b507a0d494927606010dab9a010237ed0bcb138 Mon Sep 17 00:00:00 2001 From: cketti Date: Thu, 16 Apr 2026 20:00:17 +0200 Subject: [PATCH 6/7] Add support for UTC to `AndroidEventHandler.asExDate()` --- .../mapping/calendar/AndroidEventHandler.kt | 3 ++ .../calendar/AndroidEventHandlerTest.kt | 36 +++++++++++++++++++ 2 files changed, 39 insertions(+) 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 8a59e2600..492d6da78 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/test/kotlin/at/bitfire/synctools/mapping/calendar/AndroidEventHandlerTest.kt b/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/AndroidEventHandlerTest.kt index a0cffbf1a..1ee5ec89b 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( From 54b311d1e6b7e229efb92d6098cfe2b4efec6383 Mon Sep 17 00:00:00 2001 From: cketti Date: Thu, 16 Apr 2026 20:11:46 +0200 Subject: [PATCH 7/7] Add support for UTC to `DurationHandler` --- .../calendar/handler/DurationHandler.kt | 11 +++++++--- .../calendar/handler/DurationHandlerTest.kt | 21 +++++++++++++++++++ 2 files changed, 29 insertions(+), 3 deletions(-) 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 d274aa911..5d60e4cbc 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/test/kotlin/at/bitfire/synctools/mapping/calendar/handler/DurationHandlerTest.kt b/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/handler/DurationHandlerTest.kt index 0e6511f3f..2b993fdfc 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