From ddc1d358a8e8916cbf92115013536be245231a1e Mon Sep 17 00:00:00 2001 From: Arnau Mora Gras Date: Mon, 25 May 2026 16:56:46 +0200 Subject: [PATCH 1/3] Add failing test --- .../icalendar/ICalendarGeneratorTest.kt | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/lib/src/test/kotlin/at/bitfire/synctools/icalendar/ICalendarGeneratorTest.kt b/lib/src/test/kotlin/at/bitfire/synctools/icalendar/ICalendarGeneratorTest.kt index 56be7778a..ea4c4c2a6 100644 --- a/lib/src/test/kotlin/at/bitfire/synctools/icalendar/ICalendarGeneratorTest.kt +++ b/lib/src/test/kotlin/at/bitfire/synctools/icalendar/ICalendarGeneratorTest.kt @@ -248,4 +248,27 @@ class ICalendarGeneratorTest { assertTrue(result.isEmpty()) } + @Test + fun `Write event with UTC Instant exception DTSTART does not throw`() { + val iCal = StringWriter() + writer.write(AssociatedEvents( + main = VEvent(propertyListOf( + Uid("UTCTEST"), + DtStart(ZonedDateTime.of(LocalDateTime.parse("2025-08-22T05:30:00"), tzBerlin)), + DtStamp("20250822T000000Z"), + RRule("FREQ=DAILY;COUNT=3") + )), + exceptions = listOf( + VEvent(propertyListOf( + Uid("UTCTEST"), + RecurrenceId(Instant.parse("2025-08-22T03:30:00Z")), + DtStart(Instant.parse("2025-08-22T03:30:00Z")), + DtStamp("20250822T000000Z") + )) + ), + prodId = userAgent + ), iCal) + assertTrue(iCal.toString().contains("BEGIN:VCALENDAR")) + } + } \ No newline at end of file From 33bdb4e99cc12dde2880de34d65c02ea74b55112 Mon Sep 17 00:00:00 2001 From: Arnau Mora Gras Date: Mon, 25 May 2026 16:56:53 +0200 Subject: [PATCH 2/3] Fix parsing issue --- .../bitfire/synctools/icalendar/ICalendarGenerator.kt | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/lib/src/main/kotlin/at/bitfire/synctools/icalendar/ICalendarGenerator.kt b/lib/src/main/kotlin/at/bitfire/synctools/icalendar/ICalendarGenerator.kt index 094669a5b..5ff076d89 100644 --- a/lib/src/main/kotlin/at/bitfire/synctools/icalendar/ICalendarGenerator.kt +++ b/lib/src/main/kotlin/at/bitfire/synctools/icalendar/ICalendarGenerator.kt @@ -23,6 +23,9 @@ import net.fortuna.ical4j.model.parameter.TzId import net.fortuna.ical4j.model.property.DateProperty import net.fortuna.ical4j.model.property.immutable.ImmutableVersion import java.io.Writer +import java.time.Instant +import java.time.ZoneOffset +import java.time.ZonedDateTime import java.time.temporal.Temporal import java.util.logging.Logger import javax.annotation.WillNotClose @@ -68,7 +71,9 @@ class ICalendarGenerator { ical += exception exception.dtStart()?.date?.let { start -> - if (earliestStart == null || TemporalAdapter.isBefore(start, earliestStart)) + val normalizedStart = start.normalizeForComparison() + val normalizedEarliest = earliestStart?.normalizeForComparison() + if (normalizedEarliest == null || TemporalAdapter.isBefore(normalizedStart, normalizedEarliest)) earliestStart = start } usedTimezoneIds += timeZonesOf(exception) @@ -107,6 +112,9 @@ class ICalendarGenerator { CalendarOutputter(false).output(ical, to) } + private fun Temporal.normalizeForComparison(): ZonedDateTime = + if (this is Instant) atZone(ZoneOffset.UTC) else ZonedDateTime.from(this) + /** * Creates a one-level deep copy of the given [VTimeZone] instance. * From 71d2e79252580bacfbd17c14ca7f27524e2223bb Mon Sep 17 00:00:00 2001 From: Arnau Mora Gras Date: Mon, 25 May 2026 17:06:44 +0200 Subject: [PATCH 3/3] Use `Temporal.toInstant` --- .../synctools/icalendar/ICalendarGenerator.kt | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/lib/src/main/kotlin/at/bitfire/synctools/icalendar/ICalendarGenerator.kt b/lib/src/main/kotlin/at/bitfire/synctools/icalendar/ICalendarGenerator.kt index 5ff076d89..528686582 100644 --- a/lib/src/main/kotlin/at/bitfire/synctools/icalendar/ICalendarGenerator.kt +++ b/lib/src/main/kotlin/at/bitfire/synctools/icalendar/ICalendarGenerator.kt @@ -7,6 +7,7 @@ package at.bitfire.synctools.icalendar import androidx.annotation.VisibleForTesting +import at.bitfire.synctools.util.AndroidTimeUtils.toInstant import at.bitfire.synctools.util.Utils.trimToNull import net.fortuna.ical4j.data.CalendarOutputter import net.fortuna.ical4j.model.Calendar @@ -15,7 +16,6 @@ import net.fortuna.ical4j.model.ComponentList import net.fortuna.ical4j.model.Parameter import net.fortuna.ical4j.model.PropertyContainer import net.fortuna.ical4j.model.PropertyList -import net.fortuna.ical4j.model.TemporalAdapter import net.fortuna.ical4j.model.TimeZoneRegistryFactory import net.fortuna.ical4j.model.component.VEvent import net.fortuna.ical4j.model.component.VTimeZone @@ -23,9 +23,6 @@ import net.fortuna.ical4j.model.parameter.TzId import net.fortuna.ical4j.model.property.DateProperty import net.fortuna.ical4j.model.property.immutable.ImmutableVersion import java.io.Writer -import java.time.Instant -import java.time.ZoneOffset -import java.time.ZonedDateTime import java.time.temporal.Temporal import java.util.logging.Logger import javax.annotation.WillNotClose @@ -71,9 +68,9 @@ class ICalendarGenerator { ical += exception exception.dtStart()?.date?.let { start -> - val normalizedStart = start.normalizeForComparison() - val normalizedEarliest = earliestStart?.normalizeForComparison() - if (normalizedEarliest == null || TemporalAdapter.isBefore(normalizedStart, normalizedEarliest)) + val startInstant = runCatching { start.toInstant() }.getOrNull() ?: return@let + val earliestInstant = earliestStart?.let { runCatching { it.toInstant() }.getOrNull() } + if (earliestInstant == null || startInstant < earliestInstant) earliestStart = start } usedTimezoneIds += timeZonesOf(exception) @@ -112,9 +109,6 @@ class ICalendarGenerator { CalendarOutputter(false).output(ical, to) } - private fun Temporal.normalizeForComparison(): ZonedDateTime = - if (this is Instant) atZone(ZoneOffset.UTC) else ZonedDateTime.from(this) - /** * Creates a one-level deep copy of the given [VTimeZone] instance. *