From 1d0d812d4090ef5f372b694ea0cd1821eced212c Mon Sep 17 00:00:00 2001 From: cketti Date: Thu, 7 May 2026 18:47:13 +0200 Subject: [PATCH 01/13] [Refactor] Extract `ICalendar.vAlarmToMin` to `AlarmTriggerCalculator` --- .../at/bitfire/ical4android/ICalendar.kt | 109 -------- .../calendar/builder/RemindersBuilder.kt | 6 +- .../mapping/tasks/DmfsTaskBuilder.kt | 4 +- .../synctools/util/AlarmTriggerCalculator.kt | 121 +++++++++ .../at/bitfire/ical4android/ICalendarTest.kt | 225 ---------------- .../util/AlarmTriggerCalculatorTest.kt | 240 ++++++++++++++++++ 6 files changed, 365 insertions(+), 340 deletions(-) create mode 100644 lib/src/main/kotlin/at/bitfire/synctools/util/AlarmTriggerCalculator.kt create mode 100644 lib/src/test/kotlin/at/bitfire/synctools/util/AlarmTriggerCalculatorTest.kt diff --git a/lib/src/main/kotlin/at/bitfire/ical4android/ICalendar.kt b/lib/src/main/kotlin/at/bitfire/ical4android/ICalendar.kt index 1e0ab4982..161d7caf4 100644 --- a/lib/src/main/kotlin/at/bitfire/ical4android/ICalendar.kt +++ b/lib/src/main/kotlin/at/bitfire/ical4android/ICalendar.kt @@ -11,27 +11,16 @@ import at.bitfire.synctools.BuildConfig import at.bitfire.synctools.exception.InvalidICalendarException import at.bitfire.synctools.icalendar.ICalendarParser import at.bitfire.synctools.icalendar.validation.ICalPreprocessor -import at.bitfire.synctools.util.AndroidTimeUtils.toInstant import net.fortuna.ical4j.data.CalendarBuilder import net.fortuna.ical4j.data.ParserException import net.fortuna.ical4j.model.Calendar -import net.fortuna.ical4j.model.Parameter import net.fortuna.ical4j.model.Property -import net.fortuna.ical4j.model.component.VAlarm import net.fortuna.ical4j.model.component.VTimeZone -import net.fortuna.ical4j.model.parameter.Related import net.fortuna.ical4j.model.property.Color -import net.fortuna.ical4j.model.property.DateProperty -import net.fortuna.ical4j.model.property.DtStart import net.fortuna.ical4j.model.property.ProdId -import net.fortuna.ical4j.model.property.Trigger import net.fortuna.ical4j.validate.ValidationException import java.io.Reader import java.io.StringReader -import java.time.Duration -import java.time.Instant -import java.time.Period -import java.time.temporal.Temporal import java.util.LinkedList import java.util.UUID import java.util.logging.Level @@ -156,104 +145,6 @@ open class ICalendar { } } - - // misc. iCalendar helpers - - /** - * Calculates the minutes before/after an event/task to know when a given alarm occurs. - * - * @param alarm the alarm to calculate the minutes from - * @param refStart reference `DTSTART` from the calendar component - * @param refEnd reference `DTEND` (`VEVENT`) or `DUE` (`VTODO`) from the calendar component - * @param allowRelEnd *true*: caller accepts minutes related to the end; - * *false*: caller only accepts minutes related to the start - * - * Android's alarm granularity is minutes. This methods calculates with milliseconds, but the result - * is rounded down to minutes (seconds cut off). - * - * @return Pair of values: - * - * 1. whether the minutes are related to the start or end (always [Related.START] if [allowRelEnd] is *false*) - * 2. number of minutes before start/end (negative value means number of minutes *after* start/end) - * - * May be *null* if there's not enough information to calculate the number of minutes. - */ - fun vAlarmToMin( - alarm: VAlarm, - refStart: DtStart<*>?, - refEnd: DateProperty<*>?, - refDuration: net.fortuna.ical4j.model.property.Duration?, - allowRelEnd: Boolean - ): Pair? { - val trigger = alarm.getProperty(Property.TRIGGER).getOrNull() ?: return null - - // Note: big method – maybe split? - - val minutes: Int // minutes before/after the event - var related: Related = trigger.getParameter(Parameter.RELATED).getOrNull() ?: Related.START - - // event/task start/end time - val start = refStart?.date?.toInstant() - var end = refEnd?.date?.toInstant() - - // event/task end time - if (end == null && start != null) - end = when (val refDur = refDuration?.duration) { - is Duration -> start + refDur - is Period -> start + Duration.between(start, start + refDur) - else -> null - } - - // event/task duration - val duration: Duration? = - if (start != null && end != null) - Duration.between(start, end) - else - null - - val triggerDur = trigger.duration - val triggerTime = trigger.date - - if (triggerDur != null) { - // TRIGGER value is a DURATION. Important: - // 1) Negative values in TRIGGER mean positive values in Reminders.MINUTES and vice versa. - // 2) Android doesn't know alarm seconds, but only minutes. Cut off seconds from the final result. - // 3) DURATION can be a Duration (time-based) or a Period (date-based), which have to be treated differently. - var millisBefore = - when (triggerDur) { - is Duration -> -triggerDur.toMillis() - is Period -> { - // TODO: Take time zones into account (will probably be possible with ical4j 4.x). - // For instance, an alarm one day before the DST change should be 23/25 hours before the event. - -Duration.ofDays(triggerDur.days.toLong()).toMillis() // months and years are not used in DURATION values; weeks are calculated to days - } - else -> throw AssertionError("triggerDur must be Duration or Period") - } - - if (related == Related.END && !allowRelEnd) { - if (duration == null) { - logger.warning("Event/task without duration; can't calculate END-related alarm") - return null - } - // move alarm towards end - related = Related.START - millisBefore -= duration.toMillis() - } - minutes = (millisBefore / 60000).toInt() - - } else if (triggerTime != null && start != null) { - // TRIGGER value is a DATE-TIME, calculate minutes from start time - related = Related.START - minutes = Duration.between(triggerTime, start).toMinutes().toInt() - - } else { - logger.log(Level.WARNING, "VALARM TRIGGER type is not DURATION or DATE-TIME (requires event DTSTART for Android), ignoring alarm", alarm) - return null - } - - return Pair(related, minutes) - } - } diff --git a/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/builder/RemindersBuilder.kt b/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/builder/RemindersBuilder.kt index bc0cd168a..e6f8b0c93 100644 --- a/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/builder/RemindersBuilder.kt +++ b/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/builder/RemindersBuilder.kt @@ -10,15 +10,13 @@ import android.content.ContentValues import android.content.Entity import android.provider.CalendarContract.Reminders import androidx.core.content.contentValuesOf -import at.bitfire.ical4android.ICalendar import at.bitfire.synctools.icalendar.dtStart +import at.bitfire.synctools.util.AlarmTriggerCalculator import net.fortuna.ical4j.model.Property import net.fortuna.ical4j.model.component.VAlarm import net.fortuna.ical4j.model.component.VEvent import net.fortuna.ical4j.model.property.Action -import net.fortuna.ical4j.model.property.DtEnd import java.time.temporal.Temporal -import java.util.Locale import kotlin.jvm.optionals.getOrNull class RemindersBuilder: AndroidEntityBuilder { @@ -39,7 +37,7 @@ class RemindersBuilder: AndroidEntityBuilder { else -> Reminders.METHOD_DEFAULT // won't trigger an alarm on the Android device } - val minutes = ICalendar.vAlarmToMin( + val minutes = AlarmTriggerCalculator.vAlarmToMin( alarm = alarm, refStart = event.dtStart(), refEnd = event.getEndDate().getOrNull(), diff --git a/lib/src/main/kotlin/at/bitfire/synctools/mapping/tasks/DmfsTaskBuilder.kt b/lib/src/main/kotlin/at/bitfire/synctools/mapping/tasks/DmfsTaskBuilder.kt index 5a059b3c2..90a28c816 100644 --- a/lib/src/main/kotlin/at/bitfire/synctools/mapping/tasks/DmfsTaskBuilder.kt +++ b/lib/src/main/kotlin/at/bitfire/synctools/mapping/tasks/DmfsTaskBuilder.kt @@ -6,7 +6,6 @@ package at.bitfire.synctools.mapping.tasks -import at.bitfire.ical4android.ICalendar import at.bitfire.ical4android.Task import at.bitfire.ical4android.UnknownProperty import at.bitfire.synctools.icalendar.DatePropertyTzMapper.normalizedDate @@ -16,6 +15,7 @@ import at.bitfire.synctools.storage.tasks.DmfsTask.Companion.COLUMN_FLAGS import at.bitfire.synctools.storage.tasks.DmfsTask.Companion.UNKNOWN_PROPERTY_DATA import at.bitfire.synctools.storage.tasks.DmfsTaskList import at.bitfire.synctools.storage.tasks.TasksBatchOperation +import at.bitfire.synctools.util.AlarmTriggerCalculator import at.bitfire.synctools.util.AndroidTimeUtils import at.bitfire.synctools.util.AndroidTimeUtils.toTimestamp import net.fortuna.ical4j.model.Parameter @@ -208,7 +208,7 @@ class DmfsTaskBuilder( private fun insertAlarms(batch: TasksBatchOperation, idxTask: Int?) { for (alarm in task.alarms) { - val (alarmRef, minutes) = ICalendar.vAlarmToMin( + val (alarmRef, minutes) = AlarmTriggerCalculator.vAlarmToMin( alarm = alarm, refStart = task.dtStart, refEnd = task.due, diff --git a/lib/src/main/kotlin/at/bitfire/synctools/util/AlarmTriggerCalculator.kt b/lib/src/main/kotlin/at/bitfire/synctools/util/AlarmTriggerCalculator.kt new file mode 100644 index 000000000..daf15ac5e --- /dev/null +++ b/lib/src/main/kotlin/at/bitfire/synctools/util/AlarmTriggerCalculator.kt @@ -0,0 +1,121 @@ +/* + * 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.util + +import at.bitfire.synctools.util.AndroidTimeUtils.toInstant +import net.fortuna.ical4j.model.Parameter +import net.fortuna.ical4j.model.Property +import net.fortuna.ical4j.model.component.VAlarm +import net.fortuna.ical4j.model.parameter.Related +import net.fortuna.ical4j.model.property.DateProperty +import net.fortuna.ical4j.model.property.DtStart +import net.fortuna.ical4j.model.property.Trigger +import java.time.Duration +import java.time.Period +import java.util.logging.Level +import java.util.logging.Logger +import kotlin.jvm.optionals.getOrNull +import net.fortuna.ical4j.model.property.Duration as ICalDuration + +object AlarmTriggerCalculator { + private val logger = Logger.getLogger(javaClass.name) + + /** + * Calculates the minutes before/after an event/task to know when a given alarm occurs. + * + * Note: Android's alarm granularity is minutes. This method calculates with milliseconds, but + * the result is rounded down to minutes (seconds cut off). + * + * @param alarm the alarm to calculate the minutes from + * @param refStart reference `DTSTART` from the calendar component + * @param refEnd reference `DTEND` (`VEVENT`) or `DUE` (`VTODO`) from the calendar component + * @param allowRelEnd *true*: caller accepts minutes related to the end; + * *false*: caller only accepts minutes related to the start + * + * @return Pair of values: + * + * 1. whether the minutes are related to the start or end (always [Related.START] if [allowRelEnd] is *false*) + * 2. number of minutes before start/end (negative value means number of minutes *after* start/end) + * + * May be *null* if there's not enough information to calculate the number of minutes. + */ + fun vAlarmToMin( + alarm: VAlarm, + refStart: DtStart<*>?, + refEnd: DateProperty<*>?, + refDuration: ICalDuration?, + allowRelEnd: Boolean + ): Pair? { + val trigger = alarm.getProperty(Property.TRIGGER).getOrNull() ?: return null + + // Note: big method – maybe split? + + val minutes: Int // minutes before/after the event + var related: Related = trigger.getParameter(Parameter.RELATED).getOrNull() ?: Related.START + + // event/task start/end time + val start = refStart?.date?.toInstant() + var end = refEnd?.date?.toInstant() + + // event/task end time + if (end == null && start != null) + end = when (val refDur = refDuration?.duration) { + is Duration -> start + refDur + is Period -> start + Duration.between(start, start + refDur) + else -> null + } + + // event/task duration + val duration: Duration? = + if (start != null && end != null) + Duration.between(start, end) + else + null + + val triggerDur = trigger.duration + val triggerTime = trigger.date + + if (triggerDur != null) { + // TRIGGER value is a DURATION. Important: + // 1) Negative values in TRIGGER mean positive values in Reminders.MINUTES and vice versa. + // 2) Android doesn't know alarm seconds, but only minutes. Cut off seconds from the final result. + // 3) DURATION can be a Duration (time-based) or a Period (date-based), which have to be treated differently. + var millisBefore = + when (triggerDur) { + is Duration -> -triggerDur.toMillis() + is Period -> { + // TODO: Take time zones into account (will probably be possible with ical4j 4.x). + // For instance, an alarm one day before the DST change should be 23/25 hours before the event. + -Duration.ofDays(triggerDur.days.toLong()).toMillis() // months and years are not used in DURATION values; weeks are calculated to days + } + else -> throw AssertionError("triggerDur must be Duration or Period") + } + + if (related == Related.END && !allowRelEnd) { + if (duration == null) { + logger.warning("Event/task without duration; can't calculate END-related alarm") + return null + } + // move alarm towards end + related = Related.START + millisBefore -= duration.toMillis() + } + minutes = (millisBefore / 60000).toInt() + + } else if (triggerTime != null && start != null) { + // TRIGGER value is a DATE-TIME, calculate minutes from start time + related = Related.START + minutes = Duration.between(triggerTime, start).toMinutes().toInt() + + } else { + logger.log(Level.WARNING, "VALARM TRIGGER type is not DURATION or DATE-TIME (requires event DTSTART for Android), ignoring alarm", alarm) + return null + } + + return Pair(related, minutes) + } +} \ No newline at end of file diff --git a/lib/src/test/kotlin/at/bitfire/ical4android/ICalendarTest.kt b/lib/src/test/kotlin/at/bitfire/ical4android/ICalendarTest.kt index d5f70a58c..a2d3520ec 100644 --- a/lib/src/test/kotlin/at/bitfire/ical4android/ICalendarTest.kt +++ b/lib/src/test/kotlin/at/bitfire/ical4android/ICalendarTest.kt @@ -6,35 +6,17 @@ package at.bitfire.ical4android -import at.bitfire.dateTimeValue -import at.bitfire.dateValue import net.fortuna.ical4j.model.Property -import net.fortuna.ical4j.model.Property.TRIGGER -import net.fortuna.ical4j.model.component.VAlarm -import net.fortuna.ical4j.model.parameter.Related import net.fortuna.ical4j.model.property.Color -import net.fortuna.ical4j.model.property.DtEnd -import net.fortuna.ical4j.model.property.DtStart -import net.fortuna.ical4j.model.property.Due -import net.fortuna.ical4j.model.property.Duration -import net.fortuna.ical4j.model.property.Trigger import org.junit.Assert.assertEquals import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull import org.junit.Test import java.io.StringReader -import java.time.Period -import java.time.ZoneOffset -import java.time.ZonedDateTime -import java.time.temporal.Temporal import kotlin.jvm.optionals.getOrNull class ICalendarTest { - // current time stamp - private val currentTime = ZonedDateTime.now() - - @Test fun testFromReader_calendarProperties() { val calendar = ICalendar.fromReader( @@ -122,211 +104,4 @@ class ICalendarTest { ) } - - @Test - fun testVAlarmToMin_TriggerDuration_Negative() { - // TRIGGER;REL=START:-P1DT1H1M29S - val (ref, min) = ICalendar.vAlarmToMin( - VAlarm(Duration("-P1DT1H1M29S").duration), - DtStart(), null, null, false - )!! - assertEquals(Related.START, ref) - assertEquals(60 * 24 + 60 + 1, min) - } - - @Test - fun testVAlarmToMin_TriggerDuration_OnlySeconds() { - // TRIGGER;REL=START:-PT3600S - val (ref, min) = ICalendar.vAlarmToMin( - VAlarm(Duration("-PT3600S").duration), - DtStart(), null, null, false - )!! - assertEquals(Related.START, ref) - assertEquals(60, min) - } - - @Test - fun testVAlarmToMin_TriggerDuration_Positive() { - // TRIGGER;REL=START:P1DT1H1M30S (alarm *after* start) - val (ref, min) = ICalendar.vAlarmToMin( - VAlarm(Duration("P1DT1H1M30S").duration), - DtStart(), null, null, false - )!! - assertEquals(Related.START, ref) - assertEquals(-(60 * 24 + 60 + 1), min) - } - - @Test - fun testVAlarmToMin_TriggerDuration_RelEndAllowed() { - // TRIGGER;REL=END:-P1DT1H1M30S (caller accepts Related.END) - val alarm = VAlarm(Duration("-P1DT1H1M30S").duration) - alarm.getProperty(TRIGGER).getOrNull()?.add(Related.END) - val (ref, min) = ICalendar.vAlarmToMin(alarm, DtStart(), null, null, true)!! - assertEquals(Related.END, ref) - assertEquals(60 * 24 + 60 + 1, min) - } - - @Test - fun testVAlarmToMin_TriggerDuration_RelEndNotAllowed() { - // event with TRIGGER;REL=END:-PT30S (caller doesn't accept Related.END) - val alarm = VAlarm(Duration("-PT65S").duration) - alarm.getProperty(TRIGGER).getOrNull()?.add(Related.END) - val (ref, min) = ICalendar.vAlarmToMin( - alarm, - DtStart(currentTime), - DtEnd(currentTime.plusSeconds(180)), // 180 sec later - null, - false - )!! - assertEquals(Related.START, ref) - // duration of event: 180 s (3 min), 65 s before that -> alarm 1:55 min before start - assertEquals(-1, min) - } - - @Test - fun testVAlarmToMin_TriggerDuration_RelEndNotAllowed_NoDtStart() { - // event with TRIGGER;REL=END:-PT30S (caller doesn't accept Related.END) - val alarm = VAlarm(Duration("-PT65S").duration) - alarm.getProperty(TRIGGER).getOrNull()?.add(Related.END) - assertNull(ICalendar.vAlarmToMin(alarm, DtStart(), DtEnd(currentTime), null, false)) - } - - @Test - fun testVAlarmToMin_TriggerDuration_RelEndNotAllowed_NoDuration() { - // event with TRIGGER;REL=END:-PT30S (caller doesn't accept Related.END) - val alarm = VAlarm(Duration("-PT65S").duration) - alarm.getProperty(TRIGGER).getOrNull()?.add(Related.END) - assertNull(ICalendar.vAlarmToMin(alarm, DtStart(currentTime), null, null, false)) - } - - @Test - fun testVAlarmToMin_TriggerDuration_RelEndNotAllowed_AfterEnd() { - // task with TRIGGER;REL=END:-P1DT1H1M30S (caller doesn't accept Related.END; alarm *after* end) - val alarm = VAlarm(Duration("P1DT1H1M30S").duration) - alarm.getProperty(TRIGGER).getOrNull()?.add(Related.END) - val (ref, min) = ICalendar.vAlarmToMin( - alarm, - DtStart(currentTime), - Due(currentTime.plusSeconds(90)), // 90 sec (should be rounded down to 1 min) later - null, - false - )!! - assertEquals(Related.START, ref) - assertEquals(-(60 * 24 + 60 + 1 + 1) /* duration of event: */ - 1, min) - } - - @Test - fun testVAlarm_TriggerPeriod() { - val (ref, min) = ICalendar.vAlarmToMin( - VAlarm(Period.parse("-P1W1D")), - DtStart(currentTime), null, null, - false - )!! - assertEquals(Related.START, ref) - assertEquals(8 * 24 * 60, min) - } - - @Test - fun testVAlarm_TriggerAbsoluteValue() { - // TRIGGER;VALUE=DATE-TIME: - val alarm = VAlarm(currentTime.minusSeconds(89).toInstant()) // 89 sec (should be cut off to 1 min) before event - alarm.getProperty(TRIGGER).getOrNull()?.add(Related.END) // not useful for DATE-TIME values, should be ignored - val (ref, min) = ICalendar.vAlarmToMin(alarm, DtStart(currentTime), null, null, false)!! - assertEquals(Related.START, ref) - assertEquals(1, min) - } - - @Test - fun `vAlarmToMin with trigger duration, DtStart is DATE, Duration is java_time_Duration`() { - val alarm = VAlarm(Duration("-PT5M").duration) - val dtStart = DtStart(dateValue("20260407")) - val duration = Duration("PT1H") - - val (ref, min) = ICalendar.vAlarmToMin( - alarm = alarm, - refStart = dtStart, - refEnd = null, - refDuration = duration, - allowRelEnd = true - )!! - - assertEquals(Related.START, ref) - assertEquals(5, min) - } - - @Test - fun `vAlarmToMin with trigger duration, DtStart is DATE, Duration is java_time_Period`() { - val alarm = VAlarm(Duration("-PT5M").duration) - val dtStart = DtStart(dateValue("20260407")) - val duration = Duration("P1D") - - val (ref, min) = ICalendar.vAlarmToMin( - alarm = alarm, - refStart = dtStart, - refEnd = null, - refDuration = duration, - allowRelEnd = true - )!! - - assertEquals(Related.START, ref) - assertEquals(5, min) - } - - @Test - fun `vAlarmToMin with trigger duration and Related=END, DtStart and DtEnd are DATE, allowRelEnd=false`() { - val alarm = VAlarm(Duration("-PT5M").duration).apply { - getRequiredProperty(TRIGGER).add(Related.END) - } - val dtStart = DtStart(dateValue("20260407")) - val dtEnd = DtStart(dateValue("20260408")) - - val (ref, min) = ICalendar.vAlarmToMin( - alarm = alarm, - refStart = dtStart, - refEnd = dtEnd, - refDuration = null, - allowRelEnd = false - )!! - - assertEquals(Related.START, ref) - assertEquals(-(24 * 60 - 5), min) - } - - @Test - fun `vAlarmToMin with DATE-TIME trigger, DtStart is DATE`() { - val alarm = VAlarm(dateTimeValue("20260406T120000", ZoneOffset.UTC).toInstant()) - val dtStart = DtStart(dateValue("20260407")) - - val (ref, min) = ICalendar.vAlarmToMin( - alarm = alarm, - refStart = dtStart, - refEnd = null, - refDuration = null, - allowRelEnd = true - )!! - - assertEquals(Related.START, ref) - assertEquals(12 * 60, min) - } - - // TODO Note: can we use the following now when we have ical4j 4.x? - - /* - DOES NOT WORK YET! Will work as soon as Java 8 API is consequently used in ical4j and ical4android. - - @Test - fun testVAlarm_TriggerPeriod_CrossingDST() { - // Event start: 2020/04/01 01:00 Vienna, alarm: one day before start of the event - // DST changes on 2020/03/29 02:00 -> 03:00, so there is one hour less! - // The alarm has to be set 23 hours before the event so that it is set one day earlier. - val event = Event() - event.dtStart = DtStart("20200401T010000", tzVienna) - val (ref, min) = ICalendar.vAlarmToMin( - VAlarm(Period.parse("-P1W1D")), - event, false - )!! - assertEquals(Related.START, ref) - assertEquals(8*24*60, min) - }*/ - } \ No newline at end of file diff --git a/lib/src/test/kotlin/at/bitfire/synctools/util/AlarmTriggerCalculatorTest.kt b/lib/src/test/kotlin/at/bitfire/synctools/util/AlarmTriggerCalculatorTest.kt new file mode 100644 index 000000000..93da23678 --- /dev/null +++ b/lib/src/test/kotlin/at/bitfire/synctools/util/AlarmTriggerCalculatorTest.kt @@ -0,0 +1,240 @@ +/* + * 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.util + +import at.bitfire.dateTimeValue +import at.bitfire.dateValue +import at.bitfire.synctools.util.AlarmTriggerCalculator.vAlarmToMin +import net.fortuna.ical4j.model.Property.TRIGGER +import net.fortuna.ical4j.model.component.VAlarm +import net.fortuna.ical4j.model.parameter.Related +import net.fortuna.ical4j.model.property.DtEnd +import net.fortuna.ical4j.model.property.DtStart +import net.fortuna.ical4j.model.property.Due +import net.fortuna.ical4j.model.property.Duration +import net.fortuna.ical4j.model.property.Trigger +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import java.time.Period +import java.time.ZoneOffset +import java.time.ZonedDateTime +import java.time.temporal.Temporal +import kotlin.jvm.optionals.getOrNull + +class AlarmTriggerCalculatorTest { + + // current time stamp + private val currentTime = ZonedDateTime.now() + + @Test + fun testVAlarmToMin_TriggerDuration_Negative() { + // TRIGGER;REL=START:-P1DT1H1M29S + val (ref, min) = vAlarmToMin( + VAlarm(Duration("-P1DT1H1M29S").duration), + DtStart(), null, null, false + )!! + assertEquals(Related.START, ref) + assertEquals(60 * 24 + 60 + 1, min) + } + + @Test + fun testVAlarmToMin_TriggerDuration_OnlySeconds() { + // TRIGGER;REL=START:-PT3600S + val (ref, min) = vAlarmToMin( + VAlarm(Duration("-PT3600S").duration), + DtStart(), null, null, false + )!! + assertEquals(Related.START, ref) + assertEquals(60, min) + } + + @Test + fun testVAlarmToMin_TriggerDuration_Positive() { + // TRIGGER;REL=START:P1DT1H1M30S (alarm *after* start) + val (ref, min) = vAlarmToMin( + VAlarm(Duration("P1DT1H1M30S").duration), + DtStart(), null, null, false + )!! + assertEquals(Related.START, ref) + assertEquals(-(60 * 24 + 60 + 1), min) + } + + @Test + fun testVAlarmToMin_TriggerDuration_RelEndAllowed() { + // TRIGGER;REL=END:-P1DT1H1M30S (caller accepts Related.END) + val alarm = VAlarm(Duration("-P1DT1H1M30S").duration) + alarm.getProperty(TRIGGER).getOrNull()?.add(Related.END) + val (ref, min) = vAlarmToMin(alarm, DtStart(), null, null, true)!! + assertEquals(Related.END, ref) + assertEquals(60 * 24 + 60 + 1, min) + } + + @Test + fun testVAlarmToMin_TriggerDuration_RelEndNotAllowed() { + // event with TRIGGER;REL=END:-PT30S (caller doesn't accept Related.END) + val alarm = VAlarm(Duration("-PT65S").duration) + alarm.getProperty(TRIGGER).getOrNull()?.add(Related.END) + val (ref, min) = vAlarmToMin( + alarm, + DtStart(currentTime), + DtEnd(currentTime.plusSeconds(180)), // 180 sec later + null, + false + )!! + assertEquals(Related.START, ref) + // duration of event: 180 s (3 min), 65 s before that -> alarm 1:55 min before start + assertEquals(-1, min) + } + + @Test + fun testVAlarmToMin_TriggerDuration_RelEndNotAllowed_NoDtStart() { + // event with TRIGGER;REL=END:-PT30S (caller doesn't accept Related.END) + val alarm = VAlarm(Duration("-PT65S").duration) + alarm.getProperty(TRIGGER).getOrNull()?.add(Related.END) + assertNull(vAlarmToMin(alarm, DtStart(), DtEnd(currentTime), null, false)) + } + + @Test + fun testVAlarmToMin_TriggerDuration_RelEndNotAllowed_NoDuration() { + // event with TRIGGER;REL=END:-PT30S (caller doesn't accept Related.END) + val alarm = VAlarm(Duration("-PT65S").duration) + alarm.getProperty(TRIGGER).getOrNull()?.add(Related.END) + assertNull(vAlarmToMin(alarm, DtStart(currentTime), null, null, false)) + } + + @Test + fun testVAlarmToMin_TriggerDuration_RelEndNotAllowed_AfterEnd() { + // task with TRIGGER;REL=END:-P1DT1H1M30S (caller doesn't accept Related.END; alarm *after* end) + val alarm = VAlarm(Duration("P1DT1H1M30S").duration) + alarm.getProperty(TRIGGER).getOrNull()?.add(Related.END) + val (ref, min) = vAlarmToMin( + alarm, + DtStart(currentTime), + Due(currentTime.plusSeconds(90)), // 90 sec (should be rounded down to 1 min) later + null, + false + )!! + assertEquals(Related.START, ref) + assertEquals(-(60 * 24 + 60 + 1 + 1) /* duration of event: */ - 1, min) + } + + @Test + fun testVAlarm_TriggerPeriod() { + val (ref, min) = vAlarmToMin( + VAlarm(Period.parse("-P1W1D")), + DtStart(currentTime), null, null, + false + )!! + assertEquals(Related.START, ref) + assertEquals(8 * 24 * 60, min) + } + + @Test + fun testVAlarm_TriggerAbsoluteValue() { + // TRIGGER;VALUE=DATE-TIME: + val alarm = VAlarm(currentTime.minusSeconds(89).toInstant()) // 89 sec (should be cut off to 1 min) before event + alarm.getProperty(TRIGGER).getOrNull()?.add(Related.END) // not useful for DATE-TIME values, should be ignored + val (ref, min) = vAlarmToMin(alarm, DtStart(currentTime), null, null, false)!! + assertEquals(Related.START, ref) + assertEquals(1, min) + } + + @Test + fun `vAlarmToMin with trigger duration, DtStart is DATE, Duration is java_time_Duration`() { + val alarm = VAlarm(Duration("-PT5M").duration) + val dtStart = DtStart(dateValue("20260407")) + val duration = Duration("PT1H") + + val (ref, min) = vAlarmToMin( + alarm = alarm, + refStart = dtStart, + refEnd = null, + refDuration = duration, + allowRelEnd = true + )!! + + assertEquals(Related.START, ref) + assertEquals(5, min) + } + + @Test + fun `vAlarmToMin with trigger duration, DtStart is DATE, Duration is java_time_Period`() { + val alarm = VAlarm(Duration("-PT5M").duration) + val dtStart = DtStart(dateValue("20260407")) + val duration = Duration("P1D") + + val (ref, min) = vAlarmToMin( + alarm = alarm, + refStart = dtStart, + refEnd = null, + refDuration = duration, + allowRelEnd = true + )!! + + assertEquals(Related.START, ref) + assertEquals(5, min) + } + + @Test + fun `vAlarmToMin with trigger duration and Related=END, DtStart and DtEnd are DATE, allowRelEnd=false`() { + val alarm = VAlarm(Duration("-PT5M").duration).apply { + getRequiredProperty(TRIGGER).add(Related.END) + } + val dtStart = DtStart(dateValue("20260407")) + val dtEnd = DtStart(dateValue("20260408")) + + val (ref, min) = vAlarmToMin( + alarm = alarm, + refStart = dtStart, + refEnd = dtEnd, + refDuration = null, + allowRelEnd = false + )!! + + assertEquals(Related.START, ref) + assertEquals(-(24 * 60 - 5), min) + } + + @Test + fun `vAlarmToMin with DATE-TIME trigger, DtStart is DATE`() { + val alarm = VAlarm(dateTimeValue("20260406T120000", ZoneOffset.UTC).toInstant()) + val dtStart = DtStart(dateValue("20260407")) + + val (ref, min) = vAlarmToMin( + alarm = alarm, + refStart = dtStart, + refEnd = null, + refDuration = null, + allowRelEnd = true + )!! + + assertEquals(Related.START, ref) + assertEquals(12 * 60, min) + } + + // TODO Note: can we use the following now when we have ical4j 4.x? + + /* + DOES NOT WORK YET! Will work as soon as Java 8 API is consequently used in ical4j and ical4android. + + @Test + fun testVAlarm_TriggerPeriod_CrossingDST() { + // Event start: 2020/04/01 01:00 Vienna, alarm: one day before start of the event + // DST changes on 2020/03/29 02:00 -> 03:00, so there is one hour less! + // The alarm has to be set 23 hours before the event so that it is set one day earlier. + val event = Event() + event.dtStart = DtStart("20200401T010000", tzVienna) + val (ref, min) = ICalendar.vAlarmToMin( + VAlarm(Period.parse("-P1W1D")), + event, false + )!! + assertEquals(Related.START, ref) + assertEquals(8*24*60, min) + }*/ + +} \ No newline at end of file From c2bd917c200ca9e193ff46f167fc186d5a8f6489 Mon Sep 17 00:00:00 2001 From: cketti Date: Thu, 7 May 2026 19:04:10 +0200 Subject: [PATCH 02/13] [Refactor] Rename `vAlarmToMin` to `alarmTriggerToMinutes` --- .../calendar/builder/RemindersBuilder.kt | 2 +- .../mapping/tasks/DmfsTaskBuilder.kt | 2 +- .../synctools/util/AlarmTriggerCalculator.kt | 2 +- .../util/AlarmTriggerCalculatorTest.kt | 30 +++++++++---------- 4 files changed, 18 insertions(+), 18 deletions(-) diff --git a/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/builder/RemindersBuilder.kt b/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/builder/RemindersBuilder.kt index e6f8b0c93..ea7659ca5 100644 --- a/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/builder/RemindersBuilder.kt +++ b/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/builder/RemindersBuilder.kt @@ -37,7 +37,7 @@ class RemindersBuilder: AndroidEntityBuilder { else -> Reminders.METHOD_DEFAULT // won't trigger an alarm on the Android device } - val minutes = AlarmTriggerCalculator.vAlarmToMin( + val minutes = AlarmTriggerCalculator.alarmTriggerToMinutes( alarm = alarm, refStart = event.dtStart(), refEnd = event.getEndDate().getOrNull(), diff --git a/lib/src/main/kotlin/at/bitfire/synctools/mapping/tasks/DmfsTaskBuilder.kt b/lib/src/main/kotlin/at/bitfire/synctools/mapping/tasks/DmfsTaskBuilder.kt index 90a28c816..37aa0581b 100644 --- a/lib/src/main/kotlin/at/bitfire/synctools/mapping/tasks/DmfsTaskBuilder.kt +++ b/lib/src/main/kotlin/at/bitfire/synctools/mapping/tasks/DmfsTaskBuilder.kt @@ -208,7 +208,7 @@ class DmfsTaskBuilder( private fun insertAlarms(batch: TasksBatchOperation, idxTask: Int?) { for (alarm in task.alarms) { - val (alarmRef, minutes) = AlarmTriggerCalculator.vAlarmToMin( + val (alarmRef, minutes) = AlarmTriggerCalculator.alarmTriggerToMinutes( alarm = alarm, refStart = task.dtStart, refEnd = task.due, diff --git a/lib/src/main/kotlin/at/bitfire/synctools/util/AlarmTriggerCalculator.kt b/lib/src/main/kotlin/at/bitfire/synctools/util/AlarmTriggerCalculator.kt index daf15ac5e..7674ce712 100644 --- a/lib/src/main/kotlin/at/bitfire/synctools/util/AlarmTriggerCalculator.kt +++ b/lib/src/main/kotlin/at/bitfire/synctools/util/AlarmTriggerCalculator.kt @@ -43,7 +43,7 @@ object AlarmTriggerCalculator { * * May be *null* if there's not enough information to calculate the number of minutes. */ - fun vAlarmToMin( + fun alarmTriggerToMinutes( alarm: VAlarm, refStart: DtStart<*>?, refEnd: DateProperty<*>?, diff --git a/lib/src/test/kotlin/at/bitfire/synctools/util/AlarmTriggerCalculatorTest.kt b/lib/src/test/kotlin/at/bitfire/synctools/util/AlarmTriggerCalculatorTest.kt index 93da23678..976f953f9 100644 --- a/lib/src/test/kotlin/at/bitfire/synctools/util/AlarmTriggerCalculatorTest.kt +++ b/lib/src/test/kotlin/at/bitfire/synctools/util/AlarmTriggerCalculatorTest.kt @@ -8,7 +8,7 @@ package at.bitfire.synctools.util import at.bitfire.dateTimeValue import at.bitfire.dateValue -import at.bitfire.synctools.util.AlarmTriggerCalculator.vAlarmToMin +import at.bitfire.synctools.util.AlarmTriggerCalculator.alarmTriggerToMinutes import net.fortuna.ical4j.model.Property.TRIGGER import net.fortuna.ical4j.model.component.VAlarm import net.fortuna.ical4j.model.parameter.Related @@ -34,7 +34,7 @@ class AlarmTriggerCalculatorTest { @Test fun testVAlarmToMin_TriggerDuration_Negative() { // TRIGGER;REL=START:-P1DT1H1M29S - val (ref, min) = vAlarmToMin( + val (ref, min) = alarmTriggerToMinutes( VAlarm(Duration("-P1DT1H1M29S").duration), DtStart(), null, null, false )!! @@ -45,7 +45,7 @@ class AlarmTriggerCalculatorTest { @Test fun testVAlarmToMin_TriggerDuration_OnlySeconds() { // TRIGGER;REL=START:-PT3600S - val (ref, min) = vAlarmToMin( + val (ref, min) = alarmTriggerToMinutes( VAlarm(Duration("-PT3600S").duration), DtStart(), null, null, false )!! @@ -56,7 +56,7 @@ class AlarmTriggerCalculatorTest { @Test fun testVAlarmToMin_TriggerDuration_Positive() { // TRIGGER;REL=START:P1DT1H1M30S (alarm *after* start) - val (ref, min) = vAlarmToMin( + val (ref, min) = alarmTriggerToMinutes( VAlarm(Duration("P1DT1H1M30S").duration), DtStart(), null, null, false )!! @@ -69,7 +69,7 @@ class AlarmTriggerCalculatorTest { // TRIGGER;REL=END:-P1DT1H1M30S (caller accepts Related.END) val alarm = VAlarm(Duration("-P1DT1H1M30S").duration) alarm.getProperty(TRIGGER).getOrNull()?.add(Related.END) - val (ref, min) = vAlarmToMin(alarm, DtStart(), null, null, true)!! + val (ref, min) = alarmTriggerToMinutes(alarm, DtStart(), null, null, true)!! assertEquals(Related.END, ref) assertEquals(60 * 24 + 60 + 1, min) } @@ -79,7 +79,7 @@ class AlarmTriggerCalculatorTest { // event with TRIGGER;REL=END:-PT30S (caller doesn't accept Related.END) val alarm = VAlarm(Duration("-PT65S").duration) alarm.getProperty(TRIGGER).getOrNull()?.add(Related.END) - val (ref, min) = vAlarmToMin( + val (ref, min) = alarmTriggerToMinutes( alarm, DtStart(currentTime), DtEnd(currentTime.plusSeconds(180)), // 180 sec later @@ -96,7 +96,7 @@ class AlarmTriggerCalculatorTest { // event with TRIGGER;REL=END:-PT30S (caller doesn't accept Related.END) val alarm = VAlarm(Duration("-PT65S").duration) alarm.getProperty(TRIGGER).getOrNull()?.add(Related.END) - assertNull(vAlarmToMin(alarm, DtStart(), DtEnd(currentTime), null, false)) + assertNull(alarmTriggerToMinutes(alarm, DtStart(), DtEnd(currentTime), null, false)) } @Test @@ -104,7 +104,7 @@ class AlarmTriggerCalculatorTest { // event with TRIGGER;REL=END:-PT30S (caller doesn't accept Related.END) val alarm = VAlarm(Duration("-PT65S").duration) alarm.getProperty(TRIGGER).getOrNull()?.add(Related.END) - assertNull(vAlarmToMin(alarm, DtStart(currentTime), null, null, false)) + assertNull(alarmTriggerToMinutes(alarm, DtStart(currentTime), null, null, false)) } @Test @@ -112,7 +112,7 @@ class AlarmTriggerCalculatorTest { // task with TRIGGER;REL=END:-P1DT1H1M30S (caller doesn't accept Related.END; alarm *after* end) val alarm = VAlarm(Duration("P1DT1H1M30S").duration) alarm.getProperty(TRIGGER).getOrNull()?.add(Related.END) - val (ref, min) = vAlarmToMin( + val (ref, min) = alarmTriggerToMinutes( alarm, DtStart(currentTime), Due(currentTime.plusSeconds(90)), // 90 sec (should be rounded down to 1 min) later @@ -125,7 +125,7 @@ class AlarmTriggerCalculatorTest { @Test fun testVAlarm_TriggerPeriod() { - val (ref, min) = vAlarmToMin( + val (ref, min) = alarmTriggerToMinutes( VAlarm(Period.parse("-P1W1D")), DtStart(currentTime), null, null, false @@ -139,7 +139,7 @@ class AlarmTriggerCalculatorTest { // TRIGGER;VALUE=DATE-TIME: val alarm = VAlarm(currentTime.minusSeconds(89).toInstant()) // 89 sec (should be cut off to 1 min) before event alarm.getProperty(TRIGGER).getOrNull()?.add(Related.END) // not useful for DATE-TIME values, should be ignored - val (ref, min) = vAlarmToMin(alarm, DtStart(currentTime), null, null, false)!! + val (ref, min) = alarmTriggerToMinutes(alarm, DtStart(currentTime), null, null, false)!! assertEquals(Related.START, ref) assertEquals(1, min) } @@ -150,7 +150,7 @@ class AlarmTriggerCalculatorTest { val dtStart = DtStart(dateValue("20260407")) val duration = Duration("PT1H") - val (ref, min) = vAlarmToMin( + val (ref, min) = alarmTriggerToMinutes( alarm = alarm, refStart = dtStart, refEnd = null, @@ -168,7 +168,7 @@ class AlarmTriggerCalculatorTest { val dtStart = DtStart(dateValue("20260407")) val duration = Duration("P1D") - val (ref, min) = vAlarmToMin( + val (ref, min) = alarmTriggerToMinutes( alarm = alarm, refStart = dtStart, refEnd = null, @@ -188,7 +188,7 @@ class AlarmTriggerCalculatorTest { val dtStart = DtStart(dateValue("20260407")) val dtEnd = DtStart(dateValue("20260408")) - val (ref, min) = vAlarmToMin( + val (ref, min) = alarmTriggerToMinutes( alarm = alarm, refStart = dtStart, refEnd = dtEnd, @@ -205,7 +205,7 @@ class AlarmTriggerCalculatorTest { val alarm = VAlarm(dateTimeValue("20260406T120000", ZoneOffset.UTC).toInstant()) val dtStart = DtStart(dateValue("20260407")) - val (ref, min) = vAlarmToMin( + val (ref, min) = alarmTriggerToMinutes( alarm = alarm, refStart = dtStart, refEnd = null, From 1ea4d8514399c7e0eca3661e1593d31764b8701e Mon Sep 17 00:00:00 2001 From: cketti Date: Thu, 7 May 2026 19:57:29 +0200 Subject: [PATCH 03/13] [Refactor] Refactor `AlarmTriggerCalculatorTest` --- .../util/AlarmTriggerCalculatorTest.kt | 255 ++++++++++++------ 1 file changed, 175 insertions(+), 80 deletions(-) diff --git a/lib/src/test/kotlin/at/bitfire/synctools/util/AlarmTriggerCalculatorTest.kt b/lib/src/test/kotlin/at/bitfire/synctools/util/AlarmTriggerCalculatorTest.kt index 976f953f9..27349b8ad 100644 --- a/lib/src/test/kotlin/at/bitfire/synctools/util/AlarmTriggerCalculatorTest.kt +++ b/lib/src/test/kotlin/at/bitfire/synctools/util/AlarmTriggerCalculatorTest.kt @@ -24,7 +24,11 @@ import java.time.Period import java.time.ZoneOffset import java.time.ZonedDateTime import java.time.temporal.Temporal -import kotlin.jvm.optionals.getOrNull +import kotlin.time.Duration.Companion.days +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds +import java.time.Duration as JavaDuration class AlarmTriggerCalculatorTest { @@ -32,129 +36,217 @@ class AlarmTriggerCalculatorTest { private val currentTime = ZonedDateTime.now() @Test - fun testVAlarmToMin_TriggerDuration_Negative() { + fun `negative trigger duration`() { // TRIGGER;REL=START:-P1DT1H1M29S + val alarm = VAlarm(JavaDuration.parse("-P1DT1H1M29S")) + val refStart = DtStart() + val (ref, min) = alarmTriggerToMinutes( - VAlarm(Duration("-P1DT1H1M29S").duration), - DtStart(), null, null, false + alarm = alarm, + refStart = refStart, + refEnd = null, + refDuration = null, + allowRelEnd = false )!! + assertEquals(Related.START, ref) - assertEquals(60 * 24 + 60 + 1, min) + assertEquals((1.days + 1.hours + 1.minutes).toMinutes(), min) } @Test - fun testVAlarmToMin_TriggerDuration_OnlySeconds() { + fun `trigger duration in seconds`() { // TRIGGER;REL=START:-PT3600S + val alarm = VAlarm(JavaDuration.parse("-PT3600S")) + val refStart = DtStart() + val (ref, min) = alarmTriggerToMinutes( - VAlarm(Duration("-PT3600S").duration), - DtStart(), null, null, false + alarm = alarm, + refStart = refStart, + refEnd = null, + refDuration = null, + allowRelEnd = false )!! + assertEquals(Related.START, ref) - assertEquals(60, min) + assertEquals(3600.seconds.toMinutes(), min) } @Test - fun testVAlarmToMin_TriggerDuration_Positive() { + fun `positive trigger duration`() { // TRIGGER;REL=START:P1DT1H1M30S (alarm *after* start) + val alarm = VAlarm(JavaDuration.parse("P1DT1H1M30S")) + val refStart = DtStart() + val (ref, min) = alarmTriggerToMinutes( - VAlarm(Duration("P1DT1H1M30S").duration), - DtStart(), null, null, false + alarm = alarm, + refStart = refStart, + refEnd = null, + refDuration = null, + allowRelEnd = false )!! + assertEquals(Related.START, ref) - assertEquals(-(60 * 24 + 60 + 1), min) + assertEquals(-(1.days + 1.hours + 1.minutes).toMinutes(), min) } @Test - fun testVAlarmToMin_TriggerDuration_RelEndAllowed() { - // TRIGGER;REL=END:-P1DT1H1M30S (caller accepts Related.END) - val alarm = VAlarm(Duration("-P1DT1H1M30S").duration) - alarm.getProperty(TRIGGER).getOrNull()?.add(Related.END) - val (ref, min) = alarmTriggerToMinutes(alarm, DtStart(), null, null, true)!! + fun `trigger relative to end with allowRelEnd=true`() { + // TRIGGER;REL=END:-P1DT1H1M30S + val alarm = VAlarm(JavaDuration.parse("-P1DT1H1M30S")).apply { + getRequiredProperty(TRIGGER).add(Related.END) + } + val refStart = DtStart() + val allowRelEnd = true + + val (ref, min) = alarmTriggerToMinutes( + alarm = alarm, + refStart = refStart, + refEnd = null, + refDuration = null, + allowRelEnd = allowRelEnd + )!! + assertEquals(Related.END, ref) assertEquals(60 * 24 + 60 + 1, min) } @Test - fun testVAlarmToMin_TriggerDuration_RelEndNotAllowed() { - // event with TRIGGER;REL=END:-PT30S (caller doesn't accept Related.END) - val alarm = VAlarm(Duration("-PT65S").duration) - alarm.getProperty(TRIGGER).getOrNull()?.add(Related.END) + fun `trigger relative to end with allowRelEnd=false`() { + // TRIGGER;REL=END:-PT30S + val alarm = VAlarm(JavaDuration.parse("-PT65S")).apply { + getRequiredProperty(TRIGGER).add(Related.END) + } + val refStart = DtStart(currentTime) + val refEnd = DtEnd(currentTime.plusSeconds(180)) + val allowRelEnd = false + val (ref, min) = alarmTriggerToMinutes( - alarm, - DtStart(currentTime), - DtEnd(currentTime.plusSeconds(180)), // 180 sec later - null, - false + alarm = alarm, + refStart = refStart, + refEnd = refEnd, + refDuration = null, + allowRelEnd = allowRelEnd )!! + assertEquals(Related.START, ref) // duration of event: 180 s (3 min), 65 s before that -> alarm 1:55 min before start assertEquals(-1, min) } @Test - fun testVAlarmToMin_TriggerDuration_RelEndNotAllowed_NoDtStart() { - // event with TRIGGER;REL=END:-PT30S (caller doesn't accept Related.END) - val alarm = VAlarm(Duration("-PT65S").duration) - alarm.getProperty(TRIGGER).getOrNull()?.add(Related.END) - assertNull(alarmTriggerToMinutes(alarm, DtStart(), DtEnd(currentTime), null, false)) + fun `trigger relative to end without start time and with allowRelEnd=false`() { + // TRIGGER;REL=END:-PT30S + val alarm = VAlarm(JavaDuration.parse("-PT65S")).apply { + getRequiredProperty(TRIGGER).add(Related.END) + } + val refStart = DtStart() + val refEnd = DtEnd(currentTime) + val allowRelEnd = false + + val result = alarmTriggerToMinutes( + alarm = alarm, + refStart = refStart, + refEnd = refEnd, + refDuration = null, + allowRelEnd = allowRelEnd + ) + + assertNull(result) } @Test - fun testVAlarmToMin_TriggerDuration_RelEndNotAllowed_NoDuration() { - // event with TRIGGER;REL=END:-PT30S (caller doesn't accept Related.END) - val alarm = VAlarm(Duration("-PT65S").duration) - alarm.getProperty(TRIGGER).getOrNull()?.add(Related.END) - assertNull(alarmTriggerToMinutes(alarm, DtStart(currentTime), null, null, false)) + fun `trigger relative to end without end time or duration and with allowRelEnd=false`() { + // TRIGGER;REL=END:-PT30S + val alarm = VAlarm(JavaDuration.parse("-PT65S")).apply { + getRequiredProperty(TRIGGER).add(Related.END) + } + val refStart = DtStart(currentTime) + val allowRelEnd = false + + val result = alarmTriggerToMinutes( + alarm = alarm, + refStart = refStart, + refEnd = null, + refDuration = null, + allowRelEnd = allowRelEnd + ) + + assertNull(result) } @Test - fun testVAlarmToMin_TriggerDuration_RelEndNotAllowed_AfterEnd() { - // task with TRIGGER;REL=END:-P1DT1H1M30S (caller doesn't accept Related.END; alarm *after* end) - val alarm = VAlarm(Duration("P1DT1H1M30S").duration) - alarm.getProperty(TRIGGER).getOrNull()?.add(Related.END) + fun `trigger relative to end and after end date with allowRelEnd=false`() { + // TRIGGER;REL=END:-P1DT1H1M30S + val alarm = VAlarm(JavaDuration.parse("P1DT1H1M30S")).apply { + getRequiredProperty(TRIGGER).add(Related.END) + } + val refStart = DtStart(currentTime) + // 90 sec (should be rounded down to 1 min) later + val refEnd = Due(currentTime.plusSeconds(90)) + val allowRelEnd = false + val (ref, min) = alarmTriggerToMinutes( - alarm, - DtStart(currentTime), - Due(currentTime.plusSeconds(90)), // 90 sec (should be rounded down to 1 min) later - null, - false + alarm = alarm, + refStart = refStart, + refEnd = refEnd, + refDuration = null, + allowRelEnd = allowRelEnd )!! + assertEquals(Related.START, ref) - assertEquals(-(60 * 24 + 60 + 1 + 1) /* duration of event: */ - 1, min) + assertEquals(-(1.days.toMinutes() + 1.hours.toMinutes() + 1 + 1) /* duration of event: */ - 1, min) } @Test - fun testVAlarm_TriggerPeriod() { + fun `trigger with Period instance`() { + val alarm = VAlarm(Period.parse("-P1W1D")) + //FIXME: Use fixed date, otherwise test might fail close to DST changes + val refStart = DtStart(currentTime) + val (ref, min) = alarmTriggerToMinutes( - VAlarm(Period.parse("-P1W1D")), - DtStart(currentTime), null, null, - false + alarm = alarm, + refStart = refStart, + refEnd = null, + refDuration = null, + allowRelEnd = false )!! + assertEquals(Related.START, ref) - assertEquals(8 * 24 * 60, min) + assertEquals(8.days.toMinutes(), min) } @Test - fun testVAlarm_TriggerAbsoluteValue() { + fun `trigger with DATE-TIME value`() { // TRIGGER;VALUE=DATE-TIME: - val alarm = VAlarm(currentTime.minusSeconds(89).toInstant()) // 89 sec (should be cut off to 1 min) before event - alarm.getProperty(TRIGGER).getOrNull()?.add(Related.END) // not useful for DATE-TIME values, should be ignored - val (ref, min) = alarmTriggerToMinutes(alarm, DtStart(currentTime), null, null, false)!! + // 89 sec (should be cut off to 1 min) before event + val alarm = VAlarm(currentTime.minusSeconds(89).toInstant()).apply { + // not useful for DATE-TIME values, should be ignored + getRequiredProperty(TRIGGER).add(Related.END) + } + + val (ref, min) = alarmTriggerToMinutes( + alarm = alarm, + refStart = DtStart(currentTime), + refEnd = null, + refDuration = null, + allowRelEnd = true + )!! + assertEquals(Related.START, ref) assertEquals(1, min) } @Test - fun `vAlarmToMin with trigger duration, DtStart is DATE, Duration is java_time_Duration`() { - val alarm = VAlarm(Duration("-PT5M").duration) - val dtStart = DtStart(dateValue("20260407")) - val duration = Duration("PT1H") + fun `refStart has DATE value and refDuration is Duration`() { + val alarm = VAlarm(JavaDuration.parse("-PT5M")) + val refStart = DtStart(dateValue("20260407")) + val refDuration = Duration("PT1H") val (ref, min) = alarmTriggerToMinutes( alarm = alarm, - refStart = dtStart, + refStart = refStart, refEnd = null, - refDuration = duration, + refDuration = refDuration, allowRelEnd = true )!! @@ -163,16 +255,16 @@ class AlarmTriggerCalculatorTest { } @Test - fun `vAlarmToMin with trigger duration, DtStart is DATE, Duration is java_time_Period`() { - val alarm = VAlarm(Duration("-PT5M").duration) - val dtStart = DtStart(dateValue("20260407")) - val duration = Duration("P1D") + fun `refStart has DATE value and refDuration is Period`() { + val alarm = VAlarm(JavaDuration.parse("-PT5M")) + val refStart = DtStart(dateValue("20260407")) + val refDuration = Duration("P1D") val (ref, min) = alarmTriggerToMinutes( alarm = alarm, - refStart = dtStart, + refStart = refStart, refEnd = null, - refDuration = duration, + refDuration = refDuration, allowRelEnd = true )!! @@ -181,40 +273,41 @@ class AlarmTriggerCalculatorTest { } @Test - fun `vAlarmToMin with trigger duration and Related=END, DtStart and DtEnd are DATE, allowRelEnd=false`() { + fun `trigger related to end with refStart and refDate having DATE values and allowRelEnd=false`() { val alarm = VAlarm(Duration("-PT5M").duration).apply { getRequiredProperty(TRIGGER).add(Related.END) } - val dtStart = DtStart(dateValue("20260407")) - val dtEnd = DtStart(dateValue("20260408")) + val refStart = DtStart(dateValue("20260407")) + val refEnd = DtStart(dateValue("20260408")) + val allowRelEnd = false val (ref, min) = alarmTriggerToMinutes( alarm = alarm, - refStart = dtStart, - refEnd = dtEnd, + refStart = refStart, + refEnd = refEnd, refDuration = null, - allowRelEnd = false + allowRelEnd = allowRelEnd )!! assertEquals(Related.START, ref) - assertEquals(-(24 * 60 - 5), min) + assertEquals(-(1.days - 5.minutes).toMinutes(), min) } @Test - fun `vAlarmToMin with DATE-TIME trigger, DtStart is DATE`() { + fun `trigger with DATE-TIME vale and refStart with DATE value`() { val alarm = VAlarm(dateTimeValue("20260406T120000", ZoneOffset.UTC).toInstant()) - val dtStart = DtStart(dateValue("20260407")) + val refStart = DtStart(dateValue("20260407")) val (ref, min) = alarmTriggerToMinutes( alarm = alarm, - refStart = dtStart, + refStart = refStart, refEnd = null, refDuration = null, allowRelEnd = true )!! assertEquals(Related.START, ref) - assertEquals(12 * 60, min) + assertEquals(12.hours.toMinutes(), min) } // TODO Note: can we use the following now when we have ical4j 4.x? @@ -237,4 +330,6 @@ class AlarmTriggerCalculatorTest { assertEquals(8*24*60, min) }*/ -} \ No newline at end of file +} + +private fun kotlin.time.Duration.toMinutes(): Int = inWholeMinutes.toInt() From 7c9e93f4f4e284def9ee8fb5b692963186b0cf25 Mon Sep 17 00:00:00 2001 From: cketti Date: Fri, 8 May 2026 17:49:18 +0200 Subject: [PATCH 04/13] [Refactor] Move end date calculation out of `alarmTriggerToMinutes()` --- .../kotlin/at/bitfire/ical4android/Task.kt | 36 ++++ .../calendar/builder/RemindersBuilder.kt | 3 +- .../mapping/tasks/DmfsTaskBuilder.kt | 3 +- .../synctools/util/AlarmTriggerCalculator.kt | 12 +- .../at/bitfire/ical4android/TaskTest.kt | 170 ++++++++++++++++++ .../util/AlarmTriggerCalculatorTest.kt | 34 +--- 6 files changed, 210 insertions(+), 48 deletions(-) diff --git a/lib/src/main/kotlin/at/bitfire/ical4android/Task.kt b/lib/src/main/kotlin/at/bitfire/ical4android/Task.kt index 621bb1cfe..12bb02cce 100644 --- a/lib/src/main/kotlin/at/bitfire/ical4android/Task.kt +++ b/lib/src/main/kotlin/at/bitfire/ical4android/Task.kt @@ -8,6 +8,9 @@ package at.bitfire.ical4android import androidx.annotation.IntRange import at.bitfire.ical4android.util.DateUtils +import at.bitfire.synctools.icalendar.DatePropertyTzMapper.normalizedDate +import at.bitfire.synctools.util.AndroidTimeUtils.toInstant +import at.bitfire.synctools.util.AndroidTimeUtils.toZonedDateTime import net.fortuna.ical4j.model.Property import net.fortuna.ical4j.model.component.VAlarm import net.fortuna.ical4j.model.property.Clazz @@ -23,8 +26,13 @@ import net.fortuna.ical4j.model.property.RDate import net.fortuna.ical4j.model.property.RRule import net.fortuna.ical4j.model.property.RelatedTo import net.fortuna.ical4j.model.property.Status +import java.time.Instant +import java.time.ZonedDateTime import java.util.LinkedList +import java.time.Duration as JavaDuration +import java.time.Period as JavaPeriod + /** * Data class representing a task * @@ -75,4 +83,32 @@ data class Task( ?: true } + /** + * The "end date" of this task. + * + * Returns… + * - [due] if present, otherwise… + * - [Due] instance containing the end date as [Instant] calculated from [dtStart] and + * [duration] if both present, otherwise… + * - `null`. + */ + val end: Due<*>? + get() { + if (due != null) { + return due + } + + val start = dtStart?.date?.toInstant() + val duration = duration?.duration + if (start != null && duration != null) { + val end = when (duration) { + is JavaDuration -> start + duration + is JavaPeriod -> start + JavaDuration.between(start, start + duration) + else -> throw AssertionError("Expected either Duration or Period") + } + return Due(end) + } + + return null + } } diff --git a/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/builder/RemindersBuilder.kt b/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/builder/RemindersBuilder.kt index ea7659ca5..32dc1aace 100644 --- a/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/builder/RemindersBuilder.kt +++ b/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/builder/RemindersBuilder.kt @@ -40,8 +40,7 @@ class RemindersBuilder: AndroidEntityBuilder { val minutes = AlarmTriggerCalculator.alarmTriggerToMinutes( alarm = alarm, refStart = event.dtStart(), - refEnd = event.getEndDate().getOrNull(), - refDuration = event.duration, + refEnd = event.getEndDate(true).getOrNull(), allowRelEnd = false )?.second ?: Reminders.MINUTES_DEFAULT diff --git a/lib/src/main/kotlin/at/bitfire/synctools/mapping/tasks/DmfsTaskBuilder.kt b/lib/src/main/kotlin/at/bitfire/synctools/mapping/tasks/DmfsTaskBuilder.kt index 37aa0581b..e25e9d7fb 100644 --- a/lib/src/main/kotlin/at/bitfire/synctools/mapping/tasks/DmfsTaskBuilder.kt +++ b/lib/src/main/kotlin/at/bitfire/synctools/mapping/tasks/DmfsTaskBuilder.kt @@ -211,8 +211,7 @@ class DmfsTaskBuilder( val (alarmRef, minutes) = AlarmTriggerCalculator.alarmTriggerToMinutes( alarm = alarm, refStart = task.dtStart, - refEnd = task.due, - refDuration = task.duration, + refEnd = task.end, allowRelEnd = true ) ?: continue val ref = when (alarmRef) { diff --git a/lib/src/main/kotlin/at/bitfire/synctools/util/AlarmTriggerCalculator.kt b/lib/src/main/kotlin/at/bitfire/synctools/util/AlarmTriggerCalculator.kt index 7674ce712..dae267a2d 100644 --- a/lib/src/main/kotlin/at/bitfire/synctools/util/AlarmTriggerCalculator.kt +++ b/lib/src/main/kotlin/at/bitfire/synctools/util/AlarmTriggerCalculator.kt @@ -19,7 +19,6 @@ import java.time.Period import java.util.logging.Level import java.util.logging.Logger import kotlin.jvm.optionals.getOrNull -import net.fortuna.ical4j.model.property.Duration as ICalDuration object AlarmTriggerCalculator { private val logger = Logger.getLogger(javaClass.name) @@ -47,7 +46,6 @@ object AlarmTriggerCalculator { alarm: VAlarm, refStart: DtStart<*>?, refEnd: DateProperty<*>?, - refDuration: ICalDuration?, allowRelEnd: Boolean ): Pair? { val trigger = alarm.getProperty(Property.TRIGGER).getOrNull() ?: return null @@ -59,15 +57,7 @@ object AlarmTriggerCalculator { // event/task start/end time val start = refStart?.date?.toInstant() - var end = refEnd?.date?.toInstant() - - // event/task end time - if (end == null && start != null) - end = when (val refDur = refDuration?.duration) { - is Duration -> start + refDur - is Period -> start + Duration.between(start, start + refDur) - else -> null - } + val end = refEnd?.date?.toInstant() // event/task duration val duration: Duration? = diff --git a/lib/src/test/kotlin/at/bitfire/ical4android/TaskTest.kt b/lib/src/test/kotlin/at/bitfire/ical4android/TaskTest.kt index 1aea93711..03273b5cc 100644 --- a/lib/src/test/kotlin/at/bitfire/ical4android/TaskTest.kt +++ b/lib/src/test/kotlin/at/bitfire/ical4android/TaskTest.kt @@ -6,16 +6,33 @@ package at.bitfire.ical4android +import at.bitfire.DefaultTimezoneRule +import at.bitfire.dateTimeValue +import at.bitfire.dateValue +import net.fortuna.ical4j.model.TimeZoneRegistryFactory import net.fortuna.ical4j.model.property.DtStart import net.fortuna.ical4j.model.property.Due +import net.fortuna.ical4j.model.property.Duration +import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull import org.junit.Assert.assertTrue +import org.junit.Rule import org.junit.Test import java.time.LocalDate import java.time.LocalDateTime +import java.time.Duration as JavaDuration +import java.time.Period as JavaPeriod + class TaskTest { + @get:Rule + val tzRule = DefaultTimezoneRule("Pacific/Auckland") + + private val tzRegistry = TimeZoneRegistryFactory.getInstance().createRegistry() + private val tzVienna = tzRegistry.getTimeZone("Europe/Vienna") + @Test fun testAllDay() { assertTrue(Task().isAllDay()) @@ -45,4 +62,157 @@ class TaskTest { }.isAllDay()) } + @Test + fun `end with due date`() { + val due = Due(dateTimeValue("20260508T120000Z")) + val task = Task(due = due) + + val result = task.end + + assertEquals(due, result) + } + + @Test + fun `end with start date being ZonedDateTime and duration being Duration`() { + val task = Task( + dtStart = DtStart(dateTimeValue("20260508T120000", tzVienna)), + duration = Duration(JavaDuration.parse("PT1H")) + ) + + val result = task.end + + assertEquals(Due(dateTimeValue("20260508T110000Z")), result) + } + + @Test + fun `end with start date being ZonedDateTime and duration being Duration spanning DST change`() { + val task = Task( + dtStart = DtStart(dateTimeValue("20260329T010000", tzVienna)), + duration = Duration(JavaDuration.parse("PT2H")) + ) + + val result = task.end + + assertEquals(Due(dateTimeValue("20260329T020000Z")), result) + } + + @Test + fun `end with start date being ZonedDateTime and duration being Period`() { + val task = Task( + dtStart = DtStart(dateTimeValue("20260508T120000", tzVienna)), + duration = Duration(JavaPeriod.parse("P1D")) + ) + + val result = task.end + + assertEquals(Due(dateTimeValue("20260509T100000Z")), result) + } + + @Test + fun `end with start date being ZonedDateTime and duration being Period spanning DST change`() { + val task = Task( + dtStart = DtStart(dateTimeValue("20260329T010000", tzVienna)), + duration = Duration(JavaPeriod.parse("P1D")) + ) + + val result = task.end + + assertEquals(Due(dateTimeValue("20260330T000000Z")), result) + } + + @Test + fun `end with start date being LocalDate and duration being Duration`() { + val task = Task( + dtStart = DtStart(dateValue("20260508")), + duration = Duration(JavaDuration.parse("PT1H")) + ) + + val result = task.end + + assertEquals(Due(dateTimeValue("20260508T010000Z")), result) + } + + @Test + fun `end with start date being LocalDate and duration being Period`() { + val task = Task( + dtStart = DtStart(dateValue("20260508")), + duration = Duration(JavaPeriod.parse("P1D")) + ) + + val result = task.end + + assertEquals(Due(dateTimeValue("20260509T000000Z")), result) + } + + @Test + fun `end with start date being LocalDateTime and duration being Duration`() { + val task = Task( + // floating DATE-TIME + system time zone (Pacific/Auckland): 20260508T000000Z + dtStart = DtStart(dateTimeValue("20260508T120000")), + duration = Duration(JavaDuration.parse("PT1H")) + ) + + val result = task.end + + assertEquals(Due(dateTimeValue("20260508T010000Z")), result) + } + + @Test + fun `end with start date being LocalDateTime and duration being Period`() { + val task = Task( + // floating DATE-TIME + system time zone (Pacific/Auckland): 20260508T000000Z + dtStart = DtStart(dateTimeValue("20260508T120000")), + duration = Duration(JavaPeriod.parse("P1D")) + ) + + val result = task.end + + assertEquals(Due(dateTimeValue("20260509T000000Z")), result) + } + + @Test + fun `end with start date being Instant and duration being Duration`() { + val task = Task( + dtStart = DtStart(dateTimeValue("20260508T120000Z")), + duration = Duration(JavaDuration.parse("PT1H")) + ) + + val result = task.end + + assertEquals(Due(dateTimeValue("20260508T130000Z")), result) + } + + @Test + fun `end with start date being Instant and duration being Period`() { + val task = Task( + dtStart = DtStart(dateTimeValue("20260508T120000Z")), + duration = Duration(JavaPeriod.parse("P1D")) + ) + + val result = task.end + + assertEquals(Due(dateTimeValue("20260509T120000Z")), result) + } + + @Test + fun `end with start date and without duration`() { + val task = Task( + dtStart = DtStart(dateTimeValue("20260508T120000Z")), + ) + + val result = task.end + + assertNull(result) + } + + @Test + fun `end with duration but no start date`() { + val task = Task( + duration = Duration(JavaDuration.parse("PT1H")) + ) + + val result = task.end + + assertNull(result) + } } \ No newline at end of file diff --git a/lib/src/test/kotlin/at/bitfire/synctools/util/AlarmTriggerCalculatorTest.kt b/lib/src/test/kotlin/at/bitfire/synctools/util/AlarmTriggerCalculatorTest.kt index 27349b8ad..fa7a54bbe 100644 --- a/lib/src/test/kotlin/at/bitfire/synctools/util/AlarmTriggerCalculatorTest.kt +++ b/lib/src/test/kotlin/at/bitfire/synctools/util/AlarmTriggerCalculatorTest.kt @@ -45,7 +45,6 @@ class AlarmTriggerCalculatorTest { alarm = alarm, refStart = refStart, refEnd = null, - refDuration = null, allowRelEnd = false )!! @@ -63,7 +62,6 @@ class AlarmTriggerCalculatorTest { alarm = alarm, refStart = refStart, refEnd = null, - refDuration = null, allowRelEnd = false )!! @@ -81,7 +79,6 @@ class AlarmTriggerCalculatorTest { alarm = alarm, refStart = refStart, refEnd = null, - refDuration = null, allowRelEnd = false )!! @@ -102,7 +99,6 @@ class AlarmTriggerCalculatorTest { alarm = alarm, refStart = refStart, refEnd = null, - refDuration = null, allowRelEnd = allowRelEnd )!! @@ -124,7 +120,6 @@ class AlarmTriggerCalculatorTest { alarm = alarm, refStart = refStart, refEnd = refEnd, - refDuration = null, allowRelEnd = allowRelEnd )!! @@ -147,7 +142,6 @@ class AlarmTriggerCalculatorTest { alarm = alarm, refStart = refStart, refEnd = refEnd, - refDuration = null, allowRelEnd = allowRelEnd ) @@ -167,7 +161,6 @@ class AlarmTriggerCalculatorTest { alarm = alarm, refStart = refStart, refEnd = null, - refDuration = null, allowRelEnd = allowRelEnd ) @@ -189,7 +182,6 @@ class AlarmTriggerCalculatorTest { alarm = alarm, refStart = refStart, refEnd = refEnd, - refDuration = null, allowRelEnd = allowRelEnd )!! @@ -207,7 +199,6 @@ class AlarmTriggerCalculatorTest { alarm = alarm, refStart = refStart, refEnd = null, - refDuration = null, allowRelEnd = false )!! @@ -228,7 +219,6 @@ class AlarmTriggerCalculatorTest { alarm = alarm, refStart = DtStart(currentTime), refEnd = null, - refDuration = null, allowRelEnd = true )!! @@ -237,34 +227,14 @@ class AlarmTriggerCalculatorTest { } @Test - fun `refStart has DATE value and refDuration is Duration`() { + fun `refStart has DATE value`() { val alarm = VAlarm(JavaDuration.parse("-PT5M")) val refStart = DtStart(dateValue("20260407")) - val refDuration = Duration("PT1H") val (ref, min) = alarmTriggerToMinutes( alarm = alarm, refStart = refStart, refEnd = null, - refDuration = refDuration, - allowRelEnd = true - )!! - - assertEquals(Related.START, ref) - assertEquals(5, min) - } - - @Test - fun `refStart has DATE value and refDuration is Period`() { - val alarm = VAlarm(JavaDuration.parse("-PT5M")) - val refStart = DtStart(dateValue("20260407")) - val refDuration = Duration("P1D") - - val (ref, min) = alarmTriggerToMinutes( - alarm = alarm, - refStart = refStart, - refEnd = null, - refDuration = refDuration, allowRelEnd = true )!! @@ -285,7 +255,6 @@ class AlarmTriggerCalculatorTest { alarm = alarm, refStart = refStart, refEnd = refEnd, - refDuration = null, allowRelEnd = allowRelEnd )!! @@ -302,7 +271,6 @@ class AlarmTriggerCalculatorTest { alarm = alarm, refStart = refStart, refEnd = null, - refDuration = null, allowRelEnd = true )!! From 8523139277bb1372528b3ddf4c9554e9822aa081 Mon Sep 17 00:00:00 2001 From: cketti Date: Fri, 8 May 2026 17:51:20 +0200 Subject: [PATCH 05/13] [Refactor] Simplify `Task.end` --- lib/src/main/kotlin/at/bitfire/ical4android/Task.kt | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/lib/src/main/kotlin/at/bitfire/ical4android/Task.kt b/lib/src/main/kotlin/at/bitfire/ical4android/Task.kt index 12bb02cce..17892cf92 100644 --- a/lib/src/main/kotlin/at/bitfire/ical4android/Task.kt +++ b/lib/src/main/kotlin/at/bitfire/ical4android/Task.kt @@ -8,9 +8,7 @@ package at.bitfire.ical4android import androidx.annotation.IntRange import at.bitfire.ical4android.util.DateUtils -import at.bitfire.synctools.icalendar.DatePropertyTzMapper.normalizedDate import at.bitfire.synctools.util.AndroidTimeUtils.toInstant -import at.bitfire.synctools.util.AndroidTimeUtils.toZonedDateTime import net.fortuna.ical4j.model.Property import net.fortuna.ical4j.model.component.VAlarm import net.fortuna.ical4j.model.property.Clazz @@ -27,12 +25,8 @@ import net.fortuna.ical4j.model.property.RRule import net.fortuna.ical4j.model.property.RelatedTo import net.fortuna.ical4j.model.property.Status import java.time.Instant -import java.time.ZonedDateTime import java.util.LinkedList -import java.time.Duration as JavaDuration -import java.time.Period as JavaPeriod - /** * Data class representing a task * @@ -101,11 +95,7 @@ data class Task( val start = dtStart?.date?.toInstant() val duration = duration?.duration if (start != null && duration != null) { - val end = when (duration) { - is JavaDuration -> start + duration - is JavaPeriod -> start + JavaDuration.between(start, start + duration) - else -> throw AssertionError("Expected either Duration or Period") - } + val end = start + duration return Due(end) } From d841694ef0e62d06ddfb51d588d10184736e5e88 Mon Sep 17 00:00:00 2001 From: cketti Date: Fri, 8 May 2026 18:13:59 +0200 Subject: [PATCH 06/13] [Refactor] Split `alarmTriggerToMinutes()` into multiple methods --- .../synctools/util/AlarmTriggerCalculator.kt | 160 +++++++++++++----- 1 file changed, 114 insertions(+), 46 deletions(-) diff --git a/lib/src/main/kotlin/at/bitfire/synctools/util/AlarmTriggerCalculator.kt b/lib/src/main/kotlin/at/bitfire/synctools/util/AlarmTriggerCalculator.kt index dae267a2d..98c7194f9 100644 --- a/lib/src/main/kotlin/at/bitfire/synctools/util/AlarmTriggerCalculator.kt +++ b/lib/src/main/kotlin/at/bitfire/synctools/util/AlarmTriggerCalculator.kt @@ -15,7 +15,9 @@ import net.fortuna.ical4j.model.property.DateProperty import net.fortuna.ical4j.model.property.DtStart import net.fortuna.ical4j.model.property.Trigger import java.time.Duration +import java.time.Instant import java.time.Period +import java.time.temporal.TemporalAmount import java.util.logging.Level import java.util.logging.Logger import kotlin.jvm.optionals.getOrNull @@ -50,62 +52,128 @@ object AlarmTriggerCalculator { ): Pair? { val trigger = alarm.getProperty(Property.TRIGGER).getOrNull() ?: return null - // Note: big method – maybe split? + val triggerRelated = trigger.getParameter(Parameter.RELATED).getOrNull() ?: Related.START + val triggerDuration = trigger.duration + val triggerTime = trigger.date - val minutes: Int // minutes before/after the event - var related: Related = trigger.getParameter(Parameter.RELATED).getOrNull() ?: Related.START + return if (triggerDuration != null) { + triggerDurationToMinutes( + triggerDuration, + triggerRelated, + refStart, + refEnd, + allowRelEnd + ) + } else if (triggerTime != null && refStart?.date != null) { + triggerTimeToMinutes( + triggerTime, + refStart + ) + } else { + logger.log(Level.WARNING, "VALARM TRIGGER type is not DURATION or DATE-TIME " + + "(requires event DTSTART for Android), ignoring alarm", alarm) + null + } + } - // event/task start/end time - val start = refStart?.date?.toInstant() - val end = refEnd?.date?.toInstant() + // TRIGGER value is a DURATION. Important: + // 1) Negative values in TRIGGER mean positive values in Reminders.MINUTES and vice versa. + // 2) Android doesn't know alarm seconds, but only minutes. Cut off seconds from the final result. + // 3) DURATION can be a Duration (time-based) or a Period (date-based), which have to be treated differently. + private fun triggerDurationToMinutes( + triggerDuration: TemporalAmount, + triggerRelated: Related, + refStart: DtStart<*>?, + refEnd: DateProperty<*>?, + allowRelatedEnd: Boolean + ): Pair? { + return when (triggerRelated) { + Related.START -> { + triggerRelatedStartToMinutes(triggerDuration) + } + Related.END if allowRelatedEnd -> { + triggerRelatedEndToMinutes(triggerDuration) + } + else -> { + triggerRelatedEndToRelatedStartMinutes(triggerDuration, refStart, refEnd) + } + } + } - // event/task duration - val duration: Duration? = - if (start != null && end != null) - Duration.between(start, end) - else - null + private fun triggerRelatedStartToMinutes(triggerDuration: TemporalAmount): Pair { + val millisBefore = when (triggerDuration) { + is Duration -> -triggerDuration.toMillis() + is Period -> { + // TODO: Take time zones into account (will probably be possible with ical4j 4.x). + // For instance, an alarm one day before the DST change should be 23/25 hours before the event. + -Duration.ofDays(triggerDuration.days.toLong()).toMillis() // months and years are not used in DURATION values; weeks are calculated to days + } + else -> throw AssertionError("triggerDuration must be Duration or Period") + } + val minutes = (millisBefore / 60000).toInt() - val triggerDur = trigger.duration - val triggerTime = trigger.date + return Pair(Related.START, minutes) + } - if (triggerDur != null) { - // TRIGGER value is a DURATION. Important: - // 1) Negative values in TRIGGER mean positive values in Reminders.MINUTES and vice versa. - // 2) Android doesn't know alarm seconds, but only minutes. Cut off seconds from the final result. - // 3) DURATION can be a Duration (time-based) or a Period (date-based), which have to be treated differently. - var millisBefore = - when (triggerDur) { - is Duration -> -triggerDur.toMillis() - is Period -> { - // TODO: Take time zones into account (will probably be possible with ical4j 4.x). - // For instance, an alarm one day before the DST change should be 23/25 hours before the event. - -Duration.ofDays(triggerDur.days.toLong()).toMillis() // months and years are not used in DURATION values; weeks are calculated to days - } - else -> throw AssertionError("triggerDur must be Duration or Period") - } - - if (related == Related.END && !allowRelEnd) { - if (duration == null) { - logger.warning("Event/task without duration; can't calculate END-related alarm") - return null - } - // move alarm towards end - related = Related.START - millisBefore -= duration.toMillis() + private fun triggerRelatedEndToMinutes(triggerDuration: TemporalAmount): Pair { + val millisBefore = when (triggerDuration) { + is Duration -> -triggerDuration.toMillis() + is Period -> { + // TODO: Take time zones into account (will probably be possible with ical4j 4.x). + // For instance, an alarm one day before the DST change should be 23/25 hours before the event. + -Duration.ofDays(triggerDuration.days.toLong()).toMillis() // months and years are not used in DURATION values; weeks are calculated to days } - minutes = (millisBefore / 60000).toInt() + else -> throw AssertionError("triggerDuration must be Duration or Period") + } + val minutes = (millisBefore / 60000).toInt() - } else if (triggerTime != null && start != null) { - // TRIGGER value is a DATE-TIME, calculate minutes from start time - related = Related.START - minutes = Duration.between(triggerTime, start).toMinutes().toInt() + return Pair(Related.END, minutes) + } + private fun triggerRelatedEndToRelatedStartMinutes( + triggerDuration: TemporalAmount, + refStart: DtStart<*>?, + refEnd: DateProperty<*>? + ): Pair? { + val start = refStart?.date?.toInstant() + val end = refEnd?.date?.toInstant() + + // event/task duration + val duration = if (start != null && end != null) { + Duration.between(start, end) } else { - logger.log(Level.WARNING, "VALARM TRIGGER type is not DURATION or DATE-TIME (requires event DTSTART for Android), ignoring alarm", alarm) + null + } + + if (duration == null) { + logger.warning("Event/task without duration; can't calculate END-related alarm") return null } - return Pair(related, minutes) + var millisBefore = when (triggerDuration) { + is Duration -> -triggerDuration.toMillis() + is Period -> { + // TODO: Take time zones into account (will probably be possible with ical4j 4.x). + // For instance, an alarm one day before the DST change should be 23/25 hours before the event. + -Duration.ofDays(triggerDuration.days.toLong()).toMillis() // months and years are not used in DURATION values; weeks are calculated to days + } + else -> throw AssertionError("triggerDuration must be Duration or Period") + } + + millisBefore -= duration.toMillis() + val minutes = (millisBefore / 60000).toInt() + + return Pair(Related.START, minutes) + } + + // TRIGGER value is a DATE-TIME, calculate minutes from start time + private fun triggerTimeToMinutes( + triggerTime: Instant, + refStart: DtStart<*> + ): Pair { + val start = refStart.date.toInstant() + val minutes = Duration.between(triggerTime, start).toMinutes().toInt() + + return Pair(Related.START, minutes) } -} \ No newline at end of file +} From 6f2cb75d8155cd8b27a3496352bd2104a1615225 Mon Sep 17 00:00:00 2001 From: cketti Date: Fri, 8 May 2026 18:36:30 +0200 Subject: [PATCH 07/13] Change `triggerRelatedStartToMinutes()` to support DST changes --- .../synctools/util/AlarmTriggerCalculator.kt | 23 ++++++----- .../util/AlarmTriggerCalculatorTest.kt | 38 ++++++++++--------- 2 files changed, 31 insertions(+), 30 deletions(-) diff --git a/lib/src/main/kotlin/at/bitfire/synctools/util/AlarmTriggerCalculator.kt b/lib/src/main/kotlin/at/bitfire/synctools/util/AlarmTriggerCalculator.kt index 98c7194f9..d33fc7f6d 100644 --- a/lib/src/main/kotlin/at/bitfire/synctools/util/AlarmTriggerCalculator.kt +++ b/lib/src/main/kotlin/at/bitfire/synctools/util/AlarmTriggerCalculator.kt @@ -6,7 +6,9 @@ package at.bitfire.synctools.util +import at.bitfire.synctools.icalendar.DatePropertyTzMapper.normalizedDate import at.bitfire.synctools.util.AndroidTimeUtils.toInstant +import at.bitfire.synctools.util.AndroidTimeUtils.toZonedDateTime import net.fortuna.ical4j.model.Parameter import net.fortuna.ical4j.model.Property import net.fortuna.ical4j.model.component.VAlarm @@ -89,7 +91,7 @@ object AlarmTriggerCalculator { ): Pair? { return when (triggerRelated) { Related.START -> { - triggerRelatedStartToMinutes(triggerDuration) + triggerRelatedStartToMinutes(triggerDuration, refStart) } Related.END if allowRelatedEnd -> { triggerRelatedEndToMinutes(triggerDuration) @@ -100,17 +102,14 @@ object AlarmTriggerCalculator { } } - private fun triggerRelatedStartToMinutes(triggerDuration: TemporalAmount): Pair { - val millisBefore = when (triggerDuration) { - is Duration -> -triggerDuration.toMillis() - is Period -> { - // TODO: Take time zones into account (will probably be possible with ical4j 4.x). - // For instance, an alarm one day before the DST change should be 23/25 hours before the event. - -Duration.ofDays(triggerDuration.days.toLong()).toMillis() // months and years are not used in DURATION values; weeks are calculated to days - } - else -> throw AssertionError("triggerDuration must be Duration or Period") - } - val minutes = (millisBefore / 60000).toInt() + private fun triggerRelatedStartToMinutes( + triggerDuration: TemporalAmount, + refStart: DtStart<*>? + ): Pair? { + val start = refStart?.normalizedDate()?.toZonedDateTime() ?: return null + + val alarmTime = start + triggerDuration + val minutes = Duration.between(alarmTime, start).toMinutes().toInt() return Pair(Related.START, minutes) } diff --git a/lib/src/test/kotlin/at/bitfire/synctools/util/AlarmTriggerCalculatorTest.kt b/lib/src/test/kotlin/at/bitfire/synctools/util/AlarmTriggerCalculatorTest.kt index fa7a54bbe..98d6f58cc 100644 --- a/lib/src/test/kotlin/at/bitfire/synctools/util/AlarmTriggerCalculatorTest.kt +++ b/lib/src/test/kotlin/at/bitfire/synctools/util/AlarmTriggerCalculatorTest.kt @@ -10,6 +10,7 @@ import at.bitfire.dateTimeValue import at.bitfire.dateValue import at.bitfire.synctools.util.AlarmTriggerCalculator.alarmTriggerToMinutes import net.fortuna.ical4j.model.Property.TRIGGER +import net.fortuna.ical4j.model.TimeZoneRegistryFactory import net.fortuna.ical4j.model.component.VAlarm import net.fortuna.ical4j.model.parameter.Related import net.fortuna.ical4j.model.property.DtEnd @@ -32,6 +33,9 @@ import java.time.Duration as JavaDuration class AlarmTriggerCalculatorTest { + private val tzRegistry = TimeZoneRegistryFactory.getInstance().createRegistry() + private val tzVienna = tzRegistry.getTimeZone("Europe/Vienna") + // current time stamp private val currentTime = ZonedDateTime.now() @@ -39,7 +43,7 @@ class AlarmTriggerCalculatorTest { fun `negative trigger duration`() { // TRIGGER;REL=START:-P1DT1H1M29S val alarm = VAlarm(JavaDuration.parse("-P1DT1H1M29S")) - val refStart = DtStart() + val refStart = DtStart(currentTime) val (ref, min) = alarmTriggerToMinutes( alarm = alarm, @@ -56,7 +60,7 @@ class AlarmTriggerCalculatorTest { fun `trigger duration in seconds`() { // TRIGGER;REL=START:-PT3600S val alarm = VAlarm(JavaDuration.parse("-PT3600S")) - val refStart = DtStart() + val refStart = DtStart(currentTime) val (ref, min) = alarmTriggerToMinutes( alarm = alarm, @@ -73,7 +77,7 @@ class AlarmTriggerCalculatorTest { fun `positive trigger duration`() { // TRIGGER;REL=START:P1DT1H1M30S (alarm *after* start) val alarm = VAlarm(JavaDuration.parse("P1DT1H1M30S")) - val refStart = DtStart() + val refStart = DtStart(currentTime) val (ref, min) = alarmTriggerToMinutes( alarm = alarm, @@ -278,26 +282,24 @@ class AlarmTriggerCalculatorTest { assertEquals(12.hours.toMinutes(), min) } - // TODO Note: can we use the following now when we have ical4j 4.x? - - /* - DOES NOT WORK YET! Will work as soon as Java 8 API is consequently used in ical4j and ical4android. - @Test - fun testVAlarm_TriggerPeriod_CrossingDST() { - // Event start: 2020/04/01 01:00 Vienna, alarm: one day before start of the event + fun `trigger relative to start with Period instance spanning DST change`() { + // Event start: 2020/03/30 01:00 Vienna, alarm: one day before start of the event // DST changes on 2020/03/29 02:00 -> 03:00, so there is one hour less! // The alarm has to be set 23 hours before the event so that it is set one day earlier. - val event = Event() - event.dtStart = DtStart("20200401T010000", tzVienna) - val (ref, min) = ICalendar.vAlarmToMin( - VAlarm(Period.parse("-P1W1D")), - event, false + val alarm = VAlarm(Period.parse("-P1D")) + val refStart = DtStart(dateTimeValue("20200330T010000", tzVienna)) + + val (ref, min) = alarmTriggerToMinutes( + alarm = alarm, + refStart = refStart, + refEnd = null, + allowRelEnd = false )!! - assertEquals(Related.START, ref) - assertEquals(8*24*60, min) - }*/ + assertEquals(Related.START, ref) + assertEquals(23.hours.toMinutes(), min) + } } private fun kotlin.time.Duration.toMinutes(): Int = inWholeMinutes.toInt() From e6a9ac20ba6ea1c4662de9a60b3a593903ba7dca Mon Sep 17 00:00:00 2001 From: cketti Date: Fri, 8 May 2026 18:43:33 +0200 Subject: [PATCH 08/13] Change `triggerRelatedEndToMinutes()` to support DST changes --- .../synctools/util/AlarmTriggerCalculator.kt | 21 +++++++-------- .../util/AlarmTriggerCalculatorTest.kt | 27 ++++++++++++++++--- 2 files changed, 33 insertions(+), 15 deletions(-) diff --git a/lib/src/main/kotlin/at/bitfire/synctools/util/AlarmTriggerCalculator.kt b/lib/src/main/kotlin/at/bitfire/synctools/util/AlarmTriggerCalculator.kt index d33fc7f6d..38063460d 100644 --- a/lib/src/main/kotlin/at/bitfire/synctools/util/AlarmTriggerCalculator.kt +++ b/lib/src/main/kotlin/at/bitfire/synctools/util/AlarmTriggerCalculator.kt @@ -94,7 +94,7 @@ object AlarmTriggerCalculator { triggerRelatedStartToMinutes(triggerDuration, refStart) } Related.END if allowRelatedEnd -> { - triggerRelatedEndToMinutes(triggerDuration) + triggerRelatedEndToMinutes(triggerDuration, refEnd) } else -> { triggerRelatedEndToRelatedStartMinutes(triggerDuration, refStart, refEnd) @@ -114,17 +114,14 @@ object AlarmTriggerCalculator { return Pair(Related.START, minutes) } - private fun triggerRelatedEndToMinutes(triggerDuration: TemporalAmount): Pair { - val millisBefore = when (triggerDuration) { - is Duration -> -triggerDuration.toMillis() - is Period -> { - // TODO: Take time zones into account (will probably be possible with ical4j 4.x). - // For instance, an alarm one day before the DST change should be 23/25 hours before the event. - -Duration.ofDays(triggerDuration.days.toLong()).toMillis() // months and years are not used in DURATION values; weeks are calculated to days - } - else -> throw AssertionError("triggerDuration must be Duration or Period") - } - val minutes = (millisBefore / 60000).toInt() + private fun triggerRelatedEndToMinutes( + triggerDuration: TemporalAmount, + refEnd: DateProperty<*>? + ): Pair? { + val end = refEnd?.normalizedDate()?.toZonedDateTime() ?: return null + + val alarmTime = end + triggerDuration + val minutes = Duration.between(alarmTime, end).toMinutes().toInt() return Pair(Related.END, minutes) } diff --git a/lib/src/test/kotlin/at/bitfire/synctools/util/AlarmTriggerCalculatorTest.kt b/lib/src/test/kotlin/at/bitfire/synctools/util/AlarmTriggerCalculatorTest.kt index 98d6f58cc..c721059db 100644 --- a/lib/src/test/kotlin/at/bitfire/synctools/util/AlarmTriggerCalculatorTest.kt +++ b/lib/src/test/kotlin/at/bitfire/synctools/util/AlarmTriggerCalculatorTest.kt @@ -96,13 +96,13 @@ class AlarmTriggerCalculatorTest { val alarm = VAlarm(JavaDuration.parse("-P1DT1H1M30S")).apply { getRequiredProperty(TRIGGER).add(Related.END) } - val refStart = DtStart() + val refEnd = DtStart(currentTime) val allowRelEnd = true val (ref, min) = alarmTriggerToMinutes( alarm = alarm, - refStart = refStart, - refEnd = null, + refStart = null, + refEnd = refEnd, allowRelEnd = allowRelEnd )!! @@ -300,6 +300,27 @@ class AlarmTriggerCalculatorTest { assertEquals(Related.START, ref) assertEquals(23.hours.toMinutes(), min) } + + @Test + fun `trigger relative to end with Period instance spanning DST change`() { + // Event end: 2020/03/30 01:00 Vienna, alarm: one day before end of the event + // DST changes on 2020/03/29 02:00 -> 03:00, so there is one hour less! + // The alarm has to be set 23 hours before the end of the event. + val alarm = VAlarm(Period.parse("-P1D")).apply { + getRequiredProperty(TRIGGER).add(Related.END) + } + val refEnd = DtEnd(dateTimeValue("20200330T010000", tzVienna)) + + val (ref, min) = alarmTriggerToMinutes( + alarm = alarm, + refStart = null, + refEnd = refEnd, + allowRelEnd = true + )!! + + assertEquals(Related.END, ref) + assertEquals(23.hours.toMinutes(), min) + } } private fun kotlin.time.Duration.toMinutes(): Int = inWholeMinutes.toInt() From 50a8e817025e1c10cf88689cf711716ce26ae415 Mon Sep 17 00:00:00 2001 From: cketti Date: Fri, 8 May 2026 18:49:41 +0200 Subject: [PATCH 09/13] Change `triggerRelatedEndToRelatedStartMinutes()` to support DST changes --- .../synctools/util/AlarmTriggerCalculator.kt | 31 +++---------------- .../util/AlarmTriggerCalculatorTest.kt | 23 +++++++++++++- 2 files changed, 26 insertions(+), 28 deletions(-) diff --git a/lib/src/main/kotlin/at/bitfire/synctools/util/AlarmTriggerCalculator.kt b/lib/src/main/kotlin/at/bitfire/synctools/util/AlarmTriggerCalculator.kt index 38063460d..b09b29db8 100644 --- a/lib/src/main/kotlin/at/bitfire/synctools/util/AlarmTriggerCalculator.kt +++ b/lib/src/main/kotlin/at/bitfire/synctools/util/AlarmTriggerCalculator.kt @@ -18,7 +18,6 @@ import net.fortuna.ical4j.model.property.DtStart import net.fortuna.ical4j.model.property.Trigger import java.time.Duration import java.time.Instant -import java.time.Period import java.time.temporal.TemporalAmount import java.util.logging.Level import java.util.logging.Logger @@ -131,33 +130,11 @@ object AlarmTriggerCalculator { refStart: DtStart<*>?, refEnd: DateProperty<*>? ): Pair? { - val start = refStart?.date?.toInstant() - val end = refEnd?.date?.toInstant() - - // event/task duration - val duration = if (start != null && end != null) { - Duration.between(start, end) - } else { - null - } - - if (duration == null) { - logger.warning("Event/task without duration; can't calculate END-related alarm") - return null - } - - var millisBefore = when (triggerDuration) { - is Duration -> -triggerDuration.toMillis() - is Period -> { - // TODO: Take time zones into account (will probably be possible with ical4j 4.x). - // For instance, an alarm one day before the DST change should be 23/25 hours before the event. - -Duration.ofDays(triggerDuration.days.toLong()).toMillis() // months and years are not used in DURATION values; weeks are calculated to days - } - else -> throw AssertionError("triggerDuration must be Duration or Period") - } + val start = refStart?.normalizedDate()?.toZonedDateTime() ?: return null + val end = refEnd?.normalizedDate()?.toZonedDateTime() ?: return null - millisBefore -= duration.toMillis() - val minutes = (millisBefore / 60000).toInt() + val alarmTime = end + triggerDuration + val minutes = Duration.between(alarmTime, start).toMinutes().toInt() return Pair(Related.START, minutes) } diff --git a/lib/src/test/kotlin/at/bitfire/synctools/util/AlarmTriggerCalculatorTest.kt b/lib/src/test/kotlin/at/bitfire/synctools/util/AlarmTriggerCalculatorTest.kt index c721059db..436881cb4 100644 --- a/lib/src/test/kotlin/at/bitfire/synctools/util/AlarmTriggerCalculatorTest.kt +++ b/lib/src/test/kotlin/at/bitfire/synctools/util/AlarmTriggerCalculatorTest.kt @@ -138,7 +138,7 @@ class AlarmTriggerCalculatorTest { val alarm = VAlarm(JavaDuration.parse("-PT65S")).apply { getRequiredProperty(TRIGGER).add(Related.END) } - val refStart = DtStart() + val refStart = null val refEnd = DtEnd(currentTime) val allowRelEnd = false @@ -321,6 +321,27 @@ class AlarmTriggerCalculatorTest { assertEquals(Related.END, ref) assertEquals(23.hours.toMinutes(), min) } + + @Test + fun `trigger relative to end with Period instance spanning DST change and allowRelEnd=false`() { + // Event end: 2020/03/30 01:00 Vienna, alarm: one day before end of the event + // DST changes on 2020/03/29 02:00 -> 03:00, so there is one hour less! + val alarm = VAlarm(Period.parse("-P1D")).apply { + getRequiredProperty(TRIGGER).add(Related.END) + } + val refStart = DtStart(dateTimeValue("20200330T000000", tzVienna)) + val refEnd = DtEnd(dateTimeValue("20200330T010000", tzVienna)) + + val (ref, min) = alarmTriggerToMinutes( + alarm = alarm, + refStart = refStart, + refEnd = refEnd, + allowRelEnd = false + )!! + + assertEquals(Related.START, ref) + assertEquals(22.hours.toMinutes(), min) + } } private fun kotlin.time.Duration.toMinutes(): Int = inWholeMinutes.toInt() From 6e76464cfb574344b06281a99154a28c0dbd3ee0 Mon Sep 17 00:00:00 2001 From: cketti Date: Fri, 8 May 2026 18:57:31 +0200 Subject: [PATCH 10/13] Use fixed date in test to avoid failures around DST change --- .../at/bitfire/synctools/util/AlarmTriggerCalculatorTest.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/lib/src/test/kotlin/at/bitfire/synctools/util/AlarmTriggerCalculatorTest.kt b/lib/src/test/kotlin/at/bitfire/synctools/util/AlarmTriggerCalculatorTest.kt index 436881cb4..21cd60e4b 100644 --- a/lib/src/test/kotlin/at/bitfire/synctools/util/AlarmTriggerCalculatorTest.kt +++ b/lib/src/test/kotlin/at/bitfire/synctools/util/AlarmTriggerCalculatorTest.kt @@ -196,8 +196,7 @@ class AlarmTriggerCalculatorTest { @Test fun `trigger with Period instance`() { val alarm = VAlarm(Period.parse("-P1W1D")) - //FIXME: Use fixed date, otherwise test might fail close to DST changes - val refStart = DtStart(currentTime) + val refStart = DtStart(dateTimeValue("20260508T120000", tzVienna)) val (ref, min) = alarmTriggerToMinutes( alarm = alarm, From 61a29af7eafe4108eb308e05ba1e49a3a9062e2b Mon Sep 17 00:00:00 2001 From: cketti Date: Fri, 8 May 2026 19:01:29 +0200 Subject: [PATCH 11/13] Remove comments that don't add much value but are easily forgotten to be updated --- .../bitfire/synctools/util/AlarmTriggerCalculatorTest.kt | 9 --------- 1 file changed, 9 deletions(-) diff --git a/lib/src/test/kotlin/at/bitfire/synctools/util/AlarmTriggerCalculatorTest.kt b/lib/src/test/kotlin/at/bitfire/synctools/util/AlarmTriggerCalculatorTest.kt index 21cd60e4b..4157ac06d 100644 --- a/lib/src/test/kotlin/at/bitfire/synctools/util/AlarmTriggerCalculatorTest.kt +++ b/lib/src/test/kotlin/at/bitfire/synctools/util/AlarmTriggerCalculatorTest.kt @@ -41,7 +41,6 @@ class AlarmTriggerCalculatorTest { @Test fun `negative trigger duration`() { - // TRIGGER;REL=START:-P1DT1H1M29S val alarm = VAlarm(JavaDuration.parse("-P1DT1H1M29S")) val refStart = DtStart(currentTime) @@ -58,7 +57,6 @@ class AlarmTriggerCalculatorTest { @Test fun `trigger duration in seconds`() { - // TRIGGER;REL=START:-PT3600S val alarm = VAlarm(JavaDuration.parse("-PT3600S")) val refStart = DtStart(currentTime) @@ -75,7 +73,6 @@ class AlarmTriggerCalculatorTest { @Test fun `positive trigger duration`() { - // TRIGGER;REL=START:P1DT1H1M30S (alarm *after* start) val alarm = VAlarm(JavaDuration.parse("P1DT1H1M30S")) val refStart = DtStart(currentTime) @@ -92,7 +89,6 @@ class AlarmTriggerCalculatorTest { @Test fun `trigger relative to end with allowRelEnd=true`() { - // TRIGGER;REL=END:-P1DT1H1M30S val alarm = VAlarm(JavaDuration.parse("-P1DT1H1M30S")).apply { getRequiredProperty(TRIGGER).add(Related.END) } @@ -112,7 +108,6 @@ class AlarmTriggerCalculatorTest { @Test fun `trigger relative to end with allowRelEnd=false`() { - // TRIGGER;REL=END:-PT30S val alarm = VAlarm(JavaDuration.parse("-PT65S")).apply { getRequiredProperty(TRIGGER).add(Related.END) } @@ -134,7 +129,6 @@ class AlarmTriggerCalculatorTest { @Test fun `trigger relative to end without start time and with allowRelEnd=false`() { - // TRIGGER;REL=END:-PT30S val alarm = VAlarm(JavaDuration.parse("-PT65S")).apply { getRequiredProperty(TRIGGER).add(Related.END) } @@ -154,7 +148,6 @@ class AlarmTriggerCalculatorTest { @Test fun `trigger relative to end without end time or duration and with allowRelEnd=false`() { - // TRIGGER;REL=END:-PT30S val alarm = VAlarm(JavaDuration.parse("-PT65S")).apply { getRequiredProperty(TRIGGER).add(Related.END) } @@ -173,7 +166,6 @@ class AlarmTriggerCalculatorTest { @Test fun `trigger relative to end and after end date with allowRelEnd=false`() { - // TRIGGER;REL=END:-P1DT1H1M30S val alarm = VAlarm(JavaDuration.parse("P1DT1H1M30S")).apply { getRequiredProperty(TRIGGER).add(Related.END) } @@ -211,7 +203,6 @@ class AlarmTriggerCalculatorTest { @Test fun `trigger with DATE-TIME value`() { - // TRIGGER;VALUE=DATE-TIME: // 89 sec (should be cut off to 1 min) before event val alarm = VAlarm(currentTime.minusSeconds(89).toInstant()).apply { // not useful for DATE-TIME values, should be ignored From 8cf1d91ebd800d4c13b2dfcaf71d9fd73bf850ae Mon Sep 17 00:00:00 2001 From: cketti Date: Fri, 8 May 2026 19:18:51 +0200 Subject: [PATCH 12/13] Add DTSTART to tests in `RemindersBuilderTest` Without a start time `Reminders.MINUTES` will always contain -1 --- .../mapping/calendar/builder/RemindersBuilderTest.kt | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/builder/RemindersBuilderTest.kt b/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/builder/RemindersBuilderTest.kt index 3a1ab7c73..6483714aa 100644 --- a/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/builder/RemindersBuilderTest.kt +++ b/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/builder/RemindersBuilderTest.kt @@ -30,6 +30,7 @@ import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import java.time.Duration +import java.time.Instant import java.time.Period @RunWith(RobolectricTestRunner::class) @@ -46,6 +47,7 @@ class RemindersBuilderTest { val result = Entity(ContentValues()) builder.build( from = VEvent().apply { + this += DtStart(Instant.now()) this += VAlarm() }, main = VEvent(), @@ -62,6 +64,7 @@ class RemindersBuilderTest { val result = Entity(ContentValues()) builder.build( from = VEvent().apply { + this += DtStart(Instant.now()) this += VAlarm(Duration.ofMinutes(-10)).apply { this += ImmutableAction.AUDIO } @@ -80,6 +83,7 @@ class RemindersBuilderTest { val result = Entity(ContentValues()) builder.build( from = VEvent().apply { + this += DtStart(Instant.now()) this += VAlarm(Duration.ofMinutes(-10)).apply { this += ImmutableAction.DISPLAY } @@ -98,6 +102,7 @@ class RemindersBuilderTest { val result = Entity(ContentValues()) builder.build( from = VEvent().apply { + this += DtStart(Instant.now()) this += VAlarm(Duration.ofSeconds(-120)).apply { this += ImmutableAction.EMAIL } @@ -116,6 +121,7 @@ class RemindersBuilderTest { val result = Entity(ContentValues()) builder.build( from = VEvent().apply { + this += DtStart(Instant.now()) this += VAlarm(Duration.ofSeconds(-120)).apply { this += Action("X-CUSTOM") } @@ -134,6 +140,7 @@ class RemindersBuilderTest { val result = Entity(ContentValues()) builder.build( from = VEvent().apply { + this += DtStart(dateTimeValue("20260508T120000Z")) this += VAlarm(Period.ofDays(-1)) }, main = VEvent(), @@ -147,6 +154,7 @@ class RemindersBuilderTest { val result = Entity(ContentValues()) builder.build( from = VEvent().apply { + this += DtStart(Instant.now()) this += VAlarm(Duration.ofSeconds(-10)) }, main = VEvent(), @@ -160,6 +168,7 @@ class RemindersBuilderTest { val result = Entity(ContentValues()) builder.build( from = VEvent().apply { + this += DtStart(Instant.now()) this += VAlarm(Duration.ofMinutes(10)) }, main = VEvent(), From 0731a05bf425873bd134bda67e0e0fc6e7c41c83 Mon Sep 17 00:00:00 2001 From: cketti Date: Mon, 11 May 2026 10:53:45 +0200 Subject: [PATCH 13/13] Fix typo --- .../at/bitfire/synctools/util/AlarmTriggerCalculatorTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/src/test/kotlin/at/bitfire/synctools/util/AlarmTriggerCalculatorTest.kt b/lib/src/test/kotlin/at/bitfire/synctools/util/AlarmTriggerCalculatorTest.kt index 4157ac06d..f43ab30e6 100644 --- a/lib/src/test/kotlin/at/bitfire/synctools/util/AlarmTriggerCalculatorTest.kt +++ b/lib/src/test/kotlin/at/bitfire/synctools/util/AlarmTriggerCalculatorTest.kt @@ -257,7 +257,7 @@ class AlarmTriggerCalculatorTest { } @Test - fun `trigger with DATE-TIME vale and refStart with DATE value`() { + fun `trigger with DATE-TIME value and refStart with DATE value`() { val alarm = VAlarm(dateTimeValue("20260406T120000", ZoneOffset.UTC).toInstant()) val refStart = DtStart(dateValue("20260407"))