Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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}")
}
}

}
Original file line number Diff line number Diff line change
@@ -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<Property>): 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<Temporal>, 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<ZonedDateTime>()
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 <date>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)
}
}
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Expand All @@ -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<RRule>(Property.RRULE)
val rDates = from.getProperties<RDate>(Property.RDATE)
val rRules = from.getProperties<RRule<*>>(Property.RRULE)
val rDates = from.getProperties<RDate<*>>(Property.RDATE)
if (from !== main || (rRules.isEmpty() && rDates.isEmpty())) {
values.putNull(Events.DURATION)
return
}

val dtStart = from.requireDtStart()
val startDate = from.requireDtStart<Temporal>().normalizedDate()

// calculate DURATION from DTEND - DTSTART, if necessary
val calculatedDuration = from.duration?.duration
?: calculateFromDtEnd(dtStart, from.endDate) // ignores DTEND < DTSTART
?: calculateFromDtEnd(startDate, from.getEndDate<Temporal>(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
Expand All @@ -61,32 +63,31 @@ class DurationBuilder: AndroidEntityBuilder {
so we wouldn't have to take care of that. However it expects seconds to be in "P<n>S" format,
whereas we provide an RFC 5545-compliant "PT<n>S", 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
Expand All @@ -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 =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Temporal>()
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)
}*/
}
}

}
Loading