Welcome to mirror list, hosted at ThFree Co, Russian Federation.

github.com/bitfireAT/ical4android.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorSunik Kupfer <kupfer@bitfire.at>2022-08-05 22:27:29 +0300
committerGitHub <noreply@github.com>2022-08-05 22:27:29 +0300
commitccea6cbd487b0872d7e399f290fd00ffbb08f37f (patch)
tree51db0b6a2c480ba5aed9f1d5410613b59cb2c789
parent41171b57fdc5f045b17d09df85853a6cc13ef4fd (diff)
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 <hirner@bitfire.at>
-rw-r--r--src/androidTest/java/at/bitfire/ical4android/AndroidEventTest.kt12
-rw-r--r--src/main/java/at/bitfire/ical4android/AndroidEvent.kt5
-rw-r--r--src/main/java/at/bitfire/ical4android/Event.kt12
-rw-r--r--src/main/java/at/bitfire/ical4android/ICalendar.kt1
-rw-r--r--src/main/java/at/bitfire/ical4android/validation/EventValidator.kt107
-rw-r--r--src/main/java/at/bitfire/ical4android/validation/ICalPreprocessor.kt (renamed from src/main/java/at/bitfire/ical4android/ICalPreprocessor.kt)13
-rw-r--r--src/test/java/at/bitfire/ical4android/ICalPreprocessorTest.kt1
-rw-r--r--src/test/java/at/bitfire/ical4android/validation/EventValidatorTest.kt314
8 files changed, 443 insertions, 22 deletions
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/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<RRule>) {
+ 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<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
+ */
+ 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/ICalPreprocessor.kt b/src/main/java/at/bitfire/ical4android/validation/ICalPreprocessor.kt
index ae7293c..f76ff12 100644
--- a/src/main/java/at/bitfire/ical4android/ICalPreprocessor.kt
+++ b/src/main/java/at/bitfire/ical4android/validation/ICalPreprocessor.kt
@@ -2,8 +2,9 @@
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
**************************************************************************************************/
-package at.bitfire.ical4android
+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
@@ -18,10 +19,10 @@ import java.util.*
import java.util.logging.Level
/**
- * Applies some rules to increase compatibility or parsed iCalendars:
+ * 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
+ * - [DatePropertyRule], [DateListPropertyRule] to rename Outlook-specific TZID parameters
* (like "W. Europe Standard Time" to an Android-friendly name like "Europe/Vienna")
*
*/
@@ -30,10 +31,10 @@ 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
+ 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!
+ DatePropertyRule(), // These two rules also replace VTIMEZONEs of the iCalendar ...
+ DateListPropertyRule(), // ... by the ical4j VTIMEZONE with the same TZID!
)
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