From 22dfb270ee783290a635ad4e3594fd8c9ad3d5ec Mon Sep 17 00:00:00 2001 From: Ricki Hirner Date: Thu, 7 Jul 2022 22:10:14 +0200 Subject: Update dependencies (#46) * Update dependencies * Trying to fix the Overload resolution ambiguity error for tests Co-authored-by: Patrick Lang <72232737+patrickunterwegs@users.noreply.github.com> --- build.gradle | 7 +++---- src/main/java/at/bitfire/ical4android/JtxICalObject.kt | 18 +++++++++++------- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/build.gradle b/build.gradle index 99e65ca..5feb4ab 100644 --- a/build.gradle +++ b/build.gradle @@ -5,9 +5,9 @@ buildscript { ext.versions = [ - kotlin: '1.6.21', + kotlin: '1.7.0', dokka: '1.5.0', - ical4j: '3.1.2', + ical4j: '3.2.0', // tests fail with >= 3.2.1: https://github.com/ical4j/ical4j/issues/350#issuecomment-1177290922 – update as soon as this is fixed // latest Apache Commons versions that don't require Java 8 (Android 7) commonsIO: '2.6' ] @@ -55,7 +55,6 @@ android { } kotlinOptions { jvmTarget = "1.8" - useIR = true } packagingOptions { resources { @@ -82,7 +81,7 @@ android { dependencies { implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:${versions.kotlin}" - coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.1.5' + coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.1.6' api("org.mnode.ical4j:ical4j:${versions.ical4j}") { exclude module: 'javax.mail' diff --git a/src/main/java/at/bitfire/ical4android/JtxICalObject.kt b/src/main/java/at/bitfire/ical4android/JtxICalObject.kt index cfaae2f..91d53ed 100644 --- a/src/main/java/at/bitfire/ical4android/JtxICalObject.kt +++ b/src/main/java/at/bitfire/ical4android/JtxICalObject.kt @@ -273,13 +273,17 @@ open class JtxICalObject( } // remove properties to add the rest to other - component.properties.remove(component.action) - component.properties.remove(component.summary) - component.properties.remove(component.description) - component.properties.remove(component.duration) - component.properties.remove(component.attachment) - component.properties.remove(component.repeat) - component.properties.remove(component.trigger) + component.properties.removeAll( + setOf( + component.action, + component.summary, + component.description, + component.duration, + component.attachment, + component.repeat, + component.trigger + ) + ) component.properties?.let { vAlarmProps -> this.other = JtxContract.getJsonStringFromXProperties(vAlarmProps) } } iCalObject.alarms.add(jtxAlarm) -- cgit v1.2.3 From 41171b57fdc5f045b17d09df85853a6cc13ef4fd Mon Sep 17 00:00:00 2001 From: Sunik Kupfer Date: Fri, 15 Jul 2022 21:40:42 +0200 Subject: Update to ical4j 3.2.4 (closes bitfireAT/ical4android#47) (#48) * Update to ical4j 3.2.4 (closes bitfireAT/ical4android#47) * exclude commons-logging, due to DuplicatePlatformClasses issue --- build.gradle | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/build.gradle b/build.gradle index 5feb4ab..a45d059 100644 --- a/build.gradle +++ b/build.gradle @@ -7,7 +7,7 @@ buildscript { ext.versions = [ kotlin: '1.7.0', dokka: '1.5.0', - ical4j: '3.2.0', // tests fail with >= 3.2.1: https://github.com/ical4j/ical4j/issues/350#issuecomment-1177290922 – update as soon as this is fixed + ical4j: '3.2.4', // latest Apache Commons versions that don't require Java 8 (Android 7) commonsIO: '2.6' ] @@ -84,7 +84,7 @@ dependencies { coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.1.6' api("org.mnode.ical4j:ical4j:${versions.ical4j}") { - exclude module: 'javax.mail' + exclude group: 'commons-logging' exclude group: 'org.codehaus.groovy', module: 'groovy' exclude group: 'org.codehaus.groovy', module: 'groovy-dateutil' } @@ -94,8 +94,6 @@ dependencies { // noinspection GradleDependency api("org.apache.commons:commons-lang3:3.8.1") { force = true } - // ical4j requires JavaMail API (e.g. for EMAIL parameter) - implementation 'com.sun.mail:android-mail:1.6.7' // noinspection GradleDependency implementation "commons-io:commons-io:${versions.commonsIO}" -- cgit v1.2.3 From ccea6cbd487b0872d7e399f290fd00ffbb08f37f Mon Sep 17 00:00:00 2001 From: Sunik Kupfer Date: Fri, 5 Aug 2022 21:27:29 +0200 Subject: validation and repairing (closes bitfireAT/ical4android#39, bitfireAT/davx5#8) (#45) * Drop RRULEs with UNTIL before DTSTART Note: Validation/repair rules should be better formalized and modularized * Tests show timezone problems * validation and repairing (closes bitfireAT/ical4android#39, bitfireAT/davx5#8) * As discussed in the call * validation and repairing (closes bitfireAT/ical4android#39, bitfireAT/davx5#8) * Move validator to validation package * Minor reformatting * use time from DTSTART in UNTIL instead of midnight * merge remote changes * drop ICalPreprocessor validation rule * make EventValidator a class with only "repair()" as API and other methods internal * validate each Event created from a VEvent before saving it to calendar provider, as well as each Event retrieved from calendar provider before creating a VEvent from it * add tests to cover timezone cases * stop use of strings and parsing for date building and add a until timezone in test * [WIP] fix test with timezone offsets * be more lenient with the result when repairing events where time is cut off Co-authored-by: Ricki Hirner --- .../at/bitfire/ical4android/AndroidEventTest.kt | 12 +- .../java/at/bitfire/ical4android/AndroidEvent.kt | 5 +- src/main/java/at/bitfire/ical4android/Event.kt | 12 +- .../at/bitfire/ical4android/ICalPreprocessor.kt | 108 ------- src/main/java/at/bitfire/ical4android/ICalendar.kt | 1 + .../ical4android/validation/EventValidator.kt | 107 +++++++ .../ical4android/validation/ICalPreprocessor.kt | 109 +++++++ .../bitfire/ical4android/ICalPreprocessorTest.kt | 1 + .../ical4android/validation/EventValidatorTest.kt | 314 +++++++++++++++++++++ 9 files changed, 545 insertions(+), 124 deletions(-) delete mode 100644 src/main/java/at/bitfire/ical4android/ICalPreprocessor.kt create mode 100644 src/main/java/at/bitfire/ical4android/validation/EventValidator.kt create mode 100644 src/main/java/at/bitfire/ical4android/validation/ICalPreprocessor.kt create mode 100644 src/test/java/at/bitfire/ical4android/validation/EventValidatorTest.kt diff --git a/src/androidTest/java/at/bitfire/ical4android/AndroidEventTest.kt b/src/androidTest/java/at/bitfire/ical4android/AndroidEventTest.kt index 5eb0bdc..c9ff0b2 100644 --- a/src/androidTest/java/at/bitfire/ical4android/AndroidEventTest.kt +++ b/src/androidTest/java/at/bitfire/ical4android/AndroidEventTest.kt @@ -10,9 +10,7 @@ import android.content.ContentUris import android.content.ContentValues import android.database.DatabaseUtils import android.net.Uri -import android.provider.CalendarContract import android.provider.CalendarContract.* -import androidx.test.filters.LargeTest import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation import androidx.test.rule.GrantPermissionRule import at.bitfire.ical4android.MiscUtils.ContentProviderClientHelper.closeCompat @@ -21,7 +19,6 @@ import at.bitfire.ical4android.impl.TestCalendar import at.bitfire.ical4android.impl.TestEvent import at.bitfire.ical4android.util.AndroidTimeUtils import net.fortuna.ical4j.model.* -import net.fortuna.ical4j.model.Date import net.fortuna.ical4j.model.component.VAlarm import net.fortuna.ical4j.model.parameter.* import net.fortuna.ical4j.model.property.* @@ -31,7 +28,6 @@ import org.junit.Assert.* import java.net.URI import java.time.Duration import java.time.Period -import java.util.* class AndroidEventTest { @@ -49,7 +45,7 @@ class AndroidEventTest { @BeforeClass @JvmStatic fun connectProvider() { - provider = getInstrumentation().targetContext.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)!! + provider = getInstrumentation().targetContext.contentResolver.acquireContentProviderClient(AUTHORITY)!! } @AfterClass @@ -60,7 +56,7 @@ class AndroidEventTest { } - private val testAccount = Account("ical4android@example.com", CalendarContract.ACCOUNT_TYPE_LOCAL) + private val testAccount = Account("ical4android@example.com", ACCOUNT_TYPE_LOCAL) private val tzVienna = DateUtils.ical4jTimeZone("Europe/Vienna")!! private val tzShanghai = DateUtils.ical4jTimeZone("Asia/Shanghai")!! @@ -75,7 +71,7 @@ class AndroidEventTest { fun prepare() { calendar = TestCalendar.findOrCreate(testAccount, provider) assertNotNull(calendar) - calendarUri = ContentUris.withAppendedId(CalendarContract.Calendars.CONTENT_URI, calendar.id) + calendarUri = ContentUris.withAppendedId(Calendars.CONTENT_URI, calendar.id) } @After @@ -1804,7 +1800,7 @@ class AndroidEventTest { @Test fun testPopulateReminder_TypeEmail_AccountNameNotEmail() { // test account name that doesn't look like an email address - val nonEmailAccount = Account("ical4android", CalendarContract.ACCOUNT_TYPE_LOCAL) + val nonEmailAccount = Account("ical4android", ACCOUNT_TYPE_LOCAL) val testCalendar = TestCalendar.findOrCreate(nonEmailAccount, provider) try { populateReminder(testCalendar) { diff --git a/src/main/java/at/bitfire/ical4android/AndroidEvent.kt b/src/main/java/at/bitfire/ical4android/AndroidEvent.kt index a465c06..4230ee0 100644 --- a/src/main/java/at/bitfire/ical4android/AndroidEvent.kt +++ b/src/main/java/at/bitfire/ical4android/AndroidEvent.kt @@ -25,6 +25,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.ical4android.validation.EventValidator import net.fortuna.ical4j.model.* import net.fortuna.ical4j.model.Date import net.fortuna.ical4j.model.component.VAlarm @@ -715,11 +716,12 @@ abstract class AndroidEvent( val dtStart = event.dtStart ?: throw InvalidCalendarException("Events must have DTSTART") val allDay = DateUtils.isDate(dtStart) - val recurring = event.rRules.isNotEmpty() || event.rDates.isNotEmpty() // make sure that time zone is supported by Android AndroidTimeUtils.androidifyTimeZone(dtStart) + val recurring = event.rRules.isNotEmpty() || event.rDates.isNotEmpty() + /* [CalendarContract.Events SDK documentation] When inserting a new event the following fields must be included: - dtstart @@ -778,6 +780,7 @@ abstract class AndroidEvent( builder .withValue(Events.DURATION, duration?.toRfc5545Duration(dtStart.date.toInstant())) .withValue(Events.DTEND, null) + // add RRULe if (event.rRules.isNotEmpty()) builder.withValue(Events.RRULE, event.rRules.joinToString(AndroidTimeUtils.RECURRENCE_RULE_SEPARATOR) { it.value }) else diff --git a/src/main/java/at/bitfire/ical4android/Event.kt b/src/main/java/at/bitfire/ical4android/Event.kt index fcbb67f..3f2e567 100644 --- a/src/main/java/at/bitfire/ical4android/Event.kt +++ b/src/main/java/at/bitfire/ical4android/Event.kt @@ -6,6 +6,7 @@ package at.bitfire.ical4android import at.bitfire.ical4android.DateUtils.isDateTime import at.bitfire.ical4android.ICalendar.Companion.CALENDAR_NAME +import at.bitfire.ical4android.validation.EventValidator import net.fortuna.ical4j.data.CalendarOutputter import net.fortuna.ical4j.data.ParserException import net.fortuna.ical4j.model.* @@ -194,13 +195,8 @@ class Event: ICalendar() { e.alarms.addAll(event.alarms) - // validation - if (e.dtStart == null) - throw InvalidCalendarException("Event without start time") - else if (e.dtEnd != null && e.dtStart!!.date > e.dtEnd!!.date) { - Ical4Android.log.warning("DTSTART after DTEND; removing DTEND") - e.dtEnd = null - } + // validate and repair + EventValidator(e).repair() return e } @@ -221,6 +217,8 @@ class Event: ICalendar() { val dtStart = dtStart ?: throw InvalidCalendarException("Won't generate event without start time") + EventValidator(this).repair() // validate and repair this event before creating VEVENT + // "main event" (without exceptions) val components = ical.components val mainEvent = toVEvent() diff --git a/src/main/java/at/bitfire/ical4android/ICalPreprocessor.kt b/src/main/java/at/bitfire/ical4android/ICalPreprocessor.kt deleted file mode 100644 index ae7293c..0000000 --- a/src/main/java/at/bitfire/ical4android/ICalPreprocessor.kt +++ /dev/null @@ -1,108 +0,0 @@ -/*************************************************************************************************** - * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. - **************************************************************************************************/ - -package at.bitfire.ical4android - -import net.fortuna.ical4j.model.Calendar -import net.fortuna.ical4j.model.Property -import net.fortuna.ical4j.transform.rfc5545.CreatedPropertyRule -import net.fortuna.ical4j.transform.rfc5545.DateListPropertyRule -import net.fortuna.ical4j.transform.rfc5545.DatePropertyRule -import net.fortuna.ical4j.transform.rfc5545.Rfc5545PropertyRule -import org.apache.commons.io.IOUtils -import java.io.IOException -import java.io.Reader -import java.io.StringReader -import java.util.* -import java.util.logging.Level - -/** - * Applies some rules to increase compatibility or parsed iCalendars: - * - * - [CreatedPropertyRule] to make sure CREATED is UTC - * - [DatePropertyRule], [DateListPropertyRule]: to rename Outlook-specific TZID parameters - * (like "W. Europe Standard Time" to an Android-friendly name like "Europe/Vienna") - * - */ -object ICalPreprocessor { - - private val TZOFFSET_REGEXP = Regex("^(TZOFFSET(FROM|TO):[+\\-]?)((18|19|[2-6]\\d)\\d\\d)$", RegexOption.MULTILINE) - - private val propertyRules = arrayOf( - CreatedPropertyRule(), // make sure CREATED is UTC - - DatePropertyRule(), // These two rules also replace VTIMEZONEs of the iCalendar ... - DateListPropertyRule() // ... by the ical4j VTIMEZONE with the same TZID! - ) - - - /** - * Some servers modify UTC offsets in TZOFFSET(FROM,TO) like "+005730" to an invalid "+5730". - * - * Rewrites values of all TZOFFSETFROM and TZOFFSETTO properties which match [TZOFFSET_REGEXP] - * so that an hour value of 00 is inserted. - * - * @param reader Reader that reads the potentially broken iCalendar (which for instance contains `TZOFFSETFROM:+5730`) - * @return Reader that reads the fixed iCalendar (for instance `TZOFFSETFROM:+005730`) - */ - fun fixInvalidUtcOffset(reader: Reader): Reader { - fun fixStringFromReader() = - IOUtils.toString(reader).replace(TZOFFSET_REGEXP) { - Ical4Android.log.log(Level.FINE, "Applying Synology WebDAV fix to invalid utc-offset", it.value) - "${it.groupValues[1]}00${it.groupValues[3]}" - } - - var result: String? = null - - val resetSupported = try { - reader.reset() - true - } catch(e: IOException) { - false - } - - if (resetSupported) { - // reset is supported, no need to copy the whole stream to another String (unless we have to fix the TZOFFSET) - if (Scanner(reader).findWithinHorizon(TZOFFSET_REGEXP.toPattern(), 0) != null) { - reader.reset() - result = fixStringFromReader() - } - } else - result = fixStringFromReader() - - if (result != null) - return StringReader(result) - - // not modified, return original iCalendar - reader.reset() - return reader - } - - - /** - * Applies the set of rules (see class definition) to a given calendar object. - * - * @param calendar the calendar object that is going to be modified - */ - fun preProcess(calendar: Calendar) { - for (component in calendar.components) { - for (property in component.properties) - applyRules(property) - } - } - - @Suppress("UNCHECKED_CAST") - private fun applyRules(property: Property) { - propertyRules - .filter { rule -> rule.supportedType.isAssignableFrom(property::class.java) } - .forEach { - val beforeStr = property.toString() - (it as Rfc5545PropertyRule).applyTo(property) - val afterStr = property.toString() - if (beforeStr != afterStr) - Ical4Android.log.log(Level.FINER, "$beforeStr -> $afterStr") - } - } - -} \ No newline at end of file diff --git a/src/main/java/at/bitfire/ical4android/ICalendar.kt b/src/main/java/at/bitfire/ical4android/ICalendar.kt index a6ea234..678b195 100644 --- a/src/main/java/at/bitfire/ical4android/ICalendar.kt +++ b/src/main/java/at/bitfire/ical4android/ICalendar.kt @@ -4,6 +4,7 @@ package at.bitfire.ical4android +import at.bitfire.ical4android.validation.ICalPreprocessor import net.fortuna.ical4j.data.CalendarBuilder import net.fortuna.ical4j.data.ParserException import net.fortuna.ical4j.model.Calendar diff --git a/src/main/java/at/bitfire/ical4android/validation/EventValidator.kt b/src/main/java/at/bitfire/ical4android/validation/EventValidator.kt new file mode 100644 index 0000000..ac0e108 --- /dev/null +++ b/src/main/java/at/bitfire/ical4android/validation/EventValidator.kt @@ -0,0 +1,107 @@ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ + +package at.bitfire.ical4android.validation + +import at.bitfire.ical4android.DateUtils +import at.bitfire.ical4android.Event +import at.bitfire.ical4android.Ical4Android +import at.bitfire.ical4android.InvalidCalendarException +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.toZoneIdCompat +import net.fortuna.ical4j.model.Date +import net.fortuna.ical4j.model.DateTime +import net.fortuna.ical4j.model.property.DtStart +import net.fortuna.ical4j.model.property.RRule +import java.time.* + +/** + * Sometimes CalendarStorage or servers respond with invalid event definitions. Here we try to + * validate, repair and assume whatever seems appropriate before denying the whole event. + */ +class EventValidator(val e: Event) { + + fun repair() { + val dtStart = correctStartAndEndTime(e) + sameTypeForDtStartAndRruleUntil(dtStart, e.rRules) + removeRRulesWithUntilBeforeDtStart(dtStart, e.rRules) + } + + companion object { + /** + * Ensure proper start and end time + */ + internal fun correctStartAndEndTime(e: Event): DtStart { + val dtStart = e.dtStart ?: throw InvalidCalendarException("Event without start time") + e.dtEnd?.let { dtEnd -> + if (dtStart.date > dtEnd.date) { + Ical4Android.log.warning("DTSTART after DTEND; removing DTEND") + e.dtEnd = null + } + } + return dtStart + } + + /** + * Tries to make the value type of UNTIL and DTSTART the same (both DATE or DATETIME). + */ + internal fun sameTypeForDtStartAndRruleUntil(dtStart: DtStart, rRules: MutableList) { + if (DateUtils.isDate(dtStart)) { + for (rRule in rRules) { + rRule.recur.until?.let { until -> + if (until is DateTime) { + Ical4Android.log.warning("DTSTART has DATE, but UNTIL has DATETIME; making UNTIL have DATE only") + rRule.recur.until = until.toLocalDate().toIcal4jDate() + } + } + } + } else if (DateUtils.isDateTime(dtStart)) { + for (rRule in rRules) { + rRule.recur.until?.let { until -> + if (until !is DateTime) { + Ical4Android.log.warning("DTSTART has DATETIME, but UNTIL has DATE; copying time from DTSTART to UNTIL") + val timeZone = if (dtStart.timeZone != null) + dtStart.timeZone.toZoneIdCompat() + else if (dtStart.isUtc) + ZoneOffset.UTC + else /* floating time */ + ZoneId.systemDefault() + rRule.recur.until = + ZonedDateTime.of( + until.toLocalDate(), // date from until + LocalTime.ofInstant(dtStart.date.toInstant(), timeZone), // time from dtStart + timeZone + ).toIcal4jDateTime() + } + } + } + } else + throw InvalidCalendarException("Event with invalid DTSTART value") + } + + /** + * Will remove the RRULES of an event where UNTIL lies before DTSTART + */ + internal fun removeRRulesWithUntilBeforeDtStart(dtStart: DtStart, rRules: MutableList) { + 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 + */ + internal fun hasUntilBeforeDtStart(dtStart: DtStart, rRule: RRule): Boolean { + val until = rRule.recur.until ?: return false + return until < dtStart.date + } + } +} \ No newline at end of file diff --git a/src/main/java/at/bitfire/ical4android/validation/ICalPreprocessor.kt b/src/main/java/at/bitfire/ical4android/validation/ICalPreprocessor.kt new file mode 100644 index 0000000..f76ff12 --- /dev/null +++ b/src/main/java/at/bitfire/ical4android/validation/ICalPreprocessor.kt @@ -0,0 +1,109 @@ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ + +package at.bitfire.ical4android.validation + +import at.bitfire.ical4android.Ical4Android +import net.fortuna.ical4j.model.Calendar +import net.fortuna.ical4j.model.Property +import net.fortuna.ical4j.transform.rfc5545.CreatedPropertyRule +import net.fortuna.ical4j.transform.rfc5545.DateListPropertyRule +import net.fortuna.ical4j.transform.rfc5545.DatePropertyRule +import net.fortuna.ical4j.transform.rfc5545.Rfc5545PropertyRule +import org.apache.commons.io.IOUtils +import java.io.IOException +import java.io.Reader +import java.io.StringReader +import java.util.* +import java.util.logging.Level + +/** + * Applies some rules to increase compatibility of parsed (incoming) iCalendars: + * + * - [CreatedPropertyRule] to make sure CREATED is UTC + * - [DatePropertyRule], [DateListPropertyRule] to rename Outlook-specific TZID parameters + * (like "W. Europe Standard Time" to an Android-friendly name like "Europe/Vienna") + * + */ +object ICalPreprocessor { + + private val TZOFFSET_REGEXP = Regex("^(TZOFFSET(FROM|TO):[+\\-]?)((18|19|[2-6]\\d)\\d\\d)$", RegexOption.MULTILINE) + + private val propertyRules = arrayOf( + CreatedPropertyRule(), // make sure CREATED is UTC + + DatePropertyRule(), // These two rules also replace VTIMEZONEs of the iCalendar ... + DateListPropertyRule(), // ... by the ical4j VTIMEZONE with the same TZID! + ) + + + /** + * Some servers modify UTC offsets in TZOFFSET(FROM,TO) like "+005730" to an invalid "+5730". + * + * Rewrites values of all TZOFFSETFROM and TZOFFSETTO properties which match [TZOFFSET_REGEXP] + * so that an hour value of 00 is inserted. + * + * @param reader Reader that reads the potentially broken iCalendar (which for instance contains `TZOFFSETFROM:+5730`) + * @return Reader that reads the fixed iCalendar (for instance `TZOFFSETFROM:+005730`) + */ + fun fixInvalidUtcOffset(reader: Reader): Reader { + fun fixStringFromReader() = + IOUtils.toString(reader).replace(TZOFFSET_REGEXP) { + Ical4Android.log.log(Level.FINE, "Applying Synology WebDAV fix to invalid utc-offset", it.value) + "${it.groupValues[1]}00${it.groupValues[3]}" + } + + var result: String? = null + + val resetSupported = try { + reader.reset() + true + } catch(e: IOException) { + false + } + + if (resetSupported) { + // reset is supported, no need to copy the whole stream to another String (unless we have to fix the TZOFFSET) + if (Scanner(reader).findWithinHorizon(TZOFFSET_REGEXP.toPattern(), 0) != null) { + reader.reset() + result = fixStringFromReader() + } + } else + result = fixStringFromReader() + + if (result != null) + return StringReader(result) + + // not modified, return original iCalendar + reader.reset() + return reader + } + + + /** + * Applies the set of rules (see class definition) to a given calendar object. + * + * @param calendar the calendar object that is going to be modified + */ + fun preProcess(calendar: Calendar) { + for (component in calendar.components) { + for (property in component.properties) + applyRules(property) + } + } + + @Suppress("UNCHECKED_CAST") + private fun applyRules(property: Property) { + propertyRules + .filter { rule -> rule.supportedType.isAssignableFrom(property::class.java) } + .forEach { + val beforeStr = property.toString() + (it as Rfc5545PropertyRule).applyTo(property) + val afterStr = property.toString() + if (beforeStr != afterStr) + Ical4Android.log.log(Level.FINER, "$beforeStr -> $afterStr") + } + } + +} \ No newline at end of file diff --git a/src/test/java/at/bitfire/ical4android/ICalPreprocessorTest.kt b/src/test/java/at/bitfire/ical4android/ICalPreprocessorTest.kt index 894452e..e1d614b 100644 --- a/src/test/java/at/bitfire/ical4android/ICalPreprocessorTest.kt +++ b/src/test/java/at/bitfire/ical4android/ICalPreprocessorTest.kt @@ -4,6 +4,7 @@ package at.bitfire.ical4android +import at.bitfire.ical4android.validation.ICalPreprocessor import net.fortuna.ical4j.data.CalendarBuilder import net.fortuna.ical4j.model.Component import net.fortuna.ical4j.model.component.VEvent diff --git a/src/test/java/at/bitfire/ical4android/validation/EventValidatorTest.kt b/src/test/java/at/bitfire/ical4android/validation/EventValidatorTest.kt new file mode 100644 index 0000000..c64d0d7 --- /dev/null +++ b/src/test/java/at/bitfire/ical4android/validation/EventValidatorTest.kt @@ -0,0 +1,314 @@ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ + +package at.bitfire.ical4android.validation + +import at.bitfire.ical4android.Event +import at.bitfire.ical4android.InvalidCalendarException +import net.fortuna.ical4j.model.Date +import net.fortuna.ical4j.model.DateTime +import net.fortuna.ical4j.model.Recur +import net.fortuna.ical4j.model.TimeZoneRegistryFactory +import net.fortuna.ical4j.model.property.DtEnd +import net.fortuna.ical4j.model.property.DtStart +import net.fortuna.ical4j.model.property.RRule +import org.junit.Assert.* +import org.junit.Test +import java.io.StringReader +import java.util.* + +class EventValidatorTest { + + val tzReg = TimeZoneRegistryFactory.getInstance().createRegistry() + + + // DTSTART and DTEND + + @Test + fun testEnsureCorrectStartAndEndTime_noDtStart() { + assertThrows(InvalidCalendarException::class.java) { + val event = Event().apply { + dtEnd = DtEnd(DateTime("20000105T000000")) // DATETIME + // no dtStart + } + EventValidator.correctStartAndEndTime(event) + } + + assertThrows(InvalidCalendarException::class.java) { + Event.eventsFromReader(StringReader( + "BEGIN:VCALENDAR\n" + + "BEGIN:VEVENT\n" + + "UID:51d8529a-5844-4609-918b-2891b855e0e8\n" + + "DTEND;VALUE=DATE:20211116\n" + // DATE + "END:VEVENT\n" + + "END:VCALENDAR")).first() + } + } + + @Test + fun testEnsureCorrectStartAndEndTime_dtEndBeforeDtStart() { + val event = Event().apply { + dtStart = DtStart(DateTime("20000105T001100")) // DATETIME + dtEnd = DtEnd(DateTime("20000105T000000")) // DATETIME + } + assertTrue(event.dtStart!!.date.time > event.dtEnd!!.date.time) + EventValidator.correctStartAndEndTime(event) + assertNull(event.dtEnd) + + val event1 = Event.eventsFromReader(StringReader( + "BEGIN:VCALENDAR\n" + + "BEGIN:VEVENT\n" + + "UID:51d8529a-5844-4609-918b-2891b855e0e8\n" + + "DTSTART;VALUE=DATE:20211117\n" + // DATE + "DTEND;VALUE=DATE:20211116\n" + // DATE + "END:VEVENT\n" + + "END:VCALENDAR")).first() + assertNull(event1.dtEnd) + } + + + // RRULE UNTIL and DTSTART of same type (DATETIME/DATE) + + @Test + fun testSameTypeForDtStartAndRruleUntil_DtStartAndRruleUntilAreBothDateTimeOrDate() { + // should do nothing when types are the same + + val event = Event().apply { + dtStart = DtStart(DateTime("20211115T001100Z")) // DATETIME (UTC) + rRules.add(RRule("FREQ=MONTHLY;UNTIL=20251214T001100Z")) // DATETIME (UTC) + } + assertEquals(DateTime("20211115T001100Z"), event.dtStart!!.date) + assertEquals(DateTime("20251214T001100Z"), event.rRules.first.recur.until) + EventValidator.sameTypeForDtStartAndRruleUntil(event.dtStart!!, event.rRules) + assertEquals(DateTime("20211115T001100Z"), event.dtStart!!.date) + assertEquals(DateTime("20251214T001100Z"), event.rRules.first.recur.until) + + val event1 = Event.eventsFromReader(StringReader( + "BEGIN:VCALENDAR\n" + + "BEGIN:VEVENT\n" + + "UID:51d8529a-5844-4609-918b-2891b855e0e8\n" + + "DTSTART;VALUE=DATE:20211115\n" + // DATE + "RRULE:FREQ=MONTHLY;UNTIL=20231214;BYMONTHDAY=15\n" + // DATE + "END:VEVENT\n" + + "END:VCALENDAR")).first() + assertEquals(Date("20231214"), event1.rRules.first.recur.until) + + val event2 = Event.eventsFromReader(StringReader( + "BEGIN:VCALENDAR\n" + + "BEGIN:VEVENT\n" + + "UID:381fb26b-2da5-4dd2-94d7-2e0874128aa7\n" + + "DTSTART;VALUE=DATE:20080215\n" + // DATE + "RRULE:FREQ=YEARLY;UNTIL=20230216;BYMONTHDAY=15\n" + // DATE + "END:VEVENT\n" + + "END:VCALENDAR")).first() + assertEquals(Date("20230216"), event2.rRules.first.recur.until) + } + + @Test + fun testSameTypeForDtStartAndRruleUntil_DtStartIsDateAndRruleUntilIsDateTime() { + // should remove (possibly existing) time in RRULE if DTSTART value is of type DATE (not DATETIME) + // we want time to be cut off hard, not taking time zones into account risking the date could flip when time is close to midnight + + val event = Event().apply { + dtStart = DtStart(Date("20211115")) // DATE + rRules.add(RRule("FREQ=MONTHLY;UNTIL=20211214T235959Z")) // DATETIME (UTC), close to flip up + } + assertEquals( + DateTime("20211214T235959Z"), + event.rRules.first.recur.until + ) + EventValidator.sameTypeForDtStartAndRruleUntil(event.dtStart!!, event.rRules) + assertEquals(Date("20211214"), event.rRules.first.recur.until) + + val event1 = Event.eventsFromReader( + StringReader( + "BEGIN:VCALENDAR\n" + + "BEGIN:VEVENT\n" + + "UID:51d8529a-5844-4609-918b-2891b855e0e8\n" + + "DTSTART;VALUE=DATE:20211115\n" + // DATE + "RRULE:FREQ=MONTHLY;UNTIL=20211214T235959;BYMONTHDAY=15\n" + // DATETIME (no timezone), close to flip up + "END:VEVENT\n" + + "END:VCALENDAR" + ) + ).first() + assertEquals(1639440000000, event1.rRules.first.recur.until.time) + assertEquals(Date("20211214"), event1.rRules.first.recur.until) + } + + @Test + fun testSameTypeForDtStartAndRruleUntil_DtStartIsDateAndRruleUntilIsDateTime_2() { + val event2 = Event().apply { + dtStart = DtStart(Date("20080215")) // DATE (local/system time zone) + rRules.add(RRule("FREQ=YEARLY;TZID=Europe/Vienna;UNTIL=20230218T000000;BYMONTHDAY=15")) // DATETIME (with timezone), close to flip down + } + // NOTE: ical4j will: + // - ignore the time zone of the RRULE (TZID=Europe/Vienna) + // - use the timezone from DTSTART to determine the time value (timezone of DTSTART is local/system for a DATE) + // - take DST into account + // Because of this when running the test in a different timezone the date may flip down before we cut off time, making the test hard to predict. + // As it does not happen often, for the sake of simplicity we just accept either + EventValidator.sameTypeForDtStartAndRruleUntil(event2.dtStart!!, event2.rRules) + assertTrue( + Date("20230218") == event2.rRules.first.recur.until || + Date("20230217") == event2.rRules.first.recur.until + ) + } + + @Test + fun testSameTypeForDtStartAndRruleUntil_DtStartIsDateTimeAndRruleUntilIsDate() { + // should add (possibly missing) time in UNTIL if DTSTART value is of type DATETIME (not just DATE) + + val event = Event().apply { + dtStart = DtStart(DateTime("20110605T001100Z")) // DATETIME (UTC) + rRules.add(RRule("FREQ=MONTHLY;UNTIL=20211214")) // DATE + } + assertEquals(Date("20211214"), event.rRules.first.recur.until) + EventValidator.sameTypeForDtStartAndRruleUntil(event.dtStart!!, event.rRules) + assertEquals(DateTime("20211214T001100Z"), event.rRules.first.recur.until) + + val event1 = Event.eventsFromReader(StringReader( + "BEGIN:VCALENDAR\n" + + "BEGIN:VEVENT\n" + + "UID:51d8529a-5844-4609-918b-2891b855e0e8\n" + + "DTSTART;TZID=America/New_York:20211111T053000\n" + // DATETIME (with timezone) + "RRULE:FREQ=MONTHLY;UNTIL=20211214;BYMONTHDAY=15\n" + // DATE + "END:VEVENT\n" + + "END:VCALENDAR")).first() + assertEquals(DateTime("20211214T053000", tzReg.getTimeZone("America/New_York")), event1.rRules.first.recur.until) + + val event2 = Event.eventsFromReader(StringReader( + "BEGIN:VCALENDAR\n" + + "BEGIN:VEVENT\n" + + "UID:381fb26b-2da5-4dd2-94d7-2e0874128aa7\n" + + "DTSTART;VALUE=DATETIME:20080214T001100\n" + // DATETIME (no timezone) + "RRULE:FREQ=YEARLY;UNTIL=20110214;BYMONTHDAY=15\n" + // DATE + "END:VEVENT\n" + + "END:VCALENDAR")).first() + assertEquals(DateTime("20110214T001100"), event2.rRules.first.recur.until) + } + + + // RRULE UNTIL time before DTSTART time + + @Test + fun testHasUntilBeforeDtStart_DtStartTime_RRuleNoUntil() { + assertFalse( + EventValidator.hasUntilBeforeDtStart( + DtStart(DateTime("20220531T010203")), RRule()) + ) + } + + + @Test + fun testHasUntilBeforeDtStart_DtStartDate_RRuleUntil_TimeBeforeDtStart_UTC() { + assertTrue( + EventValidator.hasUntilBeforeDtStart(DtStart("20220912", tzReg.getTimeZone("UTC")), RRule(Recur.Builder() + .frequency(Recur.Frequency.DAILY) + .until(DateTime("20220911T235959Z")) + .build()))) + } + + @Test + fun testHasUntilBeforeDtStart_DtStartDate_RRuleUntil_TimeBeforeDtStart_noTimezone() { + assertTrue( + EventValidator.hasUntilBeforeDtStart(DtStart("20220912"), RRule(Recur.Builder() + .frequency(Recur.Frequency.DAILY) + .until(DateTime("20220911T235959")) + .build()))) + } + + @Test + fun testHasUntilBeforeDtStart_DtStartDate_RRuleUntil_TimeBeforeDtStart_withTimezone() { + assertTrue( + EventValidator.hasUntilBeforeDtStart(DtStart("20220912", tzReg.getTimeZone("America/New_York")), RRule(Recur.Builder() + .frequency(Recur.Frequency.DAILY) + .until(DateTime("20220911T235959", tzReg.getTimeZone("America/New_York"))) + .build()))) + } + + @Test + fun testHasUntilBeforeDtStart_DtStartDate_RRuleUntil_DateBeforeDtStart() { + assertTrue( + EventValidator.hasUntilBeforeDtStart(DtStart("20220531"), RRule(Recur.Builder() + .frequency(Recur.Frequency.DAILY) + .until(DateTime("20220530T000000")) + .build()))) + } + + @Test + fun testHasUntilBeforeDtStart_DtStartDate_RRuleUntil_TimeAfterDtStart() { + assertFalse( + EventValidator.hasUntilBeforeDtStart(DtStart("20200912"), RRule(Recur.Builder() + .frequency(Recur.Frequency.DAILY) + .until(DateTime("20220912T000001Z")) + .build())) + ) + } + + + @Test + fun testHasUntilBeforeDtStart_DtStartTime_RRuleUntil_DateBeforeDtStart() { + assertTrue( + EventValidator.hasUntilBeforeDtStart(DtStart(DateTime("20220531T010203")), RRule(Recur.Builder() + .frequency(Recur.Frequency.DAILY) + .until(Date("20220530")) + .build())) + ) + } + + @Test + fun testHasUntilBeforeDtStart_DtStartTime_RRuleUntil_TimeBeforeDtStart() { + assertTrue( + EventValidator.hasUntilBeforeDtStart(DtStart(DateTime("20220531T010203")), RRule(Recur.Builder() + .frequency(Recur.Frequency.DAILY) + .until(DateTime("20220531T010202")) + .build())) + ) + } + + @Test + fun testHasUntilBeforeDtStart_DtStartTime_RRuleUntil_TimeAtDtStart() { + assertFalse( + EventValidator.hasUntilBeforeDtStart(DtStart(DateTime("20220531T010203")), RRule(Recur.Builder() + .frequency(Recur.Frequency.DAILY) + .until(DateTime("20220531T010203")) + .build())) + ) + } + + @Test + fun testHasUntilBeforeDtStart_DtStartTime_RRuleUntil_TimeAfterDtStart() { + assertFalse( + EventValidator.hasUntilBeforeDtStart(DtStart(DateTime("20220531T010203")), RRule(Recur.Builder() + .frequency(Recur.Frequency.DAILY) + .until(DateTime("20220531T010204")) + .build())) + ) + } + + + @Test + fun testRemoveRRulesWithUntilBeforeDtStart() { + val dtStart = DtStart(DateTime("20220531T125304")) + val rruleBefore = RRule(Recur.Builder() + .frequency(Recur.Frequency.DAILY) + .until(DateTime("20220531T125303")) + .build()) + val rruleAfter = RRule(Recur.Builder() + .frequency(Recur.Frequency.DAILY) + .until(DateTime("20220531T125305")) + .build()) + + val rrules = mutableListOf( + rruleBefore, + rruleAfter + ) + EventValidator.removeRRulesWithUntilBeforeDtStart(dtStart, rrules) + assertArrayEquals(arrayOf( + // rRuleBefore has been removed because RRULE UNTIL is before DTSTART + rruleAfter + ), rrules.toTypedArray()) + } + +} \ No newline at end of file -- cgit v1.2.3