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/ical4android/Task.kt b/lib/src/main/kotlin/at/bitfire/ical4android/Task.kt index 621bb1cfe..17892cf92 100644 --- a/lib/src/main/kotlin/at/bitfire/ical4android/Task.kt +++ b/lib/src/main/kotlin/at/bitfire/ical4android/Task.kt @@ -8,6 +8,7 @@ package at.bitfire.ical4android import androidx.annotation.IntRange import at.bitfire.ical4android.util.DateUtils +import at.bitfire.synctools.util.AndroidTimeUtils.toInstant import net.fortuna.ical4j.model.Property import net.fortuna.ical4j.model.component.VAlarm import net.fortuna.ical4j.model.property.Clazz @@ -23,6 +24,7 @@ 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.util.LinkedList /** @@ -75,4 +77,28 @@ 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 = start + duration + 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 bc0cd168a..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 @@ -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,11 +37,10 @@ class RemindersBuilder: AndroidEntityBuilder { else -> Reminders.METHOD_DEFAULT // won't trigger an alarm on the Android device } - val minutes = ICalendar.vAlarmToMin( + 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 9e0b548d0..cd9ee81bd 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 @@ -8,7 +8,6 @@ package at.bitfire.synctools.mapping.tasks import android.content.ContentValues import android.content.Entity -import at.bitfire.ical4android.ICalendar import at.bitfire.ical4android.Task import at.bitfire.ical4android.UnknownProperty import at.bitfire.synctools.icalendar.DatePropertyTzMapper.normalizedDate @@ -20,6 +19,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 @@ -93,7 +93,7 @@ class DmfsTaskBuilder( builder .withValue(Tasks.LIST_ID, taskList.id) // new builders - + val entity = Entity(ContentValues()) for (fieldBuilder in fieldBuilders) fieldBuilder.build(task, entity) @@ -224,11 +224,10 @@ class DmfsTaskBuilder( private fun insertAlarms(batch: TasksBatchOperation, idxTask: Int?) { for (alarm in task.alarms) { - val (alarmRef, minutes) = ICalendar.vAlarmToMin( + 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 new file mode 100644 index 000000000..b09b29db8 --- /dev/null +++ b/lib/src/main/kotlin/at/bitfire/synctools/util/AlarmTriggerCalculator.kt @@ -0,0 +1,152 @@ +/* + * 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.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 +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.Instant +import java.time.temporal.TemporalAmount +import java.util.logging.Level +import java.util.logging.Logger +import kotlin.jvm.optionals.getOrNull + +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 alarmTriggerToMinutes( + alarm: VAlarm, + refStart: DtStart<*>?, + refEnd: DateProperty<*>?, + allowRelEnd: Boolean + ): Pair? { + val trigger = alarm.getProperty(Property.TRIGGER).getOrNull() ?: return null + + val triggerRelated = trigger.getParameter(Parameter.RELATED).getOrNull() ?: Related.START + val triggerDuration = trigger.duration + val triggerTime = trigger.date + + 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 + } + } + + // 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, refStart) + } + Related.END if allowRelatedEnd -> { + triggerRelatedEndToMinutes(triggerDuration, refEnd) + } + else -> { + triggerRelatedEndToRelatedStartMinutes(triggerDuration, refStart, refEnd) + } + } + } + + 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) + } + + 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) + } + + private fun triggerRelatedEndToRelatedStartMinutes( + triggerDuration: TemporalAmount, + refStart: DtStart<*>?, + refEnd: DateProperty<*>? + ): Pair? { + val start = refStart?.normalizedDate()?.toZonedDateTime() ?: return null + val end = refEnd?.normalizedDate()?.toZonedDateTime() ?: return null + + val alarmTime = end + triggerDuration + val minutes = Duration.between(alarmTime, start).toMinutes().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) + } +} 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/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/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(), 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..f43ab30e6 --- /dev/null +++ b/lib/src/test/kotlin/at/bitfire/synctools/util/AlarmTriggerCalculatorTest.kt @@ -0,0 +1,337 @@ +/* + * 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.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 +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.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 { + + private val tzRegistry = TimeZoneRegistryFactory.getInstance().createRegistry() + private val tzVienna = tzRegistry.getTimeZone("Europe/Vienna") + + // current time stamp + private val currentTime = ZonedDateTime.now() + + @Test + fun `negative trigger duration`() { + val alarm = VAlarm(JavaDuration.parse("-P1DT1H1M29S")) + val refStart = DtStart(currentTime) + + val (ref, min) = alarmTriggerToMinutes( + alarm = alarm, + refStart = refStart, + refEnd = null, + allowRelEnd = false + )!! + + assertEquals(Related.START, ref) + assertEquals((1.days + 1.hours + 1.minutes).toMinutes(), min) + } + + @Test + fun `trigger duration in seconds`() { + val alarm = VAlarm(JavaDuration.parse("-PT3600S")) + val refStart = DtStart(currentTime) + + val (ref, min) = alarmTriggerToMinutes( + alarm = alarm, + refStart = refStart, + refEnd = null, + allowRelEnd = false + )!! + + assertEquals(Related.START, ref) + assertEquals(3600.seconds.toMinutes(), min) + } + + @Test + fun `positive trigger duration`() { + val alarm = VAlarm(JavaDuration.parse("P1DT1H1M30S")) + val refStart = DtStart(currentTime) + + val (ref, min) = alarmTriggerToMinutes( + alarm = alarm, + refStart = refStart, + refEnd = null, + allowRelEnd = false + )!! + + assertEquals(Related.START, ref) + assertEquals(-(1.days + 1.hours + 1.minutes).toMinutes(), min) + } + + @Test + fun `trigger relative to end with allowRelEnd=true`() { + val alarm = VAlarm(JavaDuration.parse("-P1DT1H1M30S")).apply { + getRequiredProperty(TRIGGER).add(Related.END) + } + val refEnd = DtStart(currentTime) + val allowRelEnd = true + + val (ref, min) = alarmTriggerToMinutes( + alarm = alarm, + refStart = null, + refEnd = refEnd, + allowRelEnd = allowRelEnd + )!! + + assertEquals(Related.END, ref) + assertEquals(60 * 24 + 60 + 1, min) + } + + @Test + fun `trigger relative to end with allowRelEnd=false`() { + 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 = alarm, + refStart = refStart, + refEnd = refEnd, + 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 `trigger relative to end without start time and with allowRelEnd=false`() { + val alarm = VAlarm(JavaDuration.parse("-PT65S")).apply { + getRequiredProperty(TRIGGER).add(Related.END) + } + val refStart = null + val refEnd = DtEnd(currentTime) + val allowRelEnd = false + + val result = alarmTriggerToMinutes( + alarm = alarm, + refStart = refStart, + refEnd = refEnd, + allowRelEnd = allowRelEnd + ) + + assertNull(result) + } + + @Test + fun `trigger relative to end without end time or duration and with allowRelEnd=false`() { + 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, + allowRelEnd = allowRelEnd + ) + + assertNull(result) + } + + @Test + fun `trigger relative to end and after end date with allowRelEnd=false`() { + 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 = alarm, + refStart = refStart, + refEnd = refEnd, + allowRelEnd = allowRelEnd + )!! + + assertEquals(Related.START, ref) + assertEquals(-(1.days.toMinutes() + 1.hours.toMinutes() + 1 + 1) /* duration of event: */ - 1, min) + } + + @Test + fun `trigger with Period instance`() { + val alarm = VAlarm(Period.parse("-P1W1D")) + val refStart = DtStart(dateTimeValue("20260508T120000", tzVienna)) + + val (ref, min) = alarmTriggerToMinutes( + alarm = alarm, + refStart = refStart, + refEnd = null, + allowRelEnd = false + )!! + + assertEquals(Related.START, ref) + assertEquals(8.days.toMinutes(), min) + } + + @Test + fun `trigger with DATE-TIME value`() { + // 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, + allowRelEnd = true + )!! + + assertEquals(Related.START, ref) + assertEquals(1, min) + } + + @Test + fun `refStart has DATE value`() { + val alarm = VAlarm(JavaDuration.parse("-PT5M")) + val refStart = DtStart(dateValue("20260407")) + + val (ref, min) = alarmTriggerToMinutes( + alarm = alarm, + refStart = refStart, + refEnd = null, + allowRelEnd = true + )!! + + assertEquals(Related.START, ref) + assertEquals(5, min) + } + + @Test + 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 refStart = DtStart(dateValue("20260407")) + val refEnd = DtStart(dateValue("20260408")) + val allowRelEnd = false + + val (ref, min) = alarmTriggerToMinutes( + alarm = alarm, + refStart = refStart, + refEnd = refEnd, + allowRelEnd = allowRelEnd + )!! + + assertEquals(Related.START, ref) + assertEquals(-(1.days - 5.minutes).toMinutes(), min) + } + + @Test + fun `trigger with DATE-TIME value and refStart with DATE value`() { + val alarm = VAlarm(dateTimeValue("20260406T120000", ZoneOffset.UTC).toInstant()) + val refStart = DtStart(dateValue("20260407")) + + val (ref, min) = alarmTriggerToMinutes( + alarm = alarm, + refStart = refStart, + refEnd = null, + allowRelEnd = true + )!! + + assertEquals(Related.START, ref) + assertEquals(12.hours.toMinutes(), min) + } + + @Test + 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 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(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) + } + + @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()