diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5e674acf..faa418ff 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -26,6 +26,7 @@ guava = { module = "com.google.guava:guava", version.ref = "guava" } ical4j = { module = "org.mnode.ical4j:ical4j", version.ref = "ical4j" } junit = { module = "junit:junit", version.ref = "junit" } kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib", version.ref = "kotlin" } +mockk = { module = "io.mockk:mockk", version.ref = "mockk" } mockk-android = { module = "io.mockk:mockk-android", version.ref = "mockk" } slf4j-jdk = { module = "org.slf4j:slf4j-jdk14", version.ref = "slf4j" } diff --git a/lib/build.gradle.kts b/lib/build.gradle.kts index 80c18b31..63596a59 100644 --- a/lib/build.gradle.kts +++ b/lib/build.gradle.kts @@ -135,4 +135,5 @@ dependencies { // unit tests testImplementation(libs.junit) + testImplementation(libs.mockk) } \ No newline at end of file diff --git a/lib/src/main/kotlin/at/bitfire/ical4android/AndroidEvent.kt b/lib/src/main/kotlin/at/bitfire/ical4android/AndroidEvent.kt index 00608a25..be9f7d53 100644 --- a/lib/src/main/kotlin/at/bitfire/ical4android/AndroidEvent.kt +++ b/lib/src/main/kotlin/at/bitfire/ical4android/AndroidEvent.kt @@ -38,6 +38,7 @@ import at.bitfire.ical4android.util.TimeApiExtensions.toLocalDate import at.bitfire.ical4android.util.TimeApiExtensions.toLocalTime import at.bitfire.ical4android.util.TimeApiExtensions.toRfc5545Duration import at.bitfire.ical4android.util.TimeApiExtensions.toZonedDateTime +import at.bitfire.synctools.exception.InvalidLocalResourceException import at.bitfire.synctools.storage.BatchOperation.CpoBuilder import at.bitfire.synctools.storage.CalendarBatchOperation import at.bitfire.synctools.storage.LocalStorageException @@ -215,7 +216,7 @@ class AndroidEvent( } val allDay = (row.getAsInteger(Events.ALL_DAY) ?: 0) != 0 - val tsStart = row.getAsLong(Events.DTSTART) ?: throw LocalStorageException("Found event without DTSTART") + val tsStart = row.getAsLong(Events.DTSTART) ?: throw InvalidLocalResourceException("Found event without DTSTART") var tsEnd = row.getAsLong(Events.DTEND) var duration = // only use DURATION of DTEND is not defined @@ -785,7 +786,7 @@ class AndroidEvent( private fun buildEvent(recurrence: Event?, builder: CpoBuilder) { val event = recurrence ?: requireNotNull(event) - val dtStart = event.dtStart ?: throw InvalidCalendarException("Events must have DTSTART") + val dtStart = event.dtStart ?: throw InvalidLocalResourceException("Events must have DTSTART") val allDay = DateUtils.isDate(dtStart) // make sure that time zone is supported by Android diff --git a/lib/src/main/kotlin/at/bitfire/ical4android/Event.kt b/lib/src/main/kotlin/at/bitfire/ical4android/Event.kt index f8a3c372..0427d94f 100644 --- a/lib/src/main/kotlin/at/bitfire/ical4android/Event.kt +++ b/lib/src/main/kotlin/at/bitfire/ical4android/Event.kt @@ -9,6 +9,8 @@ package at.bitfire.ical4android import at.bitfire.ical4android.ICalendar.Companion.CALENDAR_NAME import at.bitfire.ical4android.util.DateUtils.isDateTime import at.bitfire.ical4android.validation.EventValidator +import at.bitfire.synctools.exception.InvalidLocalResourceException +import at.bitfire.synctools.icalendar.CalendarUidSplitter import net.fortuna.ical4j.data.CalendarOutputter import net.fortuna.ical4j.data.ParserException import net.fortuna.ical4j.model.Calendar @@ -108,7 +110,7 @@ data class Event( ical.properties += Version.VERSION_2_0 ical.properties += prodId.withUserAgents(userAgents) - val dtStart = dtStart ?: throw InvalidCalendarException("Won't generate event without start time") + val dtStart = dtStart ?: throw InvalidLocalResourceException("Won't generate event without start time") EventValidator.repair(this) // repair this event before creating the VEVENT @@ -258,77 +260,41 @@ data class Event( val ical = fromReader(reader, properties) // process VEVENTs - val vEvents = ical.getComponents(Component.VEVENT) + val splitter = CalendarUidSplitter() + val vEventsByUid = splitter.associateByUid(ical, Component.VEVENT) - // make sure every event has an UID - for (vEvent in vEvents) - if (vEvent.uid == null) { - val uid = Uid(UUID.randomUUID().toString()) - logger.warning("Found VEVENT without UID, using a random one: ${uid.value}") - vEvent.properties += uid - } - - logger.fine("Assigning exceptions to main events") - val mainEvents = mutableMapOf() - val exceptions = mutableMapOf>() - for (vEvent in vEvents) { - val uid = vEvent.uid.value - val sequence = vEvent.sequence?.sequenceNo ?: 0 - - if (vEvent.recurrenceId == null) { - // main event (no RECURRENCE-ID) - - // If there are multiple entries, compare SEQUENCE and use the one with higher SEQUENCE. - // If the SEQUENCE is identical, use latest version. - val event = mainEvents[uid] - if (event == null || (event.sequence != null && sequence >= event.sequence.sequenceNo)) - mainEvents[uid] = vEvent - - } else { - // exception (RECURRENCE-ID) - var ex = exceptions[uid] - // first index level: UID - if (ex == null) { - ex = mutableMapOf() - exceptions[uid] = ex - } - // second index level: RECURRENCE-ID - val recurrenceID = vEvent.recurrenceId.value - val event = ex[recurrenceID] - if (event == null || (event.sequence != null && sequence >= event.sequence.sequenceNo)) - ex[recurrenceID] = vEvent - } - } - - /* There may be UIDs which have only RECURRENCE-ID entries and not a main entry (for instance, a recurring + /* Note: There may be UIDs which have only RECURRENCE-ID entries and not a main entry (for instance, a recurring event with an exception where the current user has been invited only to this exception. In this case, the UID will not appear in mainEvents but only in exceptions. */ - val events = mutableListOf() - for ((uid, vEvent) in mainEvents) { - val event = fromVEvent(vEvent) + // make sure every event has an UID + vEventsByUid[null]?.let { withoutUid -> + val uid = Uid(UUID.randomUUID().toString()) + logger.warning("Found VEVENT without UID, using a random one: ${uid.value}") + withoutUid.main?.properties?.add(uid) + withoutUid.exceptions.forEach { it.properties.add(uid) } + } - // assign exceptions to main event and then remove them from exceptions array - exceptions.remove(uid)?.let { eventExceptions -> - event.exceptions.addAll(eventExceptions.values.map { fromVEvent(it) }) + // convert into Events (data class) + val events = mutableListOf() + for (associatedEvents in vEventsByUid.values) { + val mainVEvent = associatedEvents.main ?: + // no main event but only exceptions, create fake main event + // FIXME: we should construct a proper recurring fake event, not just take first the exception + associatedEvents.exceptions.first() + + val event = fromVEvent(mainVEvent) + associatedEvents.exceptions.mapTo(event.exceptions) { exceptionVEvent -> + fromVEvent(exceptionVEvent).also { exception -> + // make sure that exceptions have at least a SUMMARY (if the main event does have one) + if (exception.summary == null) + exception.summary = event.summary + } } - // make sure that exceptions have at least a SUMMARY - event.exceptions.forEach { it.summary = it.summary ?: event.summary } - events += event } - for ((uid, onlyExceptions) in exceptions) { - logger.info("UID $uid doesn't have a main event but only exceptions: $onlyExceptions") - - // create a fake main event from the first exception - val fakeEvent = fromVEvent(onlyExceptions.values.first()) - fakeEvent.exceptions.addAll(onlyExceptions.values.map { fromVEvent(it) }) - - events += fakeEvent - } - // Try to repair all events after reading the whole iCalendar for (event in events) EventValidator.repair(event) @@ -336,7 +302,7 @@ data class Event( return events } - private fun fromVEvent(event: VEvent): Event { + fun fromVEvent(event: VEvent): Event { val e = Event() // sequence must only be null for locally created, not-yet-synchronized events diff --git a/lib/src/main/kotlin/at/bitfire/ical4android/ICalendar.kt b/lib/src/main/kotlin/at/bitfire/ical4android/ICalendar.kt index 7c9a51c5..a80827ae 100644 --- a/lib/src/main/kotlin/at/bitfire/ical4android/ICalendar.kt +++ b/lib/src/main/kotlin/at/bitfire/ical4android/ICalendar.kt @@ -9,9 +9,9 @@ package at.bitfire.ical4android import at.bitfire.ical4android.ICalendar.Companion.CALENDAR_NAME import at.bitfire.ical4android.validation.ICalPreprocessor import at.bitfire.synctools.BuildConfig +import at.bitfire.synctools.exception.InvalidRemoteResourceException +import at.bitfire.synctools.icalendar.ICalendarParser import net.fortuna.ical4j.data.CalendarBuilder -import net.fortuna.ical4j.data.CalendarParserFactory -import net.fortuna.ical4j.data.ContentHandlerContext import net.fortuna.ical4j.data.ParserException import net.fortuna.ical4j.model.Calendar import net.fortuna.ical4j.model.ComponentList @@ -19,7 +19,6 @@ import net.fortuna.ical4j.model.Date import net.fortuna.ical4j.model.Parameter import net.fortuna.ical4j.model.Property import net.fortuna.ical4j.model.PropertyList -import net.fortuna.ical4j.model.TimeZoneRegistryFactory import net.fortuna.ical4j.model.component.Daylight import net.fortuna.ical4j.model.component.Observance import net.fortuna.ical4j.model.component.Standard @@ -89,35 +88,14 @@ open class ICalendar { * @param properties Known iCalendar properties (like [CALENDAR_NAME]) will be put into this map. Key: property name; value: property value * * @return parsed iCalendar resource - * @throws ParserException when the iCalendar can't be parsed - * @throws IllegalArgumentException when the iCalendar resource contains an invalid value + * + * @throws InvalidRemoteResourceException when the iCalendar can't be parsed */ + @Deprecated("Use ICalendarParser directly") fun fromReader(reader: Reader, properties: MutableMap? = null): Calendar { logger.fine("Parsing iCalendar stream") - // preprocess stream to work around some problems that can't be fixed later - val preprocessed = ICalPreprocessor.preprocessStream(reader) - - // parse stream - val calendar: Calendar - try { - calendar = CalendarBuilder( - CalendarParserFactory.getInstance().get(), - ContentHandlerContext().withSupressInvalidProperties(true), - TimeZoneRegistryFactory.getInstance().createRegistry() // AndroidCompatTimeZoneRegistry - ).build(preprocessed) - } catch(e: ParserException) { - throw InvalidCalendarException("Couldn't parse iCalendar", e) - } catch(e: IllegalArgumentException) { - throw InvalidCalendarException("iCalendar contains invalid value", e) - } - - // apply ICalPreprocessor for increased compatibility - try { - ICalPreprocessor.preprocessCalendar(calendar) - } catch (e: Exception) { - logger.log(Level.WARNING, "Couldn't pre-process iCalendar", e) - } + val calendar = ICalendarParser().parse(reader) // fill calendar properties properties?.let { diff --git a/lib/src/main/kotlin/at/bitfire/ical4android/JtxICalObject.kt b/lib/src/main/kotlin/at/bitfire/ical4android/JtxICalObject.kt index 916421af..001b63d4 100644 --- a/lib/src/main/kotlin/at/bitfire/ical4android/JtxICalObject.kt +++ b/lib/src/main/kotlin/at/bitfire/ical4android/JtxICalObject.kt @@ -14,13 +14,13 @@ import android.os.ParcelFileDescriptor import android.util.Base64 import at.bitfire.ical4android.ICalendar.Companion.withUserAgents import at.bitfire.ical4android.util.MiscUtils.toValues +import at.bitfire.synctools.exception.InvalidRemoteResourceException import at.bitfire.synctools.storage.BatchOperation import at.bitfire.synctools.storage.JtxBatchOperation import at.techbee.jtx.JtxContract import at.techbee.jtx.JtxContract.JtxICalObject.TZ_ALLDAY import at.techbee.jtx.JtxContract.asSyncAdapter import net.fortuna.ical4j.data.CalendarOutputter -import net.fortuna.ical4j.data.ParserException import net.fortuna.ical4j.model.Calendar import net.fortuna.ical4j.model.ComponentList import net.fortuna.ical4j.model.Date @@ -283,8 +283,7 @@ open class JtxICalObject( * * @return array of filled [JtxICalObject] data objects (may have size 0) * - * @throws ParserException when the iCalendar can't be parsed - * @throws IllegalArgumentException when the iCalendar resource contains an invalid value + * @throws InvalidRemoteResourceException when the iCalendar can't be parsed * @throws IOException on I/O errors */ fun fromReader( diff --git a/lib/src/main/kotlin/at/bitfire/ical4android/Task.kt b/lib/src/main/kotlin/at/bitfire/ical4android/Task.kt index 6274904a..0ca27845 100644 --- a/lib/src/main/kotlin/at/bitfire/ical4android/Task.kt +++ b/lib/src/main/kotlin/at/bitfire/ical4android/Task.kt @@ -8,8 +8,8 @@ package at.bitfire.ical4android import androidx.annotation.IntRange import at.bitfire.ical4android.util.DateUtils +import at.bitfire.synctools.exception.InvalidRemoteResourceException import net.fortuna.ical4j.data.CalendarOutputter -import net.fortuna.ical4j.data.ParserException import net.fortuna.ical4j.model.Calendar import net.fortuna.ical4j.model.Component import net.fortuna.ical4j.model.DateTime @@ -106,8 +106,7 @@ data class Task( * * @return array of filled [Task] data objects (may have size 0) * - * @throws ParserException when the iCalendar can't be parsed - * @throws IllegalArgumentException when the iCalendar resource contains an invalid value + * @throws InvalidRemoteResourceException when the iCalendar can't be parsed * @throws IOException on I/O errors */ fun tasksFromReader(reader: Reader): List { diff --git a/lib/src/main/kotlin/at/bitfire/synctools/exception/InvalidLocalResourceException.kt b/lib/src/main/kotlin/at/bitfire/synctools/exception/InvalidLocalResourceException.kt new file mode 100644 index 00000000..825eb720 --- /dev/null +++ b/lib/src/main/kotlin/at/bitfire/synctools/exception/InvalidLocalResourceException.kt @@ -0,0 +1,17 @@ +/* + * 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.exception + +/** + * Represents an invalid local resource (for instance, an Android event). + */ +class InvalidLocalResourceException: InvalidResourceException { + + constructor(message: String): super(message) + constructor(message: String, ex: Throwable): super(message, ex) + +} \ No newline at end of file diff --git a/lib/src/main/kotlin/at/bitfire/synctools/exception/InvalidRemoteResourceException.kt b/lib/src/main/kotlin/at/bitfire/synctools/exception/InvalidRemoteResourceException.kt new file mode 100644 index 00000000..9a84758a --- /dev/null +++ b/lib/src/main/kotlin/at/bitfire/synctools/exception/InvalidRemoteResourceException.kt @@ -0,0 +1,17 @@ +/* + * 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.exception + +/** + * Represents an invalid remote resource (for instance, a calendar object resource). + */ +class InvalidRemoteResourceException: InvalidResourceException { + + constructor(message: String): super(message) + constructor(message: String, ex: Throwable): super(message, ex) + +} \ No newline at end of file diff --git a/lib/src/main/kotlin/at/bitfire/ical4android/InvalidCalendarException.kt b/lib/src/main/kotlin/at/bitfire/synctools/exception/InvalidResourceException.kt similarity index 72% rename from lib/src/main/kotlin/at/bitfire/ical4android/InvalidCalendarException.kt rename to lib/src/main/kotlin/at/bitfire/synctools/exception/InvalidResourceException.kt index 93b7f6c0..3a33e3fd 100644 --- a/lib/src/main/kotlin/at/bitfire/ical4android/InvalidCalendarException.kt +++ b/lib/src/main/kotlin/at/bitfire/synctools/exception/InvalidResourceException.kt @@ -4,9 +4,12 @@ * SPDX-License-Identifier: GPL-3.0-or-later */ -package at.bitfire.ical4android +package at.bitfire.synctools.exception -class InvalidCalendarException: Exception { +/** + * Represents an invalid resource. + */ +abstract class InvalidResourceException: Exception { constructor(message: String): super(message) constructor(message: String, ex: Throwable): super(message, ex) diff --git a/lib/src/main/kotlin/at/bitfire/synctools/icalendar/AssociatedComponents.kt b/lib/src/main/kotlin/at/bitfire/synctools/icalendar/AssociatedComponents.kt new file mode 100644 index 00000000..041532ac --- /dev/null +++ b/lib/src/main/kotlin/at/bitfire/synctools/icalendar/AssociatedComponents.kt @@ -0,0 +1,78 @@ +/* + * 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.icalendar + +import net.fortuna.ical4j.model.component.CalendarComponent +import net.fortuna.ical4j.model.component.VEvent + +/** + * Represents a set of components (like VEVENT) stored in a calendar object resource as defined + * in RFC 4791 section 4.1. It consists of + * + * - an (optional) main component, + * - optional exceptions of this main component. + * + * Note: It's possible and valid that there's no main component, but only exceptions, for instance + * when the user has been invited to a specific instance (= exception) of a recurring event, but + * not to the event as a whole (→ main event is unknown / not present). + * + * @param main main component (with or without UID, but without RECURRENCE-ID), may be `null` if only exceptions are present + * @param exceptions exceptions (each without RECURRENCE-ID); UID must be + * 1. the same as the UID of [main], + * 2. the same for all exceptions. + * + * If no [main] is present, [exceptions] must not be empty. + * + * @throws IllegalArgumentException when the constraints above are violated + */ +data class AssociatedComponents( + val main: T?, + val exceptions: List +) { + + init { + validate() + } + + /** + * Validates the requirements of [main] and [exceptions] UIDs. + * + * @throws IllegalArgumentException if [main] and/or [exceptions] UIDs don't match + */ + private fun validate() { + if (main == null && exceptions.isEmpty()) + throw IllegalArgumentException("At least one component is required") + + val mainUid = + if (main != null) { + if (main.recurrenceId != null) + throw IllegalArgumentException("Main event must not have a RECURRENCE-ID") + + main.uid + } + else + null + + val exceptionsUid = + if (exceptions.isNotEmpty()) { + if (exceptions.any { it.recurrenceId == null } ) + throw IllegalArgumentException("Exceptions must have RECURRENCE-ID") + + val firstExceptionUid = exceptions.first().uid + if (exceptions.any { it.uid != firstExceptionUid }) + throw IllegalArgumentException("Exceptions must not have different UIDs") + firstExceptionUid + } else + null + + if (main != null && exceptions.isNotEmpty() && exceptionsUid != mainUid) + throw IllegalArgumentException("Exceptions must have the same UID as the main event") + } + +} + +typealias AssociatedEvents = AssociatedComponents \ No newline at end of file diff --git a/lib/src/main/kotlin/at/bitfire/synctools/icalendar/CalendarUidSplitter.kt b/lib/src/main/kotlin/at/bitfire/synctools/icalendar/CalendarUidSplitter.kt new file mode 100644 index 00000000..2cd96283 --- /dev/null +++ b/lib/src/main/kotlin/at/bitfire/synctools/icalendar/CalendarUidSplitter.kt @@ -0,0 +1,62 @@ +/* + * 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.icalendar + +import androidx.annotation.VisibleForTesting +import net.fortuna.ical4j.model.Calendar +import net.fortuna.ical4j.model.component.CalendarComponent + +class CalendarUidSplitter { + + /** + * Splits iCalendar components by UID and classifies them as main events (without RECURRENCE-ID) + * or exceptions (with RECURRENCE-ID). + * + * When there are multiple components with the same UID and RECURRENCE-ID, but different SEQUENCE, + * this method keeps only the ones with the highest SEQUENCE. + */ + fun associateByUid(calendar: Calendar, componentName: String): Map> { + // get all components of type T (for instance: all VEVENTs) + val all = calendar.getComponents(componentName) + + // Note for VEVENT: UID is REQUIRED in RFC 5545 section 3.6.1, but optional in RFC 2445 section 4.6.1, + // so it's possible that the Uid is null. + val byUid: Map> = all + .groupBy { it.uid?.value } + .mapValues { filterBySequence(it.value) } + + val result = mutableMapOf>() + for ((uid, vEventsWithUid) in byUid) { + val mainVEvent = vEventsWithUid.lastOrNull { it.recurrenceId == null } + val exceptions = vEventsWithUid.filter { it.recurrenceId != null } + result[uid] = AssociatedComponents(mainVEvent, exceptions) + } + + return result + } + + /** + * Keeps only the events with the highest SEQUENCE (per RECURRENCE-ID). + * + * @param events list of VEVENTs with the same UID, but different RECURRENCE-IDs (may be `null`) and SEQUENCEs + * + * @return same as input list, but each RECURRENCE-ID occurs only with the highest SEQUENCE + */ + @VisibleForTesting + internal fun filterBySequence(events: List): List { + // group by RECURRENCE-ID (may be null) + val byRecurId = events.groupBy { it.recurrenceId?.value }.values + + // for every RECURRENCE-ID: keep only event with highest sequence + val latest = byRecurId.map { sameUidAndRecurId -> + sameUidAndRecurId.maxBy { it.sequence?.sequenceNo ?: 0 } + } + + return latest + } + +} \ No newline at end of file diff --git a/lib/src/main/kotlin/at/bitfire/synctools/icalendar/ICalendarParser.kt b/lib/src/main/kotlin/at/bitfire/synctools/icalendar/ICalendarParser.kt new file mode 100644 index 00000000..aec4ae70 --- /dev/null +++ b/lib/src/main/kotlin/at/bitfire/synctools/icalendar/ICalendarParser.kt @@ -0,0 +1,65 @@ +/* + * 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.icalendar + +import at.bitfire.ical4android.validation.ICalPreprocessor +import at.bitfire.synctools.exception.InvalidRemoteResourceException +import net.fortuna.ical4j.data.CalendarBuilder +import net.fortuna.ical4j.data.CalendarParserFactory +import net.fortuna.ical4j.data.ContentHandlerContext +import net.fortuna.ical4j.data.ParserException +import net.fortuna.ical4j.model.Calendar +import net.fortuna.ical4j.model.TimeZoneRegistryFactory +import java.io.Reader +import java.util.logging.Level +import java.util.logging.Logger + +/** + * Custom iCalendar parser that applies error correction using [ICalPreprocessor]. + */ +class ICalendarParser { + + private val logger + get() = Logger.getLogger(javaClass.name) + + /** + * Parses the given iCalendar as lenient as possible and applies some error correction: + * + * 1. The input stream from is preprocessed with [ICalPreprocessor.preprocessStream]. + * 2. The parsed calendar is preprocessed with [ICalPreprocessor.preprocessCalendar]. + * + * @throws InvalidRemoteResourceException 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 + val preprocessed = ICalPreprocessor.preprocessStream(reader) + + // parse stream, ignoring invalid properties (if possible) + val calendar: Calendar + try { + calendar = CalendarBuilder( + /* parser = */ CalendarParserFactory.getInstance().get(), + /* contentHandlerContext = */ ContentHandlerContext().withSupressInvalidProperties(/* supressInvalidProperties = */ true), + /* tzRegistry = */ TimeZoneRegistryFactory.getInstance().createRegistry() // AndroidCompatTimeZoneRegistry + ).build(preprocessed) + } catch(e: ParserException) { + throw InvalidRemoteResourceException("Couldn't parse iCalendar", e) + } catch(e: IllegalArgumentException) { + throw InvalidRemoteResourceException("iCalendar contains invalid value", e) + } + + // Pre-process calendar for increased compatibility (fixes some common errors) + try { + ICalPreprocessor.preprocessCalendar(calendar) + } catch (e: Exception) { + logger.log(Level.WARNING, "Couldn't pre-process iCalendar", e) + } + + return calendar + } + +} \ No newline at end of file diff --git a/lib/src/main/kotlin/at/bitfire/synctools/icalendar/Ical4jHelpers.kt b/lib/src/main/kotlin/at/bitfire/synctools/icalendar/Ical4jHelpers.kt new file mode 100644 index 00000000..7fa6dec8 --- /dev/null +++ b/lib/src/main/kotlin/at/bitfire/synctools/icalendar/Ical4jHelpers.kt @@ -0,0 +1,36 @@ +/* + * 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.icalendar + +import net.fortuna.ical4j.model.ComponentList +import net.fortuna.ical4j.model.Property +import net.fortuna.ical4j.model.PropertyList +import net.fortuna.ical4j.model.component.CalendarComponent +import net.fortuna.ical4j.model.property.RecurrenceId +import net.fortuna.ical4j.model.property.Sequence +import net.fortuna.ical4j.model.property.Uid + + +fun componentListOf(vararg components: T) = + ComponentList().apply { + addAll(components) + } + +fun propertyListOf(vararg properties: Property) = + PropertyList().apply { + addAll(properties) + } + + +val CalendarComponent.uid: Uid? + get() = getProperty(Property.UID) + +val CalendarComponent.recurrenceId: RecurrenceId? + get() = getProperty(Property.RECURRENCE_ID) + +val CalendarComponent.sequence: Sequence? + get() = getProperty(Property.SEQUENCE) diff --git a/lib/src/test/kotlin/at/bitfire/synctools/icalendar/AssociatedComponentsTest.kt b/lib/src/test/kotlin/at/bitfire/synctools/icalendar/AssociatedComponentsTest.kt new file mode 100644 index 00000000..679b6b9a --- /dev/null +++ b/lib/src/test/kotlin/at/bitfire/synctools/icalendar/AssociatedComponentsTest.kt @@ -0,0 +1,79 @@ +/* + * 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.icalendar + +import net.fortuna.ical4j.model.Date +import net.fortuna.ical4j.model.component.VEvent +import net.fortuna.ical4j.model.property.RecurrenceId +import net.fortuna.ical4j.model.property.Uid +import org.junit.Test + +class AssociatedComponentsTest { + + @Test(expected = IllegalArgumentException::class) + fun testEmpty() { + AssociatedEvents(null, emptyList()) + } + + @Test + fun testOnlyExceptions_UidNull() { + AssociatedEvents(null, listOf( + VEvent(propertyListOf( + RecurrenceId(Date("20250629")) + )) + )) + } + + @Test + fun testOnlyExceptions_UidNotNull() { + AssociatedEvents(null, listOf( + VEvent(propertyListOf( + Uid("test1"), + RecurrenceId(Date("20250629")) + )) + )) + } + + @Test(expected = IllegalArgumentException::class) + fun testOnlyExceptions_UidNotIdentical() { + AssociatedEvents(null, listOf( + VEvent(propertyListOf( + RecurrenceId(Date("20250629")) + )), + VEvent(propertyListOf( + Uid("test1"), + RecurrenceId(Date("20250630")) + )) + )) + } + + @Test + fun testOnlyMain_NoUid() { + AssociatedEvents(VEvent(), emptyList()) + } + + @Test(expected = IllegalArgumentException::class) + fun testOnlyMain_RecurId() { + AssociatedEvents(VEvent(propertyListOf( + RecurrenceId(Date("20250629")) + )), emptyList()) + } + + @Test + fun testOnlyMain_Uid() { + AssociatedEvents(VEvent(propertyListOf(Uid("test1"))), emptyList()) + } + + @Test(expected = IllegalArgumentException::class) + fun testOnlyMain_UidAndRecurId() { + AssociatedEvents(VEvent(propertyListOf( + Uid("test1"), + RecurrenceId(Date("20250629")) + )), emptyList()) + } + +} \ No newline at end of file diff --git a/lib/src/test/kotlin/at/bitfire/synctools/icalendar/CalendarUidSplitterTest.kt b/lib/src/test/kotlin/at/bitfire/synctools/icalendar/CalendarUidSplitterTest.kt new file mode 100644 index 00000000..ce06de1f --- /dev/null +++ b/lib/src/test/kotlin/at/bitfire/synctools/icalendar/CalendarUidSplitterTest.kt @@ -0,0 +1,180 @@ +/* + * 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.icalendar + +import net.fortuna.ical4j.model.Calendar +import net.fortuna.ical4j.model.Component +import net.fortuna.ical4j.model.ComponentList +import net.fortuna.ical4j.model.Date +import net.fortuna.ical4j.model.component.VEvent +import net.fortuna.ical4j.model.property.RecurrenceId +import net.fortuna.ical4j.model.property.Sequence +import net.fortuna.ical4j.model.property.Uid +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class CalendarUidSplitterTest { + + @Test + fun testAssociatedVEventsByUid_Empty() { + val calendar = Calendar(ComponentList()) + val result = CalendarUidSplitter().associateByUid(calendar, Component.VEVENT) + assertTrue(result.isEmpty()) + } + + @Test + fun testAssociatedVEventsByUid_ExceptionOnly_NoUid() { + val exception = VEvent(propertyListOf( + RecurrenceId("20250629T000000Z") + )) + val calendar = Calendar(componentListOf(exception)) + val result = CalendarUidSplitter().associateByUid(calendar, Component.VEVENT) + assertEquals( + mapOf( + null to AssociatedEvents(null, listOf(exception)) + ), + result + ) + } + + @Test + fun testAssociatedVEventsByUid_MainOnly_NoUid() { + val mainEvent = VEvent() + val calendar = Calendar(componentListOf(mainEvent)) + val result = CalendarUidSplitter().associateByUid(calendar, Component.VEVENT) + assertEquals( + mapOf( + null to AssociatedEvents(mainEvent, emptyList()) + ), + result + ) + } + + @Test + fun testAssociatedVEventsByUid_MainOnly_WithUid() { + val mainEvent = VEvent(propertyListOf( + Uid("main") + )) + val calendar = Calendar(componentListOf(mainEvent)) + val result = CalendarUidSplitter().associateByUid(calendar, Component.VEVENT) + assertEquals( + mapOf( + "main" to AssociatedEvents(mainEvent, emptyList()) + ), + result + ) + } + + + @Test + fun testFilterBySequence_Empty() { + val result = CalendarUidSplitter().filterBySequence(emptyList()) + assertEquals(emptyList(), result) + } + + @Test + fun testFilterBySequence_MainAndExceptions_MultipleSequences() { + val mainEvent1a = VEvent(propertyListOf(Sequence(1))) + val mainEvent1b = VEvent(propertyListOf(Sequence(2))) + val exception1a = VEvent(propertyListOf( + RecurrenceId("20250629T000000Z"), + Sequence(1) + )) + val exception1b = VEvent(propertyListOf( + RecurrenceId("20250629T000000Z"), + Sequence(2) + )) + val exception1c = VEvent(propertyListOf( + RecurrenceId("20250629T000000Z"), + Sequence(3) + )) + val exception2a = VEvent(propertyListOf( + RecurrenceId(Date("20250629")) + // Sequence(0) + )) + val exception2b = VEvent(propertyListOf( + RecurrenceId(Date("20250629")), + Sequence(1) + )) + val result = CalendarUidSplitter().filterBySequence( + listOf(mainEvent1a, mainEvent1b, exception1a, exception1c, exception1b, exception2a, exception2b) + ) + assertEquals(listOf(mainEvent1b, exception1c, exception2b), result) + } + + @Test + fun testFilterBySequence_MainAndExceptions_SingleSequence() { + val mainEvent = VEvent(propertyListOf(Sequence(1))) + val exception1 = VEvent(propertyListOf( + RecurrenceId("20250629T000000Z"), + Sequence(1) + )) + val exception2 = VEvent(propertyListOf( + RecurrenceId(Date("20250629")) + // Sequence(0) + )) + val result = CalendarUidSplitter().filterBySequence( + listOf(mainEvent, exception1, exception2) + ) + assertEquals(listOf(mainEvent, exception1, exception2), result) + } + + @Test + fun testFilterBySequence_OnlyException_SingleSequence() { + val exception = VEvent(propertyListOf( + RecurrenceId("20250629T000000Z") + )) + val result = CalendarUidSplitter().filterBySequence(listOf(exception)) + assertEquals(listOf(exception), result) + } + + @Test + fun testFilterBySequence_OnlyExceptions_MultipleSequences() { + val exception1a = VEvent(propertyListOf( + RecurrenceId("20250629T000000Z"), + Sequence(1) + )) + val exception1b = VEvent(propertyListOf( + RecurrenceId("20250629T000000Z"), + Sequence(2) + )) + val exception1c = VEvent(propertyListOf( + RecurrenceId("20250629T000000Z"), + Sequence(3) + )) + val exception2a = VEvent(propertyListOf( + RecurrenceId(Date("20250629")) + // Sequence(0) + )) + val exception2b = VEvent(propertyListOf( + RecurrenceId(Date("20250629")), + Sequence(1) + )) + val result = CalendarUidSplitter().filterBySequence( + listOf(exception1a, exception1c, exception1b, exception2a, exception2b) + ) + assertEquals(listOf(exception1c, exception2b), result) + } + + @Test + fun testFilterBySequence_OnlyMain_SingleSequence() { + val mainEvent = VEvent() + val result = CalendarUidSplitter().filterBySequence(listOf(mainEvent)) + assertEquals(listOf(mainEvent), result) + } + + @Test + fun testFilterBySequence_OnlyMain_MultipleSequences() { + val mainEvent1a = VEvent(propertyListOf(Sequence(1))) + val mainEvent1b = VEvent(propertyListOf(Sequence(2))) + val mainEvent1c = VEvent(propertyListOf(Sequence(2))) + val result = CalendarUidSplitter().filterBySequence(listOf(mainEvent1a, mainEvent1c, mainEvent1b)) + assertEquals(listOf(mainEvent1c), result) + } + +} \ No newline at end of file diff --git a/lib/src/test/kotlin/at/bitfire/synctools/icalendar/ICalendarParserTest.kt b/lib/src/test/kotlin/at/bitfire/synctools/icalendar/ICalendarParserTest.kt new file mode 100644 index 00000000..c4df7d84 --- /dev/null +++ b/lib/src/test/kotlin/at/bitfire/synctools/icalendar/ICalendarParserTest.kt @@ -0,0 +1,62 @@ +/* + * 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.icalendar + +import at.bitfire.ical4android.validation.ICalPreprocessor +import at.bitfire.synctools.exception.InvalidRemoteResourceException +import io.mockk.junit4.MockKRule +import io.mockk.mockkObject +import io.mockk.verify +import org.junit.Rule +import org.junit.Test +import java.io.StringReader + +class ICalendarParserTest { + + @get:Rule + val mockkRule = MockKRule(this) + + @Test + fun testParse_AppliesPreProcessing() { + mockkObject(ICalPreprocessor) + + val reader = StringReader( + "BEGIN:VCALENDAR\r\n" + + "BEGIN:VEVENT\r\n" + + "END:VEVENT\r\n" + + "END:VCALENDAR\r\n" + ) + val cal = ICalendarParser().parse(reader) + + verify(exactly = 1) { + // verify preprocessing was applied to stream + ICalPreprocessor.preprocessStream(any()) + + // verify preprocessing was applied to resulting calendar + ICalPreprocessor.preprocessCalendar(cal) + } + } + + @Test + fun testParse_SuppressesInvalidProperties() { + val reader = StringReader( + "BEGIN:VCALENDAR\r\n" + + "BEGIN:VEVENT\r\n" + + "DTSTAMP:invalid\r\n" + + "END:VEVENT\r\n" + + "END:VCALENDAR\r\n" + ) + ICalendarParser().parse(reader) + } + + @Test(expected = InvalidRemoteResourceException::class) + fun testParse_ThrowsExceptionOnInvalidInput() { + val reader = StringReader("invalid") + ICalendarParser().parse(reader) + } + +} \ No newline at end of file