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

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.rule.GrantPermissionRule
import at.bitfire.ical4android.impl.TestCalendar
import at.bitfire.ical4android.util.MiscUtils.closeCompat
import at.bitfire.synctools.test.assertContentValuesEqual
import org.junit.After
import org.junit.Before
import org.junit.Rule
Expand Down Expand Up @@ -55,6 +56,39 @@ class AndroidCalendarProviderBehaviorTest {
}


@Test
fun testInsertEventWithDurationZeroSeconds() {
// To make sure that it's not a problem to insert a recurring event with a duration of zero seconds.
val values = contentValuesOf(
Events.CALENDAR_ID to calendar.id,
Events.DTSTART to 1759403653000, // Thu Oct 02 2025 11:14:13 GMT+0000
Events.DURATION to "PT0S",
Events.TITLE to "Event with useless RRULE",
Events.RRULE to "FREQ=DAILY;UNTIL=20251002T000000Z"
)
val id = calendar.addEvent(Entity(values))

val event2 = calendar.getEventRow(id)
assertContentValuesEqual(values, event2!!, onlyFieldsInExpected = true)
}

@Test
fun testInsertEventWithRRuleUntilBeforeDtStart() {
// To make sure that's not a problem to insert an (invalid/useless) RRULE with UNTIL before the event's DTSTART.
val values = contentValuesOf(
Events.CALENDAR_ID to calendar.id,
Events.DTSTART to 1759403653000, // Thu Oct 02 2025 11:14:13 GMT+0000
Events.DURATION to "PT1H",
Events.TITLE to "Event with useless RRULE",
Events.RRULE to "FREQ=DAILY;UNTIL=20251002T000000Z"
)
val id = calendar.addEvent(Entity(values))

val event2 = calendar.getEventRow(id)
assertContentValuesEqual(values, event2!!, onlyFieldsInExpected = true)
}


/**
* Reported as https://issuetracker.google.com/issues/446730408.
*/
Expand Down
4 changes: 4 additions & 0 deletions lib/src/main/kotlin/at/bitfire/ical4android/Event.kt
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

package at.bitfire.ical4android

import at.bitfire.synctools.exception.InvalidICalendarException
import at.bitfire.synctools.icalendar.Css3Color
import net.fortuna.ical4j.model.Parameter
import net.fortuna.ical4j.model.Property
Expand Down Expand Up @@ -91,4 +92,7 @@ data class Event(
return email
}

fun requireDtStart(): DtStart =
dtStart ?: throw InvalidICalendarException("Missing DTSTART in VEVENT")

}
4 changes: 2 additions & 2 deletions lib/src/main/kotlin/at/bitfire/ical4android/ICalendar.kt
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ package at.bitfire.ical4android

import at.bitfire.ical4android.ICalendar.Companion.CALENDAR_NAME
import at.bitfire.synctools.BuildConfig
import at.bitfire.synctools.exception.InvalidRemoteResourceException
import at.bitfire.synctools.exception.InvalidICalendarException
import at.bitfire.synctools.icalendar.ICalendarParser
import at.bitfire.synctools.icalendar.validation.ICalPreprocessor
import net.fortuna.ical4j.data.CalendarBuilder
Expand Down Expand Up @@ -89,7 +89,7 @@ open class ICalendar {
*
* @return parsed iCalendar resource
*
* @throws InvalidRemoteResourceException when the iCalendar can't be parsed
* @throws InvalidICalendarException when the iCalendar can't be parsed
*/
@Deprecated("Use ICalendarParser directly")
fun fromReader(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import android.os.ParcelFileDescriptor
import android.util.Base64
import androidx.core.content.contentValuesOf
import at.bitfire.ical4android.ICalendar.Companion.withUserAgents
import at.bitfire.synctools.exception.InvalidRemoteResourceException
import at.bitfire.synctools.exception.InvalidICalendarException
import at.bitfire.synctools.icalendar.Css3Color
import at.bitfire.synctools.storage.BatchOperation
import at.bitfire.synctools.storage.JtxBatchOperation
Expand Down Expand Up @@ -285,7 +285,7 @@ open class JtxICalObject(
*
* @return array of filled [JtxICalObject] data objects (may have size 0)
*
* @throws InvalidRemoteResourceException when the iCalendar can't be parsed
* @throws InvalidICalendarException when the iCalendar can't be parsed
* @throws IOException on I/O errors
*/
fun fromReader(
Expand Down
4 changes: 2 additions & 2 deletions lib/src/main/kotlin/at/bitfire/ical4android/Task.kt
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ package at.bitfire.ical4android

import androidx.annotation.IntRange
import at.bitfire.ical4android.util.DateUtils
import at.bitfire.synctools.exception.InvalidRemoteResourceException
import at.bitfire.synctools.exception.InvalidICalendarException
import at.bitfire.synctools.icalendar.Css3Color
import net.fortuna.ical4j.data.CalendarOutputter
import net.fortuna.ical4j.model.Calendar
Expand Down Expand Up @@ -107,7 +107,7 @@ data class Task(
*
* @return array of filled [Task] data objects (may have size 0)
*
* @throws InvalidRemoteResourceException when the iCalendar can't be parsed
* @throws InvalidICalendarException when the iCalendar can't be parsed
* @throws IOException on I/O errors
*/
fun tasksFromReader(reader: Reader): List<Task> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ object AndroidTimeUtils {
/**
* Timezone ID to store for all-day events, according to CalendarContract.Events SDK documentation.
*/
val TZID_ALLDAY = "UTC"
val TZID_UTC = "UTC"

private const val RECURRENCE_LIST_TZID_SEPARATOR = ';'
private const val RECURRENCE_LIST_VALUE_SEPARATOR = ","
Expand Down Expand Up @@ -136,7 +136,7 @@ object AndroidTimeUtils {
}
} else
// DATE
TZID_ALLDAY
TZID_UTC


// recurrence sets
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ object TimeApiExtensions {
var secs = seconds

if (secs == 0L)
return "P0S"
return "PT0S"

var weeks = secs / SECONDS_PER_WEEK
secs -= weeks * SECONDS_PER_WEEK
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,6 @@ object EventValidator {
fun repair(event: Event) {
val dtStart = correctStartAndEndTime(event)
sameTypeForDtStartAndRruleUntil(dtStart, event.rRules)
removeRRulesWithUntilBeforeDtStart(dtStart, event.rRules)
}


Expand Down Expand Up @@ -167,28 +166,4 @@ object EventValidator {
}
}

/**
* Will remove the RRULES of an event where UNTIL lies before DTSTART
*/
@VisibleForTesting
internal fun removeRRulesWithUntilBeforeDtStart(dtStart: DtStart, rRules: MutableList<RRule>) {
val iter = rRules.iterator()
while (iter.hasNext()) {
val rRule = iter.next()

// drop invalid RRULEs
if (hasUntilBeforeDtStart(dtStart, rRule))
iter.remove()
}
}

/**
* Checks whether UNTIL of an RRULE lies before DTSTART
*/
@VisibleForTesting
internal fun hasUntilBeforeDtStart(dtStart: DtStart, rRule: RRule): Boolean {
val until = rRule.recur.until ?: return false
return until < dtStart.date
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@
package at.bitfire.synctools.exception

/**
* Represents an invalid remote resource (for instance, a calendar object resource).
* Represents an invalid iCalendar resource.
*/
class InvalidRemoteResourceException: InvalidResourceException {
class InvalidICalendarException: InvalidResourceException {

constructor(message: String): super(message)
constructor(message: String, ex: Throwable): super(message, ex)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

package at.bitfire.synctools.icalendar

import at.bitfire.synctools.exception.InvalidRemoteResourceException
import at.bitfire.synctools.exception.InvalidICalendarException
import at.bitfire.synctools.icalendar.validation.ICalPreprocessor
import net.fortuna.ical4j.data.CalendarBuilder
import net.fortuna.ical4j.data.CalendarParserFactory
Expand Down Expand Up @@ -39,7 +39,7 @@ class ICalendarParser(
* @param reader where the iCalendar is read from
* @param tzRegistry time zone registry where VTIMEZONE definitions of the iCalendar will be put
*
* @throws InvalidRemoteResourceException when the resource is can't be parsed
* @throws InvalidICalendarException when the resource is can't be parsed
*/
fun parse(reader: Reader): Calendar {
// preprocess stream to work around problems that prevent parsing and thus can't be fixed later
Expand All @@ -54,9 +54,9 @@ class ICalendarParser(
/* tzRegistry = */ TimeZoneRegistryFactory.getInstance().createRegistry()
).build(preprocessed)
} catch(e: ParserException) {
throw InvalidRemoteResourceException("Couldn't parse iCalendar", e)
throw InvalidICalendarException("Couldn't parse iCalendar", e)
} catch(e: IllegalArgumentException) {
throw InvalidRemoteResourceException("iCalendar contains invalid value", e)
throw InvalidICalendarException("iCalendar contains invalid value", e)
}

// Pre-process calendar for increased compatibility (fixes some common errors)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,17 +19,19 @@ import at.bitfire.synctools.mapping.calendar.builder.CategoriesBuilder
import at.bitfire.synctools.mapping.calendar.builder.ColorBuilder
import at.bitfire.synctools.mapping.calendar.builder.DescriptionBuilder
import at.bitfire.synctools.mapping.calendar.builder.DirtyAndDeletedBuilder
import at.bitfire.synctools.mapping.calendar.builder.DurationBuilder
import at.bitfire.synctools.mapping.calendar.builder.ETagBuilder
import at.bitfire.synctools.mapping.calendar.builder.EndTimeBuilder
import at.bitfire.synctools.mapping.calendar.builder.LocationBuilder
import at.bitfire.synctools.mapping.calendar.builder.OrganizerBuilder
import at.bitfire.synctools.mapping.calendar.builder.OriginalInstanceTimeBuilder
import at.bitfire.synctools.mapping.calendar.builder.RecurrenceFieldsBuilder
import at.bitfire.synctools.mapping.calendar.builder.RemindersBuilder
import at.bitfire.synctools.mapping.calendar.builder.SequenceBuilder
import at.bitfire.synctools.mapping.calendar.builder.StartTimeBuilder
import at.bitfire.synctools.mapping.calendar.builder.StatusBuilder
import at.bitfire.synctools.mapping.calendar.builder.SyncFlagsBuilder
import at.bitfire.synctools.mapping.calendar.builder.SyncIdBuilder
import at.bitfire.synctools.mapping.calendar.builder.TimeFieldsBuilder
import at.bitfire.synctools.mapping.calendar.builder.TitleBuilder
import at.bitfire.synctools.mapping.calendar.builder.UidBuilder
import at.bitfire.synctools.mapping.calendar.builder.UnknownPropertiesBuilder
Expand Down Expand Up @@ -72,7 +74,9 @@ class LegacyAndroidEventBuilder2(
ETagBuilder(eTag = eTag, scheduleTag = scheduleTag),
SyncFlagsBuilder(flags),
SequenceBuilder(),
TimeFieldsBuilder(),
StartTimeBuilder(),
EndTimeBuilder(),
DurationBuilder(),
AllDayBuilder(),
AccessLevelBuilder(),
AvailabilityBuilder(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,25 +83,27 @@ class LegacyAndroidEventProcessor(
to = to
)

// exceptions of recurring main event
for (exception in eventAndExceptions.exceptions) {
val exceptionEvent = Event()
// Add exceptions of recurring main event
if (to.rRules.isNotEmpty() || to.rDates.isNotEmpty()) {
for (exception in eventAndExceptions.exceptions) {
val exceptionEvent = Event()

// convert exception to Event
populateEvent(
entity = exception,
main = eventAndExceptions.main,
to = exceptionEvent
)
// convert exception to Event
populateEvent(
entity = exception,
main = eventAndExceptions.main,
to = exceptionEvent
)

// make sure that exception has a RECURRENCE-ID
val recurrenceId = exceptionEvent.recurrenceId ?: continue
// make sure that exception has a RECURRENCE-ID
val recurrenceId = exceptionEvent.recurrenceId ?: continue

// generate EXDATE instead of VEVENT with RECURRENCE-ID for cancelled instances
if (exception.entityValues.getAsInteger(Events.STATUS) == Events.STATUS_CANCELED)
addAsExDate(exception, recurrenceId, to = to)
else
to.exceptions += exceptionEvent
// generate EXDATE instead of VEVENT with RECURRENCE-ID for cancelled instances
if (exception.entityValues.getAsInteger(Events.STATUS) == Events.STATUS_CANCELED)
addAsExDate(exception, recurrenceId, to = to)
else
to.exceptions += exceptionEvent
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ interface AndroidEntityBuilder {
* @param main main event
* @param to destination object where built values are stored (set `null` values, see note)
*
* @throws at.bitfire.synctools.exception.InvalidRemoteResourceException on missing or invalid required properties (like DTSTART)
* @throws at.bitfire.synctools.exception.InvalidICalendarException on missing or invalid required properties (like DTSTART)
*/
fun build(from: Event, main: Event, to: Entity)

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
/*
* 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 android.content.Entity
import android.provider.CalendarContract.Events
import androidx.annotation.VisibleForTesting
import at.bitfire.ical4android.Event
import at.bitfire.ical4android.util.DateUtils
import at.bitfire.ical4android.util.TimeApiExtensions.toLocalDate
import at.bitfire.ical4android.util.TimeApiExtensions.toRfc5545Duration
import net.fortuna.ical4j.model.property.DtEnd
import net.fortuna.ical4j.model.property.DtStart
import net.fortuna.ical4j.model.property.Duration
import java.time.Period

class DurationBuilder: AndroidEntityBuilder {

override fun build(from: Event, main: Event, to: Entity) {
val values = to.entityValues

/* The calendar provider requires
- DTEND when the event is non-recurring, and
- 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). */
if (from !== main || (from.rRules.isEmpty() && from.rDates.isEmpty())) {
values.putNull(Events.DURATION)
return
}

val dtStart = from.requireDtStart()
val duration = from.duration
?: calculateFromDtEnd(dtStart, from.dtEnd)
?: defaultDuration(DateUtils.isDate(dtStart))

/* [RFC 5545 3.8.2.5]
> When the "DURATION" property relates to a "DTSTART" property that is specified as a DATE value, then the
>"DURATION" property MUST be specified as a "dur-day" or "dur-week" value.

The calendar provider automatically converts the DURATION of an all-day event to unit: days,
so we don't have to take care of that. */

// TemporalAmount can have months and years, but the RFC 5545 must only contain weeks, days and time.
// So we have to recalculate the months/years to days according to their position in the calendar.
val durationStr = duration.duration.toRfc5545Duration(dtStart.date.toInstant())
values.put(Events.DURATION, durationStr)
}

@VisibleForTesting
internal fun calculateFromDtEnd(dtStart: DtStart, dtEnd: DtEnd?): Duration? {
if (dtEnd == null)
return null

return if (DateUtils.isDateTime(dtStart) && DateUtils.isDateTime(dtEnd)) {
// DTSTART and DTEND are DATE-TIME → calculate difference between timestamps
val seconds = (dtEnd.date.time - dtStart.date.time) / 1000
Duration(java.time.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()
Duration(Period.between(startDate, endDate))
}
}

private fun defaultDuration(allDay: Boolean): Duration =
Duration(
if (allDay)
Period.ofDays(1)
else
java.time.Duration.ZERO
)

}
Loading