diff --git a/lib/src/main/kotlin/at/bitfire/ical4android/util/TimeApiExtensions.kt b/lib/src/main/kotlin/at/bitfire/ical4android/util/TimeApiExtensions.kt index a6f0dc9f..786723c7 100644 --- a/lib/src/main/kotlin/at/bitfire/ical4android/util/TimeApiExtensions.kt +++ b/lib/src/main/kotlin/at/bitfire/ical4android/util/TimeApiExtensions.kt @@ -14,11 +14,14 @@ import net.fortuna.ical4j.util.TimeZones import java.time.Duration import java.time.Instant import java.time.LocalDate +import java.time.LocalDateTime import java.time.LocalTime +import java.time.OffsetDateTime import java.time.Period import java.time.ZoneId import java.time.ZoneOffset import java.time.ZonedDateTime +import java.time.temporal.Temporal import java.time.temporal.TemporalAmount import java.util.Calendar import java.util.TimeZone @@ -218,4 +221,21 @@ object TimeApiExtensions { return builder.toString() } + + /***** Temporals *****/ + + /** + * Gets the [LocalDate] part of this [Temporal] instance. + */ + fun Temporal.toLocalDate(): LocalDate { + return when (this) { + is LocalDate -> this + is LocalDateTime -> toLocalDate() + is OffsetDateTime -> toLocalDate() + is ZonedDateTime -> toLocalDate() + is Instant -> LocalDate.ofInstant(this, ZoneOffset.UTC) + else -> error("Unsupported Temporal type: ${this::class.qualifiedName}") + } + } + } \ No newline at end of file diff --git a/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/builder/AndroidRecurrenceMapper.kt b/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/builder/AndroidRecurrenceMapper.kt new file mode 100644 index 00000000..8b8fa309 --- /dev/null +++ b/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/builder/AndroidRecurrenceMapper.kt @@ -0,0 +1,134 @@ +/* + * 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.mapping.calendar.builder + +import at.bitfire.ical4android.util.DateUtils +import at.bitfire.synctools.mapping.calendar.builder.AndroidTemporalMapper.toTimestamp +import at.bitfire.synctools.mapping.calendar.builder.AndroidTemporalMapper.toZonedDateTime +import net.fortuna.ical4j.model.Property +import java.time.Instant +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.ZoneOffset +import java.time.ZonedDateTime +import java.time.format.DateTimeFormatter +import java.time.temporal.Temporal +import java.util.Locale + +object AndroidRecurrenceMapper { + + private const val RECURRENCE_LIST_TZID_SEPARATOR = ';' + private const val RECURRENCE_LIST_VALUE_SEPARATOR = "," + + /** + * Used to separate multiple RRULEs/EXRULEs in the RRULE/EXRULE storage field. + */ + private const val RECURRENCE_RULE_SEPARATOR = "\n" + + + /** + * Format multiple RRULEs/EXRULEs as string to be used with Android's calendar provider. + */ + fun androidRecurrenceRuleString(rules: List): String { + return rules.joinToString(RECURRENCE_RULE_SEPARATOR) { it.value } + } + + /** + * Concatenates, if necessary, multiple RDATE/EXDATE lists and converts them to + * a formatted string which Android calendar provider can process. + * + * Android [expects this format](https://android.googlesource.com/platform/frameworks/opt/calendar/+/68b3632330e7a9a4f9813b7eb671dbfd78c25bcd/src/com/android/calendarcommon2/RecurrenceSet.java#138): + * `[TZID;]date1,date2,date3` where date is `yyyymmddThhmmss` (when + * TZID is given) or `yyyymmddThhmmssZ`. + * + * This method converts the values to the type of [startDate], if necessary: + * + * - DTSTART (DATE-TIME) and RDATE/EXDATE (DATE) → method converts RDATE/EXDATE to DATE-TIME with same time as DTSTART + * - DTSTART (DATE) and RDATE/EXDATE (DATE-TIME) → method converts RDATE/EXDATE to DATE (just drops time) + * + * @param dates list of `Temporal`s from RDATE or EXDATE properties + * @param startDate used to determine whether the event is an all-day event or not; also used to + * generate the date-time if the event is not all-day but the exception is + * + * @return formatted string for Android calendar provider + */ + fun androidRecurrenceDatesString(dates: List, startDate: Temporal): String { + /* rdate/exdate: DATE DATE_TIME + all-day store as ...T000000Z cut off time and store as ...T000000Z + event with time use time and zone from DTSTART store as ...ThhmmssZ + */ + val utcDateFormatter = DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss'Z'", Locale.ROOT) + val dateFormatter = DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss", Locale.ROOT) + val convertedDates = mutableListOf() + val allDay = DateUtils.isDate(startDate) + + // use time zone of first entry for the whole set; null for UTC + val zoneId = (dates.firstOrNull() as? ZonedDateTime)?.zone + + for (date in dates) { + if (date is LocalDate) { + // RDATE/EXDATE is DATE + if (allDay) { + // DTSTART is DATE; DATE values have to be returned as T000000Z for Android + + convertedDates.add(date.atStartOfDay().atZone(ZoneOffset.UTC)) + } else { + // DTSTART is DATE-TIME; amend DATE-TIME with clock time from DTSTART + val zonedStartDate = startDate.toZonedDateTime() + val amendedDate = ZonedDateTime.of( + date, + zonedStartDate.toLocalTime(), + zonedStartDate.zone + ) + + val convertedDate = if (zoneId != null) { + amendedDate.withZoneSameInstant(zoneId) + } else { + amendedDate.withZoneSameInstant(ZoneOffset.UTC) + } + + convertedDates.add(convertedDate) + } + + } else { + // RDATE/EXDATE is DATE-TIME + val convertedDate = if (allDay) { + // DTSTART is DATE + val localDate = if (date is LocalDateTime) { + date.toLocalDate() + } else { + Instant.ofEpochMilli(date.toTimestamp()).atZone(ZoneOffset.UTC).toLocalDate() + } + localDate.atStartOfDay().atZone(ZoneOffset.UTC) + } else { + // DTSTART is DATE-TIME + val instant = Instant.ofEpochMilli(date.toTimestamp()) + if (zoneId != null) { + instant.atZone(zoneId) + } else { + instant.atZone(ZoneOffset.UTC) + } + } + convertedDates.add(convertedDate) + } + } + + // format expected by Android: [tzid;]value1,value2,... + return buildString { + if (zoneId != null) { + append(zoneId.id) + append(RECURRENCE_LIST_TZID_SEPARATOR) + } + + val formatter = if (zoneId == null) utcDateFormatter else dateFormatter + convertedDates.joinTo(buffer = this, separator = RECURRENCE_LIST_VALUE_SEPARATOR) { + formatter.format(it) + } + } + } + +} \ No newline at end of file diff --git a/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/builder/DurationBuilder.kt b/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/builder/DurationBuilder.kt index 41a209b5..ffaabe7e 100644 --- a/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/builder/DurationBuilder.kt +++ b/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/builder/DurationBuilder.kt @@ -14,16 +14,19 @@ import at.bitfire.ical4android.util.TimeApiExtensions.abs import at.bitfire.ical4android.util.TimeApiExtensions.toDuration import at.bitfire.ical4android.util.TimeApiExtensions.toLocalDate import at.bitfire.ical4android.util.TimeApiExtensions.toRfc5545Duration +import at.bitfire.synctools.icalendar.DatePropertyTzMapper.normalizedDate import at.bitfire.synctools.icalendar.requireDtStart +import at.bitfire.synctools.mapping.calendar.builder.AndroidTemporalMapper.toTimestamp import net.fortuna.ical4j.model.Property import net.fortuna.ical4j.model.component.VEvent -import net.fortuna.ical4j.model.property.DtEnd -import net.fortuna.ical4j.model.property.DtStart import net.fortuna.ical4j.model.property.RDate import net.fortuna.ical4j.model.property.RRule import java.time.Duration +import java.time.Instant import java.time.Period +import java.time.temporal.Temporal import java.time.temporal.TemporalAmount +import kotlin.jvm.optionals.getOrNull class DurationBuilder: AndroidEntityBuilder { @@ -35,23 +38,22 @@ class DurationBuilder: AndroidEntityBuilder { - DURATION when the event is recurring. So we'll skip if this event is not a recurring main event (only main events can be recurring). */ - TODO("ical4j 4.x") - /*val rRules = from.getProperties(Property.RRULE) - val rDates = from.getProperties(Property.RDATE) + val rRules = from.getProperties>(Property.RRULE) + val rDates = from.getProperties>(Property.RDATE) if (from !== main || (rRules.isEmpty() && rDates.isEmpty())) { values.putNull(Events.DURATION) return } - val dtStart = from.requireDtStart() + val startDate = from.requireDtStart().normalizedDate() // calculate DURATION from DTEND - DTSTART, if necessary val calculatedDuration = from.duration?.duration - ?: calculateFromDtEnd(dtStart, from.endDate) // ignores DTEND < DTSTART + ?: calculateFromDtEnd(startDate, from.getEndDate(false).getOrNull()?.normalizedDate()) // ignores DTEND < DTSTART // use default duration, if necessary val duration = calculatedDuration?.abs() // always use positive duration - ?: defaultDuration(DateUtils.isDate(dtStart)) + ?: defaultDuration(DateUtils.isDate(startDate)) /* [RFC 5545 3.8.2.5] > When the "DURATION" property relates to a "DTSTART" property that is specified as a DATE value, then the @@ -61,32 +63,31 @@ class DurationBuilder: AndroidEntityBuilder { so we wouldn't have to take care of that. However it expects seconds to be in "PS" format, whereas we provide an RFC 5545-compliant "PTS", which causes the provider to crash: https://github.com/bitfireAT/synctools/issues/144. So we must convert it ourselves to be on the safe side. */ - val alignedDuration = alignWithDtStart(duration, dtStart) + val alignedDuration = alignWithDtStart(duration, startDate) /* TemporalAmount can have months and years, but the RFC 5545 value must only contain weeks, days and time. So we have to recalculate the months/years to days according to their position in the calendar. The calendar provider accepts every DURATION that `com.android.calendarcommon2.Duration` can parse, which is weeks, days, hours, minutes and seconds, like for the RFC 5545 duration. */ - val durationStr = alignedDuration.toRfc5545Duration(dtStart.date.toInstant()) - values.put(Events.DURATION, durationStr)*/ + val durationStr = alignedDuration.toRfc5545Duration(Instant.ofEpochMilli(startDate.toTimestamp())) + values.put(Events.DURATION, durationStr) } /** * Aligns the given temporal amount (taken from DURATION) to the VALUE-type (DATE-TIME/DATE) of DTSTART. * * @param amount temporal amount that shall be aligned - * @param dtStart DTSTART to compare with + * @param startDate DTSTART to compare with * * @return Temporal amount that is * - * - a [Period] (days/months/years that can't be represented by an exact number of seconds) when [dtStart] is a DATE, and - * - a [Duration] (exact time that can be represented by an exact number of seconds) when [dtStart] is a DATE-TIME. + * - a [Period] (days/months/years that can't be represented by an exact number of seconds) when [startDate] is a DATE, and + * - a [Duration] (exact time that can be represented by an exact number of seconds) when [startDate] is a DATE-TIME. */ @VisibleForTesting - internal fun alignWithDtStart(amount: TemporalAmount, dtStart: DtStart<*>): TemporalAmount { - TODO("ical4j 4.x") - /*if (DateUtils.isDate(dtStart)) { + internal fun alignWithDtStart(amount: TemporalAmount, startDate: Temporal): TemporalAmount { + if (DateUtils.isDate(startDate)) { // DTSTART is DATE return if (amount is Duration) { // amount is Duration, change to Period of days instead @@ -100,41 +101,40 @@ class DurationBuilder: AndroidEntityBuilder { // DTSTART is DATE-TIME return if (amount is Period) { // amount is Period, change to Duration instead - amount.toDuration(dtStart.date.toInstant()) + amount.toDuration(Instant.ofEpochMilli(startDate.toTimestamp())) } else { // amount is already Duration amount } - }*/ + } } /** * Calculates the DURATION from DTEND - DTSTART, if possible. * - * @param dtStart start date/date-time - * @param dtEnd (optional) end date/date-time (ignored if not after [dtStart]) + * @param startDate start date/date-time + * @param endDate (optional) end date/date-time (ignored if not after [startDate]) * * @return temporal amount ([Period] or [Duration]) or `null` if no valid end time was available */ @VisibleForTesting - internal fun calculateFromDtEnd(dtStart: DtStart<*>, dtEnd: DtEnd<*>?): TemporalAmount? { - TODO("ical4j 4.x") - /*if (dtEnd == null || dtEnd.date.toInstant() <= dtStart.date.toInstant()) + internal fun calculateFromDtEnd(startDate: Temporal, endDate: Temporal?): TemporalAmount? { + if (endDate == null || endDate.toTimestamp() <= startDate.toTimestamp()) return null - return if (DateUtils.isDateTime(dtStart) && DateUtils.isDateTime(dtEnd)) { + return if (DateUtils.isDateTime(startDate) && DateUtils.isDateTime(endDate)) { // DTSTART and DTEND are DATE-TIME → calculate difference between timestamps - val seconds = (dtEnd.date.time - dtStart.date.time) / 1000 + val seconds = (endDate.toTimestamp() - startDate.toTimestamp()) / 1000L Duration.ofSeconds(seconds) } else { // Either DTSTART or DTEND or both are DATE: // - DTSTART and DTEND are DATE → DURATION is exact number of days (no time part) // - DTSTART is DATE, DTEND is DATE-TIME → only use date part of DTEND → DURATION is exact number of days (no time part) // - DTSTART is DATE-TIME, DTEND is DATE → amend DTEND with time of DTSTART → DURATION is exact number of days (no time part) - val startDate = dtStart.date.toLocalDate() - val endDate = dtEnd.date.toLocalDate() - Period.between(startDate, endDate) - }*/ + val dateStart = startDate.toLocalDate() + val dateEnd = endDate.toLocalDate() + Period.between(dateStart, dateEnd) + } } private fun defaultDuration(allDay: Boolean): TemporalAmount = diff --git a/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/builder/OriginalInstanceTimeBuilder.kt b/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/builder/OriginalInstanceTimeBuilder.kt index 8c91d633..6e2b36a7 100644 --- a/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/builder/OriginalInstanceTimeBuilder.kt +++ b/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/builder/OriginalInstanceTimeBuilder.kt @@ -14,51 +14,52 @@ import at.bitfire.ical4android.util.TimeApiExtensions.toIcal4jDate import at.bitfire.ical4android.util.TimeApiExtensions.toIcal4jDateTime import at.bitfire.ical4android.util.TimeApiExtensions.toLocalDate import at.bitfire.ical4android.util.TimeApiExtensions.toLocalTime +import at.bitfire.synctools.icalendar.DatePropertyTzMapper.normalizedDate import at.bitfire.synctools.icalendar.recurrenceId import at.bitfire.synctools.icalendar.requireDtStart +import at.bitfire.synctools.mapping.calendar.builder.AndroidTemporalMapper.toTimestamp +import at.bitfire.synctools.mapping.calendar.builder.AndroidTemporalMapper.toZonedDateTime import net.fortuna.ical4j.model.Date import net.fortuna.ical4j.model.DateTime import net.fortuna.ical4j.model.component.VEvent +import java.time.LocalDate import java.time.ZonedDateTime +import java.time.temporal.Temporal class OriginalInstanceTimeBuilder: AndroidEntityBuilder { override fun build(from: VEvent, main: VEvent, to: Entity) { - TODO("ical4j 4.x") - - /*val values = to.entityValues + val values = to.entityValues if (from !== main) { // only for exceptions - val originalDtStart = main.requireDtStart() + val originalDtStart = main.requireDtStart() values.put(Events.ORIGINAL_ALL_DAY, if (DateUtils.isDate(originalDtStart)) 1 else 0) - var recurrenceDate = from.recurrenceId?.date - val originalDate = originalDtStart.date + var recurrenceDate = from.recurrenceId?.normalizedDate() + val originalDate = originalDtStart.normalizedDate() // rewrite recurrenceDate, if necessary - if (recurrenceDate is DateTime && originalDate != null && originalDate !is DateTime) { + if (DateUtils.isDateTime(recurrenceDate) && DateUtils.isDate(originalDate)) { // rewrite RECURRENCE-ID;VALUE=DATE-TIME to VALUE=DATE for all-day events - val localDate = recurrenceDate.toLocalDate() - recurrenceDate = Date(localDate.toIcal4jDate()) + recurrenceDate = recurrenceDate!!.toZonedDateTime().toLocalDate() - } else if (recurrenceDate != null && recurrenceDate !is DateTime && originalDate is DateTime) { + } else if (recurrenceDate is LocalDate && DateUtils.isDateTime(originalDate)) { // rewrite RECURRENCE-ID;VALUE=DATE to VALUE=DATE-TIME for non-all-day-events - val localDate = recurrenceDate.toLocalDate() // guess time and time zone from DTSTART - val zonedTime = ZonedDateTime.of( - localDate, - originalDate.toLocalTime(), - originalDate.requireZoneId() + val zonedDateTime = originalDate.toZonedDateTime() + recurrenceDate = ZonedDateTime.of( + recurrenceDate, + zonedDateTime.toLocalTime(), + zonedDateTime.zone ) - recurrenceDate = zonedTime.toIcal4jDateTime() } - values.put(Events.ORIGINAL_INSTANCE_TIME, recurrenceDate?.time) + values.put(Events.ORIGINAL_INSTANCE_TIME, recurrenceDate?.toTimestamp()) } else { // main event values.putNull(Events.ORIGINAL_ALL_DAY) values.putNull(Events.ORIGINAL_INSTANCE_TIME) - }*/ + } } } \ No newline at end of file diff --git a/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/builder/RecurrenceFieldsBuilder.kt b/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/builder/RecurrenceFieldsBuilder.kt index e0020a5b..02935d89 100644 --- a/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/builder/RecurrenceFieldsBuilder.kt +++ b/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/builder/RecurrenceFieldsBuilder.kt @@ -8,16 +8,20 @@ package at.bitfire.synctools.mapping.calendar.builder import android.content.Entity import android.provider.CalendarContract.Events +import at.bitfire.synctools.icalendar.DatePropertyTzMapper.normalizedDate +import at.bitfire.synctools.icalendar.DatePropertyTzMapper.normalizedDates import at.bitfire.synctools.icalendar.requireDtStart -import at.bitfire.synctools.util.AndroidTimeUtils -import net.fortuna.ical4j.model.DateList +import at.bitfire.synctools.mapping.calendar.builder.AndroidRecurrenceMapper.androidRecurrenceRuleString +import at.bitfire.synctools.mapping.calendar.builder.AndroidRecurrenceMapper.androidRecurrenceDatesString import net.fortuna.ical4j.model.Property import net.fortuna.ical4j.model.component.VEvent import net.fortuna.ical4j.model.property.ExDate import net.fortuna.ical4j.model.property.ExRule import net.fortuna.ical4j.model.property.RDate import net.fortuna.ical4j.model.property.RRule +import java.time.temporal.Temporal import java.util.logging.Logger +import kotlin.jvm.optionals.getOrDefault class RecurrenceFieldsBuilder: AndroidEntityBuilder { @@ -27,56 +31,67 @@ class RecurrenceFieldsBuilder: AndroidEntityBuilder { override fun build(from: VEvent, main: VEvent, to: Entity) { val values = to.entityValues - TODO("ical4j 4.x") - /*val rRules = from.getProperties(Property.RRULE) - val rDates = from.getProperties(Property.RDATE) + val rRules = from.getProperties>(Property.RRULE) + val rDates = from.getProperties>(Property.RDATE) val recurring = rRules.isNotEmpty() || rDates.isNotEmpty() if (recurring && from === main) { // generate recurrence fields only for recurring main events - val dtStart = from.requireDtStart() + val startDate = from.requireDtStart().normalizedDate() // RRULE if (rRules.isNotEmpty()) - values.put(Events.RRULE, rRules.joinToString(AndroidTimeUtils.RECURRENCE_RULE_SEPARATOR) { it.value }) + values.put(Events.RRULE, androidRecurrenceRuleString(rRules)) else values.putNull(Events.RRULE) // RDATE (start with null value) - values.putNull(Events.RDATE) if (rDates.isNotEmpty()) { // ignore RDATEs when there's also an infinite RRULE [https://issuetracker.google.com/issues/216374004] val infiniteRrule = rRules.any { rRule -> rRule.recur.count == -1 && // no COUNT AND rRule.recur.until == null // no UNTIL } - if (infiniteRrule) + + if (infiniteRrule) { logger.warning("Android can't handle infinite RRULE + RDATE [https://issuetracker.google.com/issues/216374004]; ignoring RDATE(s)") - else { - for (rDate in rDates) - AndroidTimeUtils.androidifyTimeZone(rDate) + values.putNull(Events.RDATE) + + } else { + val normalizedRDates = rDates.flatMap { rDate -> + if (rDate.periods.getOrDefault(emptySet()).isNotEmpty()) { + logger.warning("RDATE PERIOD not supported, ignoring") + emptyList() + } else { + rDate.normalizedDates() + } + } - // Calendar provider drops DTSTART instance when using RDATE [https://code.google.com/p/android/issues/detail?id=171292] - val listWithDtStart = DateList() - listWithDtStart.add(dtStart.date) - rDates.add(0, RDate(listWithDtStart)) + if (normalizedRDates.isNotEmpty()) { + // Calendar provider drops DTSTART instance when using RDATE [https://code.google.com/p/android/issues/detail?id=171292] + val listWithDtStart = listOf(startDate) + normalizedRDates + val recurrenceDates = androidRecurrenceDatesString(listWithDtStart, startDate) - values.put(Events.RDATE, AndroidTimeUtils.recurrenceSetsToAndroidString(rDates, dtStart.date)) + values.put(Events.RDATE, recurrenceDates) + } else { + values.putNull(Events.RDATE) + } } + } else { + values.putNull(Events.RDATE) } // EXRULE - val exRules = from.getProperties(Property.EXRULE) + val exRules = from.getProperties>(Property.EXRULE) if (exRules.isNotEmpty()) - values.put(Events.EXRULE, exRules.joinToString(AndroidTimeUtils.RECURRENCE_RULE_SEPARATOR) { it.value }) + values.put(Events.EXRULE, androidRecurrenceRuleString(exRules)) else values.putNull(Events.EXRULE) // EXDATE - val exDates = from.getProperties(Property.EXDATE) + val exDates = from.getProperties>(Property.EXDATE) if (exDates.isNotEmpty()) { - for (exDate in exDates) - AndroidTimeUtils.androidifyTimeZone(exDate) - values.put(Events.EXDATE, AndroidTimeUtils.recurrenceSetsToAndroidString(exDates, dtStart.date)) + val normalizedExDates = exDates.flatMap { exDate -> exDate.normalizedDates() } + values.put(Events.EXDATE, androidRecurrenceDatesString(normalizedExDates, startDate)) } else values.putNull(Events.EXDATE) @@ -85,7 +100,7 @@ class RecurrenceFieldsBuilder: AndroidEntityBuilder { values.putNull(Events.EXRULE) values.putNull(Events.RDATE) values.putNull(Events.EXDATE) - }*/ + } } } \ No newline at end of file diff --git a/lib/src/main/kotlin/at/bitfire/synctools/util/AndroidTimeUtils.kt b/lib/src/main/kotlin/at/bitfire/synctools/util/AndroidTimeUtils.kt index 6a023927..6bf84cf5 100644 --- a/lib/src/main/kotlin/at/bitfire/synctools/util/AndroidTimeUtils.kt +++ b/lib/src/main/kotlin/at/bitfire/synctools/util/AndroidTimeUtils.kt @@ -7,9 +7,7 @@ package at.bitfire.synctools.util import at.bitfire.ical4android.util.TimeApiExtensions -import net.fortuna.ical4j.model.Date import net.fortuna.ical4j.model.DateList -import net.fortuna.ical4j.model.DateTime import net.fortuna.ical4j.model.TemporalAdapter import net.fortuna.ical4j.model.TemporalAmountAdapter import net.fortuna.ical4j.model.TimeZone @@ -17,14 +15,11 @@ import net.fortuna.ical4j.model.TimeZoneRegistry import net.fortuna.ical4j.model.property.DateListProperty import net.fortuna.ical4j.model.property.DateProperty import net.fortuna.ical4j.model.property.RDate -import java.text.SimpleDateFormat import java.time.Duration import java.time.OffsetDateTime import java.time.Period import java.time.temporal.ChronoField import java.time.temporal.TemporalAmount -import java.util.LinkedList -import java.util.Locale import java.util.logging.Logger import kotlin.jvm.optionals.getOrDefault @@ -38,11 +33,6 @@ object AndroidTimeUtils { private const val RECURRENCE_LIST_TZID_SEPARATOR = ';' private const val RECURRENCE_LIST_VALUE_SEPARATOR = "," - /** - * Used to separate multiple RRULEs/EXRULEs in the RRULE/EXRULE storage field. - */ - const val RECURRENCE_RULE_SEPARATOR = "\n" - private val logger get() = Logger.getLogger(javaClass.name) @@ -95,94 +85,6 @@ object AndroidTimeUtils { // recurrence sets - /** - * Concatenates, if necessary, multiple RDATE/EXDATE lists and converts them to - * a formatted string which Android calendar provider can process. - * - * Android [expects this format](https://android.googlesource.com/platform/frameworks/opt/calendar/+/68b3632330e7a9a4f9813b7eb671dbfd78c25bcd/src/com/android/calendarcommon2/RecurrenceSet.java#138): - * `[TZID;]date1,date2,date3` where date is `yyyymmddThhmmss` (when - * TZID is given) or `yyyymmddThhmmssZ`. We don't use the TZID format here because then we're limited - * to one time-zone, while an iCalendar may contain multiple EXDATE/RDATE lines with different time zones. - * - * This method converts the values to the type of [dtStart], if necessary: - * - * - DTSTART (DATE-TIME) and RDATE/EXDATE (DATE) → method converts RDATE/EXDATE to DATE-TIME with same time as DTSTART - * - DTSTART (DATE) and RDATE/EXDATE (DATE-TIME) → method converts RDATE/EXDATE to DATE (just drops time) - * - * @param dates one more more lists of RDATE or EXDATE - * @param dtStart used to determine whether the event is an all-day event or not; also used to - * generate the date-time if the event is not all-day but the exception is - * - * @return formatted string for Android calendar provider - */ - fun recurrenceSetsToAndroidString(dates: List>, dtStart: Date): String { - /* rdate/exdate: DATE DATE_TIME - all-day store as ...T000000Z cut off time and store as ...T000000Z - event with time (undefined) store as ...ThhmmssZ - */ - val dateFormatUtcMidnight = SimpleDateFormat("yyyyMMdd'T'000000'Z'", Locale.ROOT) - val strDates = LinkedList() - val allDay = dtStart !is DateTime - - // use time zone of first entry for the whole set; null for UTC - TODO("ical4j 4.x") - /*val tz = - (dates.firstOrNull() as? RDate)?.periods?.timeZone ?: // VALUE=PERIOD (only RDate) - dates.firstOrNull()?.dates?.timeZone // VALUE=DATE/DATE-TIME - - for (dateListProp in dates) { - if (dateListProp is RDate && dateListProp.periods.isNotEmpty()) { - logger.warning("RDATE PERIOD not supported, ignoring") - break - } - - when (dateListProp.dates.type) { - Value.DATE_TIME -> { // RDATE/EXDATE is DATE-TIME - if (tz == null && !dateListProp.dates.isUtc) - dateListProp.setUtc(true) - else if (tz != null && dateListProp.timeZone != tz) - dateListProp.timeZone = tz - - if (allDay) - // DTSTART is DATE - dateListProp.dates.mapTo(strDates) { dateFormatUtcMidnight.format(it) } - else - // DTSTART is DATE-TIME - strDates.add(dateListProp.value) - } - Value.DATE -> // RDATE/EXDATE is DATE - if (allDay) { - // DTSTART is DATE; DATE values have to be returned as T000000Z for Android - dateListProp.dates.mapTo(strDates) { date -> - dateFormatUtcMidnight.format(date) - } - } else { - // DTSTART is DATE-TIME; amend DATE-TIME with clock time from dtStart - dateListProp.dates.mapTo(strDates) { date -> - // take time (including time zone) from dtStart and date from date - val dtStartTime = dtStart.toZonedDateTime() - val localDate = date.toLocalDate() - val dtStartTimeUtc = ZonedDateTime.of( - localDate, - dtStartTime.toLocalTime(), - dtStartTime.zone - ).withZoneSameInstant(ZoneOffset.UTC) - - val dateFormatUtc = DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss'Z'", Locale.ROOT) - dtStartTimeUtc.format(dateFormatUtc) - } - } - } - } - - // format expected by Android: [tzid;]value1,value2,... - val result = StringBuilder() - if (tz != null) - result.append(tz.id).append(RECURRENCE_LIST_TZID_SEPARATOR) - result.append(strDates.joinToString(RECURRENCE_LIST_VALUE_SEPARATOR)) - return result.toString()*/ - } - /** * Takes a formatted string as provided by the Android calendar provider and returns a DateListProperty * constructed from these values. diff --git a/lib/src/test/kotlin/at/bitfire/ical4android/util/TimeApiExtensionsTest.kt b/lib/src/test/kotlin/at/bitfire/ical4android/util/TimeApiExtensionsTest.kt index 5574d9d2..2a39dde9 100644 --- a/lib/src/test/kotlin/at/bitfire/ical4android/util/TimeApiExtensionsTest.kt +++ b/lib/src/test/kotlin/at/bitfire/ical4android/util/TimeApiExtensionsTest.kt @@ -23,16 +23,20 @@ import net.fortuna.ical4j.model.TimeZoneRegistryFactory import net.fortuna.ical4j.util.TimeZones import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue +import org.junit.Assert.fail import org.junit.Test import java.time.DayOfWeek import java.time.Duration import java.time.Instant import java.time.LocalDate +import java.time.LocalDateTime import java.time.LocalTime +import java.time.OffsetDateTime import java.time.Period import java.time.ZoneId import java.time.ZoneOffset import java.time.ZonedDateTime +import java.time.chrono.JapaneseDate class TimeApiExtensionsTest { @@ -240,4 +244,61 @@ class TimeApiExtensionsTest { assertEquals("P30D", Period.ofMonths(1).toRfc5545Duration(date20200601)) } + + @Test + fun `LocalDate_toLocalDate()`() { + val localDate = LocalDate.now() + + val result = localDate.toLocalDate() + + assertEquals(localDate, result) + } + + @Test + fun `LocalDateTime_toLocalDate()`() { + val localDateTime = LocalDateTime.of(2026, 3, 17, 0, 0, 0) + + val result = localDateTime.toLocalDate() + + assertEquals(LocalDate.of(2026, 3, 17), result) + } + + @Test + fun `OffsetDateTime_toLocalDate()`() { + val offsetDateTime = OffsetDateTime.of(2026, 3, 17, 0, 0, 0, 0, ZoneOffset.UTC) + + val result = offsetDateTime.toLocalDate() + + assertEquals(LocalDate.of(2026, 3, 17), result) + } + + @Test + fun `ZonedDateTime_toLocalDate()`() { + val zonedDateTime = ZonedDateTime.of(2026, 3, 17, 0, 0, 0, 0, ZoneOffset.UTC) + + val result = zonedDateTime.toLocalDate() + + assertEquals(LocalDate.of(2026, 3, 17), result) + } + + @Test + fun `Instant_toLocalDate()`() { + val instant = Instant.ofEpochSecond(1773754730) + + val result = instant.toLocalDate() + + assertEquals(LocalDate.of(2026, 3, 17), result) + } + + @Test + fun `toLocalDate() on unsupported type`() { + try { + JapaneseDate.now().toLocalDate() + + fail("Expected exception") + } catch (e: IllegalStateException) { + assertEquals("Unsupported Temporal type: java.time.chrono.JapaneseDate", e.message) + } + } + } \ No newline at end of file diff --git a/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/builder/AndroidRecurrenceMapperTest.kt b/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/builder/AndroidRecurrenceMapperTest.kt new file mode 100644 index 00000000..0757ebb5 --- /dev/null +++ b/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/builder/AndroidRecurrenceMapperTest.kt @@ -0,0 +1,123 @@ +/* + * 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.mapping.calendar.builder + +import at.bitfire.dateTimeValue +import at.bitfire.dateValue +import at.bitfire.synctools.mapping.calendar.builder.AndroidRecurrenceMapper.androidRecurrenceDatesString +import net.fortuna.ical4j.model.TimeZone +import net.fortuna.ical4j.model.TimeZoneRegistryFactory +import org.junit.Assert.assertEquals +import org.junit.Test + +class AndroidRecurrenceMapperTest { + + val tzRegistry = TimeZoneRegistryFactory.getInstance().createRegistry()!! + val tzBerlin: TimeZone = tzRegistry.getTimeZone("Europe/Berlin")!! + val tzToronto: TimeZone = tzRegistry.getTimeZone("America/Toronto")!! + + @Test + fun testAndroidRecurrenceDatesString_Date() { + // DATEs (without time) have to be converted to THHmmssZ for Android + val dates = listOf(dateValue("20150101"), dateValue("20150702")) + val startDate = dateValue("20150101") + + val result = androidRecurrenceDatesString(dates, startDate) + + assertEquals("20150101T000000Z,20150702T000000Z", result) + } + + @Test + fun testAndroidRecurrenceDatesString_Date_AlthoughDtStartIsDateTime() { + // DATEs (without time) have to be converted to THHmmssZ for Android + val dates = listOf(dateValue("20150101"), dateValue("20150702")) + val startDate = dateTimeValue("20150101T043210", tzBerlin) + + val result = androidRecurrenceDatesString(dates, startDate) + + assertEquals("20150101T033210Z,20150702T023210Z", result) + } + + @Test + fun testAndroidRecurrenceDatesString_Date_AlthoughDtStartIsDateTime_MonthWithLessDays() { + // DATEs (without time) have to be converted to THHmmssZ for Android + val dates = listOf(dateValue("20240531")) + val startDate = dateTimeValue("20240401T114500", tzBerlin) + + val result = androidRecurrenceDatesString(dates, startDate) + + assertEquals("20240531T094500Z", result) + } + + @Test + fun testAndroidRecurrenceDatesString_Time_AlthoughDtStartIsAllDay() { + // DATE-TIME (floating time or UTC) recurrences for all-day events have to be converted to T000000Z for Android + val dates = listOf(dateTimeValue("20150101T000000"), dateTimeValue("20150702T000000Z")) + val startDate = dateValue("20150101") + + val result = androidRecurrenceDatesString(dates, startDate) + + assertEquals("20150101T000000Z,20150702T000000Z", result) + } + + @Test + fun testAndroidRecurrenceDatesString_TwoTimesWithSameTimezone() { + // two separate entries, both with timezone Toronto + val dates = listOf( + dateTimeValue("20150103T113030", tzToronto), + dateTimeValue("20150704T113040", tzToronto), + ) + val startDate = dateTimeValue("20150103T113030", tzToronto) + + val result = androidRecurrenceDatesString(dates, startDate) + + assertEquals("America/Toronto;20150103T113030,20150704T113040", result) + } + + @Test + fun testAndroidRecurrenceDatesString_TwoTimesWithDifferentTimezone() { + // two separate entries, one with timezone Toronto, one with Berlin + // 2015/01/03 11:30:30 Toronto [-5] = 2015/01/03 16:30:30 UTC + // DST: 2015/07/04 11:30:40 Berlin [+2] = 2015/07/04 09:30:40 UTC = 2015/07/04 05:30:40 Toronto [-4] + val dates = listOf( + dateTimeValue("20150103T113030", tzToronto), + dateTimeValue("20150704T113040", tzBerlin), + ) + val startDate = dateTimeValue("20150103T113030", tzToronto) + + val result = androidRecurrenceDatesString(dates, startDate) + + assertEquals("America/Toronto;20150103T113030,20150704T053040", result) + } + + @Test + fun testAndroidRecurrenceDatesString_TwoTimesWithOneUtc() { + // two separate entries, one with timezone Toronto, one with Berlin + // 2015/01/03 11:30:30 Toronto [-5] = 2015/01/03 16:30:30 UTC + // DST: 2015/07/04 11:30:40 Berlin [+2] = 2015/07/04 09:30:40 UTC = 2015/07/04 05:30:40 Toronto [-4] + val dates = listOf( + dateTimeValue("20150103T113030Z"), + dateTimeValue("20150704T113040", tzBerlin), + ) + val startDate = dateTimeValue("20150103T113030Z") + + val result = androidRecurrenceDatesString(dates, startDate) + + assertEquals("20150103T113030Z,20150704T093040Z", result) + } + + @Test + fun testAndroidRecurrenceDatesString_UtcTime() { + val dates = listOf(dateTimeValue("20150101T103010Z"), dateTimeValue("20150102T103020Z")) + val startDate = dateTimeValue("20150101T103010Z") + + val result = androidRecurrenceDatesString(dates, startDate) + + assertEquals("20150101T103010Z,20150102T103020Z", result) + } + +} \ No newline at end of file diff --git a/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/builder/DurationBuilderTest.kt b/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/builder/DurationBuilderTest.kt index 6e65590c..3fbc6a4d 100644 --- a/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/builder/DurationBuilderTest.kt +++ b/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/builder/DurationBuilderTest.kt @@ -9,9 +9,9 @@ package at.bitfire.synctools.mapping.calendar.builder import android.content.ContentValues import android.content.Entity import android.provider.CalendarContract.Events +import at.bitfire.dateTimeValue +import at.bitfire.dateValue import at.bitfire.synctools.icalendar.propertyListOf -import net.fortuna.ical4j.model.Date -import net.fortuna.ical4j.model.DateTime import net.fortuna.ical4j.model.TimeZoneRegistryFactory import net.fortuna.ical4j.model.component.VEvent import net.fortuna.ical4j.model.property.DtEnd @@ -21,13 +21,14 @@ import net.fortuna.ical4j.model.property.RRule import org.junit.Assert.assertEquals import org.junit.Assert.assertNull import org.junit.Assert.assertTrue -import org.junit.Ignore import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner +import java.time.LocalDate +import java.time.LocalDateTime import java.time.Period +import java.time.temporal.Temporal -@Ignore("ical4j 4.x") @RunWith(RobolectricTestRunner::class) class DurationBuilderTest { @@ -36,17 +37,13 @@ class DurationBuilderTest { private val builder = DurationBuilder() - init { - TODO("ical4j 4.x") - } - - /*@Test + @Test fun `Not a main event`() { val result = Entity(ContentValues()) builder.build(VEvent(propertyListOf( - DtStart(Date("20251010")), - DtEnd(Date("20251011")), - RRule("FREQ=DAILY;COUNT=5") + DtStart(dateValue("20251010")), + DtEnd(dateValue("20251011")), + RRule("FREQ=DAILY;COUNT=5") )), VEvent(), result) assertTrue(result.entityValues.containsKey(Events.DURATION)) assertNull(result.entityValues.get(Events.DURATION)) @@ -56,7 +53,7 @@ class DurationBuilderTest { fun `Not a recurring event`() { val result = Entity(ContentValues()) val event = VEvent(propertyListOf( - DtStart(Date("20251010")), + DtStart(dateValue("20251010")), Duration(Period.ofDays(2)) )) builder.build(event, event, result) @@ -69,9 +66,9 @@ class DurationBuilderTest { fun `Recurring all-day event (with DURATION)`() { val result = Entity(ContentValues()) val event = VEvent(propertyListOf( - DtStart(Date("20251010")), + DtStart(dateValue("20251010")), Duration(Period.ofDays(3)), - RRule("FREQ=DAILY;COUNT=5") + RRule("FREQ=DAILY;COUNT=5") )) builder.build(event, event, result) assertEquals("P3D", result.entityValues.get(Events.DURATION)) @@ -81,9 +78,9 @@ class DurationBuilderTest { fun `Recurring all-day event (with negative DURATION)`() { val result = Entity(ContentValues()) val event = VEvent(propertyListOf( - DtStart(Date("20251010")), + DtStart(dateValue("20251010")), Duration(Period.ofDays(-3)), // invalid negative DURATION will be treated as positive - RRule("FREQ=DAILY;COUNT=5") + RRule("FREQ=DAILY;COUNT=5") )) builder.build(event, event, result) assertEquals("P3D", result.entityValues.get(Events.DURATION)) @@ -93,9 +90,9 @@ class DurationBuilderTest { fun `Recurring all-day event (with zero seconds DURATION)`() { val result = Entity(ContentValues()) val event = VEvent(propertyListOf( - DtStart(Date("20251010")), + DtStart(dateValue("20251010")), Duration(java.time.Duration.ofSeconds(0)), - RRule("FREQ=DAILY;COUNT=5") + RRule("FREQ=DAILY;COUNT=5") )) builder.build(event, event, result) assertEquals("P0D", result.entityValues.get(Events.DURATION)) @@ -105,9 +102,9 @@ class DurationBuilderTest { fun `Recurring non-all-day event (with DURATION)`() { val result = Entity(ContentValues()) val event = VEvent(propertyListOf( - DtStart(DateTime("20251010T010203", tzVienna)), + DtStart(dateTimeValue("20251010T010203", tzVienna)), Duration(java.time.Duration.ofMinutes(90)), - RRule("FREQ=DAILY;COUNT=5") + RRule("FREQ=DAILY;COUNT=5") )) builder.build(event, event, result) assertEquals("PT1H30M", result.entityValues.get(Events.DURATION)) @@ -117,9 +114,9 @@ class DurationBuilderTest { fun `Recurring non-all-day event (with negative DURATION)`() { val result = Entity(ContentValues()) val event = VEvent(propertyListOf( - DtStart(DateTime("20251010T010203", tzVienna)), + DtStart(dateTimeValue("20251010T010203", tzVienna)), Duration(java.time.Duration.ofMinutes(-90)), // invalid negative DURATION will be treated as positive - RRule("FREQ=DAILY;COUNT=5") + RRule("FREQ=DAILY;COUNT=5") )) builder.build(event, event, result) assertEquals("PT1H30M", result.entityValues.get(Events.DURATION)) @@ -129,9 +126,9 @@ class DurationBuilderTest { fun `Recurring all-day event (with DTEND)`() { val result = Entity(ContentValues()) val event = VEvent(propertyListOf( - DtStart(Date("20251010")), - DtEnd(Date("20251017")), - RRule("FREQ=DAILY;COUNT=5") + DtStart(dateValue("20251010")), + DtEnd(dateValue("20251017")), + RRule("FREQ=DAILY;COUNT=5") )) builder.build(event, event, result) assertEquals("P1W", result.entityValues.get(Events.DURATION)) @@ -141,9 +138,9 @@ class DurationBuilderTest { fun `Recurring all-day event (with DTEND before START)`() { val result = Entity(ContentValues()) val event = VEvent(propertyListOf( - DtStart(Date("20251017")), - DtEnd(Date("20251010")), // DTEND before DTSTART should be ignored - RRule("FREQ=DAILY;COUNT=5") + DtStart(dateValue("20251017")), + DtEnd(dateValue("20251010")), // DTEND before DTSTART should be ignored + RRule("FREQ=DAILY;COUNT=5") )) builder.build(event, event, result) // default duration for all-day events: one day @@ -154,9 +151,9 @@ class DurationBuilderTest { fun `Recurring all-day event (with DTEND equals START)`() { val result = Entity(ContentValues()) val event = VEvent(propertyListOf( - DtStart(Date("20251017")), - DtEnd(Date("20251017")), // DTEND equals DTSTART should be ignored - RRule("FREQ=DAILY;COUNT=5") + DtStart(dateValue("20251017")), + DtEnd(dateValue("20251017")), // DTEND equals DTSTART should be ignored + RRule("FREQ=DAILY;COUNT=5") )) builder.build(event, event, result) // default duration for all-day events: one day @@ -167,9 +164,9 @@ class DurationBuilderTest { fun `Recurring non-all-day event (with DTEND)`() { val result = Entity(ContentValues()) val event = VEvent(propertyListOf( - DtStart(DateTime("20251010T010203", tzVienna)), - DtEnd(DateTime("20251011T020304", tzVienna)), - RRule("FREQ=DAILY;COUNT=5") + DtStart(dateTimeValue("20251010T010203", tzVienna)), + DtEnd(dateTimeValue("20251011T020304", tzVienna)), + RRule("FREQ=DAILY;COUNT=5") )) builder.build(event, event, result) assertEquals("P1DT1H1M1S", result.entityValues.get(Events.DURATION)) @@ -179,9 +176,9 @@ class DurationBuilderTest { fun `Recurring non-all-day event (with DTEND before DTSTART)`() { val result = Entity(ContentValues()) val event = VEvent(propertyListOf( - DtStart(DateTime("20251010T010203", tzVienna)), - DtEnd(DateTime("20251010T000203", tzVienna)), // DTEND before DTSTART should be ignored - RRule("FREQ=DAILY;COUNT=5") + DtStart(dateTimeValue("20251010T010203", tzVienna)), + DtEnd(dateTimeValue("20251010T000203", tzVienna)), // DTEND before DTSTART should be ignored + RRule("FREQ=DAILY;COUNT=5") )) builder.build(event, event, result) // default duration for non-all-day events: zero seconds @@ -192,9 +189,9 @@ class DurationBuilderTest { fun `Recurring non-all-day event (with DTEND equals DTSTART)`() { val result = Entity(ContentValues()) val event = VEvent(propertyListOf( - DtStart(DateTime("20251010T010203", tzVienna)), - DtEnd(DateTime("20251010T010203", tzVienna)), // DTEND equals DTSTART should be ignored - RRule("FREQ=DAILY;COUNT=5") + DtStart(dateTimeValue("20251010T010203", tzVienna)), + DtEnd(dateTimeValue("20251010T010203", tzVienna)), // DTEND equals DTSTART should be ignored + RRule("FREQ=DAILY;COUNT=5") )) builder.build(event, event, result) // default duration for non-all-day events: zero seconds @@ -205,8 +202,8 @@ class DurationBuilderTest { fun `Recurring all-day event (neither DURATION nor DTEND)`() { val result = Entity(ContentValues()) val event = VEvent(propertyListOf( - DtStart(Date("20251010")), - RRule("FREQ=DAILY;COUNT=5")) + DtStart(dateValue("20251010")), + RRule("FREQ=DAILY;COUNT=5")) ) builder.build(event, event, result) assertEquals("P1D", result.entityValues.get(Events.DURATION)) @@ -216,8 +213,8 @@ class DurationBuilderTest { fun `Recurring non-all-day event (neither DURATION nor DTEND)`() { val result = Entity(ContentValues()) val event = VEvent(propertyListOf( - DtStart(DateTime("20251010T010203", tzVienna)), - RRule("FREQ=DAILY;COUNT=5") + DtStart(dateTimeValue("20251010T010203", tzVienna)), + RRule("FREQ=DAILY;COUNT=5") )) builder.build(event, event, result) assertEquals("PT0S", result.entityValues.get(Events.DURATION)) @@ -230,7 +227,7 @@ class DurationBuilderTest { fun `alignWithDtStart (DTSTART all-day, DURATION all-day)`() { assertEquals( Period.ofDays(1), // may not be 24 hours (for instance on DST switch) - builder.alignWithDtStart(Period.ofDays(1), DtStart(Date())) + builder.alignWithDtStart(Period.ofDays(1), LocalDate.now()) ) } @@ -238,7 +235,7 @@ class DurationBuilderTest { fun `alignWithDtStart (DTSTART non-all-day, DURATION all-day)`() { assertEquals( java.time.Duration.ofDays(1), // exactly 24 hours - builder.alignWithDtStart(Period.ofDays(1), DtStart(DateTime())) + builder.alignWithDtStart(Period.ofDays(1), LocalDateTime.now()) ) } @@ -246,7 +243,7 @@ class DurationBuilderTest { fun `alignWithDtStart (DTSTART all-day, DURATION non-all-day)`() { assertEquals( Period.ofDays(1), // may not be 24 hours (for instance on DST switch) - builder.alignWithDtStart(java.time.Duration.ofHours(25), DtStart(Date())) + builder.alignWithDtStart(java.time.Duration.ofHours(25), LocalDate.now()) ) } @@ -254,7 +251,7 @@ class DurationBuilderTest { fun `alignWithDtStart (DTSTART non-all-day, DURATION non-all-day)`() { assertEquals( java.time.Duration.ofDays(1), // exactly 24 hours - builder.alignWithDtStart(java.time.Duration.ofHours(24), DtStart(DateTime())) + builder.alignWithDtStart(java.time.Duration.ofHours(24), LocalDateTime.now()) ) } @@ -264,8 +261,8 @@ class DurationBuilderTest { @Test fun `calculateFromDtEnd (dtStart=DATE, DtEnd=DATE)`() { val result = builder.calculateFromDtEnd( - DtStart(Date("20240328")), - DtEnd(Date("20240330")) + dateValue("20240328"), + dateValue("20240330") ) assertEquals( Period.ofDays(2), @@ -276,8 +273,8 @@ class DurationBuilderTest { @Test fun `calculateFromDtEnd (dtStart=DATE, DtEnd before dtStart)`() { val result = builder.calculateFromDtEnd( - DtStart(Date("20240328")), - DtEnd(Date("20240327")) + dateValue("20240328"), + dateValue("20240327") ) assertNull(result) } @@ -285,8 +282,8 @@ class DurationBuilderTest { @Test fun `calculateFromDtEnd (dtStart=DATE, DtEnd=DATE-TIME)`() { val result = builder.calculateFromDtEnd( - DtStart(Date("20240328")), - DtEnd(DateTime("20240330T123412", tzVienna)) + dateValue("20240328"), + dateTimeValue("20240330T123412", tzVienna) ) assertEquals( Period.ofDays(2), @@ -297,8 +294,8 @@ class DurationBuilderTest { @Test fun `calculateFromDtEnd (dtStart=DATE-TIME, DtEnd before dtStart)`() { val result = builder.calculateFromDtEnd( - DtStart(DateTime("20240328T010203", tzVienna)), - DtEnd(DateTime("20240328T000000", tzVienna)) + dateTimeValue("20240328T010203", tzVienna), + dateTimeValue("20240328T000000", tzVienna) ) assertNull(result) } @@ -306,8 +303,8 @@ class DurationBuilderTest { @Test fun `calculateFromDtEnd (dtStart=DATE-TIME, DtEnd=DATE)`() { val result = builder.calculateFromDtEnd( - DtStart(DateTime("20240328T010203", tzVienna)), - DtEnd(Date("20240330")) + dateTimeValue("20240328T010203", tzVienna), + dateValue("20240330") ) assertEquals( Period.ofDays(2), @@ -318,13 +315,13 @@ class DurationBuilderTest { @Test fun `calculateFromDtEnd (dtStart=DATE-TIME, DtEnd=DATE-TIME)`() { val result = builder.calculateFromDtEnd( - DtStart(DateTime("20240728T010203", tzVienna)), - DtEnd(DateTime("20240728T010203Z")) // GMT+1 with DST → 2 hours difference + dateTimeValue("20240728T010203", tzVienna), + dateTimeValue("20240728T010203Z") // GMT+1 with DST → 2 hours difference ) assertEquals( java.time.Duration.ofHours(2), result ) - }*/ + } } \ No newline at end of file diff --git a/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/builder/OriginalInstanceTimeBuilderTest.kt b/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/builder/OriginalInstanceTimeBuilderTest.kt index 7f4ab9e4..f2c041d7 100644 --- a/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/builder/OriginalInstanceTimeBuilderTest.kt +++ b/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/builder/OriginalInstanceTimeBuilderTest.kt @@ -10,20 +10,21 @@ import android.content.ContentValues import android.content.Entity import android.provider.CalendarContract.Events import androidx.core.content.contentValuesOf +import at.bitfire.dateTimeValue +import at.bitfire.dateValue import at.bitfire.synctools.icalendar.propertyListOf import at.bitfire.synctools.test.assertContentValuesEqual -import net.fortuna.ical4j.model.Date import net.fortuna.ical4j.model.TimeZoneRegistryFactory import net.fortuna.ical4j.model.component.VEvent import net.fortuna.ical4j.model.property.DtStart import net.fortuna.ical4j.model.property.RRule import net.fortuna.ical4j.model.property.RecurrenceId -import org.junit.Ignore import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner +import java.time.LocalDateTime +import java.time.temporal.Temporal -@Ignore("ical4j 4.x") @RunWith(RobolectricTestRunner::class) class OriginalInstanceTimeBuilderTest { @@ -33,14 +34,10 @@ class OriginalInstanceTimeBuilderTest { private val builder = OriginalInstanceTimeBuilder() - init { - TODO("ical4j 4.x") - } - - /*@Test + @Test fun `Main event`() { val result = Entity(ContentValues()) - val event = VEvent(propertyListOf(DtStart())) + val event = VEvent(propertyListOf(DtStart(LocalDateTime.now()))) builder.build( from = event, main = event, @@ -57,12 +54,12 @@ class OriginalInstanceTimeBuilderTest { val result = Entity(ContentValues()) builder.build( main = VEvent(propertyListOf( - DtStart(Date("20200706")), - RRule("FREQ=WEEKLY;COUNT=3") // add RRULE to make event recurring + DtStart(dateValue("20200706")), + RRule("FREQ=WEEKLY;COUNT=3") // add RRULE to make event recurring )), from = VEvent(propertyListOf( - RecurrenceId(Date("20200707")), - DtStart("20200706T123000", tzVienna) + RecurrenceId(dateValue("20200707")), + DtStart(dateTimeValue("20200706T123000", tzVienna)) )), to = result ) @@ -77,12 +74,12 @@ class OriginalInstanceTimeBuilderTest { val result = Entity(ContentValues()) builder.build( main = VEvent(propertyListOf( - DtStart("20200706T193000", tzVienna), - RRule("FREQ=DAILY;COUNT=10") // add RRULE to make event recurring + DtStart(dateTimeValue("20200706T193000", tzVienna)), + RRule("FREQ=DAILY;COUNT=10") // add RRULE to make event recurring )), from = VEvent(propertyListOf( - RecurrenceId(Date("20200707")), // invalid! should be rewritten to DateTime("20200707T193000", tzVienna) - DtStart("20200706T203000", tzShanghai) + RecurrenceId(dateValue("20200707")), // invalid! should be rewritten to DateTime("20200707T193000", tzVienna) + DtStart(dateTimeValue("20200706T203000", tzShanghai)) )), to = result ) @@ -97,12 +94,12 @@ class OriginalInstanceTimeBuilderTest { val result = Entity(ContentValues()) builder.build( main = VEvent(propertyListOf( - DtStart(Date("20200706")), - RRule("FREQ=WEEKLY;COUNT=3") // add RRULE to make event recurring + DtStart(dateValue("20200706")), + RRule("FREQ=WEEKLY;COUNT=3") // add RRULE to make event recurring )), from = VEvent(propertyListOf( - RecurrenceId("20200707T000000", tzVienna), // invalid! should be rewritten to Date("20200707") - DtStart("20200706T123000", tzVienna) + RecurrenceId(dateTimeValue("20200707T000000", tzVienna)), // invalid! should be rewritten to Date("20200707") + DtStart(dateTimeValue("20200706T123000", tzVienna)) )), to = result ) @@ -117,12 +114,12 @@ class OriginalInstanceTimeBuilderTest { val result = Entity(ContentValues()) builder.build( main = VEvent(propertyListOf( - DtStart("20200706T193000", tzVienna), - RRule("FREQ=DAILY;COUNT=10") // add RRULE to make event recurring + DtStart(dateTimeValue("20200706T193000", tzVienna)), + RRule("FREQ=DAILY;COUNT=10") // add RRULE to make event recurring )), from = VEvent(propertyListOf( - RecurrenceId("20200707T193000", tzVienna), - DtStart("20200706T203000", tzShanghai) + RecurrenceId(dateTimeValue("20200707T193000", tzVienna)), + DtStart(dateTimeValue("20200706T203000", tzShanghai)) )), to = result ) @@ -130,6 +127,6 @@ class OriginalInstanceTimeBuilderTest { Events.ORIGINAL_ALL_DAY to 0, Events.ORIGINAL_INSTANCE_TIME to 1594143000000L ), result.entityValues) - }*/ + } } \ No newline at end of file diff --git a/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/builder/RecurrenceFieldsBuilderTest.kt b/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/builder/RecurrenceFieldsBuilderTest.kt index 471bafe6..fc3b7313 100644 --- a/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/builder/RecurrenceFieldsBuilderTest.kt +++ b/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/builder/RecurrenceFieldsBuilderTest.kt @@ -10,43 +10,41 @@ import android.content.ContentValues import android.content.Entity import android.provider.CalendarContract.Events import androidx.core.content.contentValuesOf +import at.bitfire.dateTimeValue +import at.bitfire.dateValue import at.bitfire.synctools.icalendar.propertyListOf import at.bitfire.synctools.test.assertContentValuesEqual -import net.fortuna.ical4j.model.Date import net.fortuna.ical4j.model.DateList import net.fortuna.ical4j.model.ParameterList +import net.fortuna.ical4j.model.Period import net.fortuna.ical4j.model.component.VEvent -import net.fortuna.ical4j.model.parameter.Value import net.fortuna.ical4j.model.property.DtStart import net.fortuna.ical4j.model.property.ExDate import net.fortuna.ical4j.model.property.ExRule import net.fortuna.ical4j.model.property.RDate import net.fortuna.ical4j.model.property.RRule -import org.junit.Ignore import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner +import java.time.Duration +import java.time.LocalDateTime +import java.time.temporal.Temporal -@Ignore("ical4j 4.x") @RunWith(RobolectricTestRunner::class) class RecurrenceFieldsBuilderTest { private val builder = RecurrenceFieldsBuilder() - init { - TODO("ical4j 4.x") - } - - /*@Test + @Test fun `Exception event`() { // Exceptions (of recurring events) must never have recurrence properties themselves. val result = Entity(ContentValues()) builder.build( from = VEvent(propertyListOf( - DtStart(), - RRule("FREQ=DAILY;COUNT=1"), - RDate(), - ExDate() + DtStart(), + RRule("FREQ=DAILY;COUNT=1"), + RDate(), + ExDate() )), main = VEvent(), to = result @@ -62,8 +60,8 @@ class RecurrenceFieldsBuilderTest { @Test fun `EXDATE for non-recurring event`() { val main = VEvent(propertyListOf( - DtStart(), - ExDate() + DtStart(), + ExDate() )) val result = Entity(ContentValues()) builder.build( @@ -83,8 +81,8 @@ class RecurrenceFieldsBuilderTest { fun `Single RRULE`() { val result = Entity(ContentValues()) val event = VEvent(propertyListOf( - DtStart(), - RRule("FREQ=DAILY;COUNT=10") + DtStart(LocalDateTime.now()), + RRule("FREQ=DAILY;COUNT=10") )) builder.build( from = event, @@ -103,9 +101,9 @@ class RecurrenceFieldsBuilderTest { fun `Multiple RRULEs`() { val result = Entity(ContentValues()) val event = VEvent(propertyListOf( - DtStart(), - RRule("FREQ=YEARLY;BYMONTH=4;BYDAY=-1SU"), - RRule("FREQ=YEARLY;BYMONTH=10;BYDAY=1SU") + DtStart(LocalDateTime.now()), + RRule("FREQ=YEARLY;BYMONTH=4;BYDAY=-1SU"), + RRule("FREQ=YEARLY;BYMONTH=10;BYDAY=1SU") )) builder.build( from = event, @@ -124,10 +122,8 @@ class RecurrenceFieldsBuilderTest { fun `Single RDATE`() { val result = Entity(ContentValues()) val event = VEvent(propertyListOf( - DtStart(Date("20250917")), - RDate(DateList().apply { - add(Date("20250918")) - }) + DtStart(dateValue("20250917")), + RDate(DateList(dateValue("20250918"))) )) builder.build( from = event, @@ -146,11 +142,9 @@ class RecurrenceFieldsBuilderTest { fun `RDATE with infinite RRULE present`() { val result = Entity(ContentValues()) val event = VEvent(propertyListOf( - DtStart(Date("20250917")), - RRule("FREQ=DAILY"), - RDate(DateList().apply { - add(Date("20250918")) - }) + DtStart(dateValue("20250917")), + RRule("FREQ=DAILY"), + RDate(DateList(dateValue("20250918"))) )) builder.build( from = event, @@ -165,13 +159,34 @@ class RecurrenceFieldsBuilderTest { ), result.entityValues) } + @Test + fun `RDATE with PERIOD`() { + val result = Entity(ContentValues()) + val event = VEvent(propertyListOf( + DtStart(dateValue("20250917")), + RDate(listOf( + Period(dateTimeValue("19960403T020000Z"), dateTimeValue("19960403T040000Z")), + Period(dateTimeValue("19960404T010000Z"), Duration.ofHours(3)) + )) + )) + + builder.build(from = event, main = event, to = result) + + assertContentValuesEqual(contentValuesOf( + Events.RRULE to null, + Events.RDATE to null, // RDATE PERIOD not supported yet + Events.EXRULE to null, + Events.EXDATE to null + ), result.entityValues) + } + @Test fun `Single EXRULE`() { val result = Entity(ContentValues()) val event = VEvent(propertyListOf( - DtStart(), - RRule("FREQ=DAILY"), - ExRule(ParameterList(), "FREQ=WEEKLY") + DtStart(LocalDateTime.now()), + RRule("FREQ=DAILY"), + ExRule(ParameterList(), "FREQ=WEEKLY") )) builder.build( from = event, @@ -190,9 +205,9 @@ class RecurrenceFieldsBuilderTest { fun `Single EXDATE`() { val result = Entity(ContentValues()) val event = VEvent(propertyListOf( - DtStart(Date("20250918")), - RRule("FREQ=DAILY"), - ExDate(DateList("20250920", Value.DATE)) + DtStart(dateValue("20250918")), + RRule("FREQ=DAILY"), + ExDate(DateList(dateValue("20250920"))) )) builder.build( from = event, @@ -205,6 +220,6 @@ class RecurrenceFieldsBuilderTest { Events.EXRULE to null, Events.EXDATE to "20250920T000000Z" ), result.entityValues) - }*/ + } } \ No newline at end of file diff --git a/lib/src/test/kotlin/at/bitfire/synctools/util/AndroidTimeUtilsTest.kt b/lib/src/test/kotlin/at/bitfire/synctools/util/AndroidTimeUtilsTest.kt index 33252673..650b43b4 100644 --- a/lib/src/test/kotlin/at/bitfire/synctools/util/AndroidTimeUtilsTest.kt +++ b/lib/src/test/kotlin/at/bitfire/synctools/util/AndroidTimeUtilsTest.kt @@ -7,19 +7,9 @@ package at.bitfire.synctools.util import net.fortuna.ical4j.data.CalendarBuilder -import net.fortuna.ical4j.model.DateList import net.fortuna.ical4j.model.TimeZone import net.fortuna.ical4j.model.TimeZoneRegistryFactory -import net.fortuna.ical4j.model.property.DateListProperty -import net.fortuna.ical4j.model.property.RDate -import org.junit.Assert.assertEquals -import org.junit.Test import java.io.StringReader -import java.time.Duration -import java.time.LocalDate -import java.time.ZoneOffset -import java.time.ZonedDateTime -import java.time.temporal.Temporal class AndroidTimeUtilsTest { @@ -268,111 +258,12 @@ class AndroidTimeUtilsTest { } assertEquals(0, exDate.dates.size) } - - // recurrenceSetsToAndroidString - - @Test - fun testRecurrenceSetsToAndroidString_Date() { - // DATEs (without time) have to be converted to THHmmssZ for Android - val list = ArrayList(1) - list.add(RDate(DateList("20150101,20150702", Value.DATE, tzDefault))) - val androidTimeString = AndroidTimeUtils.recurrenceSetsToAndroidString(list, Date("20150101")) - // We ignore the timezone - assertEquals("20150101T000000Z,20150702T000000Z", androidTimeString.substringAfter(';')) - } - - @Test - fun testRecurrenceSetsToAndroidString_Date_AlthoughDtStartIsDateTime() { - // DATEs (without time) have to be converted to THHmmssZ for Android - val list = ArrayList(1) - list.add(RDate(DateList("20150101,20150702", Value.DATE, tzDefault))) - val androidTimeString = AndroidTimeUtils.recurrenceSetsToAndroidString(list, DateTime("20150101T043210", tzBerlin)) - // We ignore the timezone - assertEquals("20150101T033210Z,20150702T023210Z", androidTimeString.substringAfter(';')) - } - - @Test - fun testRecurrenceSetsToAndroidString_Date_AlthoughDtStartIsDateTime_MonthWithLessDays() { - // DATEs (without time) have to be converted to THHmmssZ for Android - val list = ArrayList(1) - list.add(ExDate(DateList("20240531", Value.DATE, tzDefault))) - val androidTimeString = AndroidTimeUtils.recurrenceSetsToAndroidString(list, DateTime("20240401T114500", tzBerlin)) - // We ignore the timezone - assertEquals("20240531T094500Z", androidTimeString.substringAfter(';')) - } - - @Test - fun testRecurrenceSetsToAndroidString_Period() { - // PERIODs are not supported yet — should be implemented later - val list = listOf( - RDate(PeriodList("19960403T020000Z/19960403T040000Z,19960404T010000Z/PT3H")) - ) - assertEquals("", AndroidTimeUtils.recurrenceSetsToAndroidString(list, DateTime("19960403T020000Z"))) - } - - @Test - fun testRecurrenceSetsToAndroidString_Time_AlthoughDtStartIsAllDay() { - // DATE-TIME (floating time or UTC) recurrences for all-day events have to converted to T000000Z for Android - val list = ArrayList(1) - list.add(RDate(DateList("20150101T000000,20150702T000000Z", Value.DATE_TIME, tzDefault))) - val androidTimeString = AndroidTimeUtils.recurrenceSetsToAndroidString(list, Date("20150101")) - // We ignore the timezone - assertEquals("20150101T000000Z,20150702T000000Z", androidTimeString.substringAfter(';')) - } - - @Test - fun testRecurrenceSetsToAndroidString_TwoTimesWithSameTimezone() { - // two separate entries, both with timezone Toronto - val list = ArrayList(2) - list.add(RDate(DateList("20150103T113030", Value.DATE_TIME, tzToronto))) - list.add(RDate(DateList("20150704T113040", Value.DATE_TIME, tzToronto))) - assertEquals( - "America/Toronto;20150103T113030,20150704T113040", - AndroidTimeUtils.recurrenceSetsToAndroidString(list, DateTime("20150103T113030", tzToronto)) - ) - } - - @Test - fun testRecurrenceSetsToAndroidString_TwoTimesWithDifferentTimezone() { - // two separate entries, one with timezone Toronto, one with Berlin - // 2015/01/03 11:30:30 Toronto [-5] = 2015/01/03 16:30:30 UTC - // DST: 2015/07/04 11:30:40 Berlin [+2] = 2015/07/04 09:30:40 UTC = 2015/07/04 05:30:40 Toronto [-4] - val list = ArrayList(2) - list.add(RDate(DateList("20150103T113030", Value.DATE_TIME, tzToronto))) - list.add(RDate(DateList("20150704T113040", Value.DATE_TIME, tzBerlin))) - assertEquals( - "America/Toronto;20150103T113030,20150704T053040", - AndroidTimeUtils.recurrenceSetsToAndroidString(list, DateTime("20150103T113030", tzToronto)) - ) - } - - @Test - fun testRecurrenceSetsToAndroidString_TwoTimesWithOneUtc() { - // two separate entries, one with timezone Toronto, one with Berlin - // 2015/01/03 11:30:30 Toronto [-5] = 2015/01/03 16:30:30 UTC - // DST: 2015/07/04 11:30:40 Berlin [+2] = 2015/07/04 09:30:40 UTC = 2015/07/04 05:30:40 Toronto [-4] - val list = ArrayList(2) - list.add(RDate(DateList("20150103T113030Z", Value.DATE_TIME))) - list.add(RDate(DateList("20150704T113040", Value.DATE_TIME, tzBerlin))) - assertEquals( - "20150103T113030Z,20150704T093040Z", - AndroidTimeUtils.recurrenceSetsToAndroidString(list, DateTime("20150103T113030Z")) - ) - } - - @Test - fun testRecurrenceSetsToAndroidString_UtcTime() { - val list = ArrayList(1) - list.add(RDate(DateList("20150101T103010Z,20150102T103020Z", Value.DATE_TIME))) - assertEquals( - "20150101T103010Z,20150102T103020Z", - AndroidTimeUtils.recurrenceSetsToAndroidString(list, DateTime("20150101T103010ZZ")) - ) - }*/ + */ // recurrenceSetsToOpenTasksString + /* @Test fun testRecurrenceSetsToOpenTasksString_UtcTimes() { val list = ArrayList>(1) @@ -444,5 +335,6 @@ class AndroidTimeUtilsTest { assertEquals(Duration.parse("P11DT3H2M1S"), AndroidTimeUtils.parseDuration("P1S2M3H4D1W")) assertEquals(Duration.parse("PT1H0M10S"), AndroidTimeUtils.parseDuration("1H10S")) } +*/ } \ No newline at end of file