Skip to content

Commit 63dadf2

Browse files
authored
Only generate recurrence fields/properties for recurring main events (#89)
* Remove `EventValidator.removeRecurrenceOfExceptions` * Only generate recurrence fields/properties for recurring main events * Add RecurringFieldsProcessorTest; fix wrong AND instead of OR * Make all EXDATES valid
1 parent 7b22e37 commit 63dadf2

File tree

6 files changed

+112
-147
lines changed

6 files changed

+112
-147
lines changed

lib/src/main/kotlin/at/bitfire/ical4android/validation/EventValidator.kt

Lines changed: 0 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,6 @@ object EventValidator {
4848
val dtStart = correctStartAndEndTime(event)
4949
sameTypeForDtStartAndRruleUntil(dtStart, event.rRules)
5050
removeRRulesWithUntilBeforeDtStart(dtStart, event.rRules)
51-
removeRecurrenceOfExceptions(event.exceptions)
5251
}
5352

5453

@@ -168,25 +167,6 @@ object EventValidator {
168167
}
169168
}
170169

171-
172-
/**
173-
* Removes all recurrence information of exceptions of (potentially recurring) events. This is:
174-
* `RRULE`, `RDATE` and `EXDATE`.
175-
* Note: This repair step needs to be applied after all exceptions have been found.
176-
*
177-
* @param exceptions exceptions of an event
178-
*/
179-
@VisibleForTesting
180-
internal fun removeRecurrenceOfExceptions(exceptions: List<Event>) {
181-
for (exception in exceptions) {
182-
// Drop all RRULEs, RDATEs, EXDATEs for the exception
183-
exception.rRules.clear()
184-
exception.rDates.clear()
185-
exception.exDates.clear()
186-
}
187-
}
188-
189-
190170
/**
191171
* Will remove the RRULES of an event where UNTIL lies before DTSTART
192172
*/

lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/builder/RecurrenceFieldsBuilder.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ class RecurrenceFieldsBuilder: AndroidEntityBuilder {
2424

2525
val recurring = from.rRules.isNotEmpty() || from.rDates.isNotEmpty()
2626
if (recurring && from === main) {
27-
// only for recurring main events
27+
// generate recurrence fields only for recurring main events
2828
val dtStart = from.dtStart
2929

3030
// RRULE

lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/processor/RecurrenceFieldsProcessor.kt

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -28,28 +28,34 @@ class RecurrenceFieldsProcessor: AndroidEventFieldProcessor {
2828

2929
override fun process(from: Entity, main: Entity, to: Event) {
3030
val values = from.entityValues
31+
val rRuleField = values.getAsString(Events.RRULE)
32+
val rDateField = values.getAsString(Events.RDATE)
33+
34+
// generate recurrence properties only for recurring main events
35+
val recurring = rRuleField != null || rDateField != null
36+
if (from !== main || !recurring)
37+
return
38+
3139
val allDay = (values.getAsInteger(Events.ALL_DAY) ?: 0) != 0
3240
val tsStart = values.getAsLong(Events.DTSTART) ?: throw InvalidLocalResourceException("Found event without DTSTART")
3341

3442
// RRULE
35-
values.getAsString(Events.RRULE)?.let { rulesStr ->
43+
if (rRuleField != null)
3644
try {
37-
for (rule in rulesStr.split(AndroidTimeUtils.RECURRENCE_RULE_SEPARATOR))
45+
for (rule in rRuleField.split(AndroidTimeUtils.RECURRENCE_RULE_SEPARATOR))
3846
to.rRules += RRule(rule)
3947
} catch (e: Exception) {
4048
logger.log(Level.WARNING, "Couldn't parse RRULE field, ignoring", e)
4149
}
42-
}
4350

4451
// RDATE
45-
values.getAsString(Events.RDATE)?.let { datesStr ->
52+
if (rDateField != null)
4653
try {
47-
val rDate = AndroidTimeUtils.androidStringToRecurrenceSet(datesStr, tzRegistry, allDay, tsStart) { RDate(it) }
54+
val rDate = AndroidTimeUtils.androidStringToRecurrenceSet(rDateField, tzRegistry, allDay, tsStart) { RDate(it) }
4855
to.rDates += rDate
4956
} catch (e: Exception) {
5057
logger.log(Level.WARNING, "Couldn't parse RDATE field, ignoring", e)
5158
}
52-
}
5359

5460
// EXRULE
5561
values.getAsString(Events.EXRULE)?.let { rulesStr ->

lib/src/test/kotlin/at/bitfire/ical4android/validation/EventValidatorTest.kt

Lines changed: 0 additions & 120 deletions
Original file line numberDiff line numberDiff line change
@@ -9,23 +9,13 @@ package at.bitfire.ical4android.validation
99
import at.bitfire.ical4android.Event
1010
import at.bitfire.ical4android.EventReader
1111
import net.fortuna.ical4j.model.Date
12-
import net.fortuna.ical4j.model.DateList
1312
import net.fortuna.ical4j.model.DateTime
14-
import net.fortuna.ical4j.model.Property
15-
import net.fortuna.ical4j.model.PropertyList
1613
import net.fortuna.ical4j.model.Recur
17-
import net.fortuna.ical4j.model.TimeZone
1814
import net.fortuna.ical4j.model.TimeZoneRegistry
1915
import net.fortuna.ical4j.model.TimeZoneRegistryFactory
20-
import net.fortuna.ical4j.model.component.VTimeZone
21-
import net.fortuna.ical4j.model.parameter.Value
2216
import net.fortuna.ical4j.model.property.DtEnd
2317
import net.fortuna.ical4j.model.property.DtStart
24-
import net.fortuna.ical4j.model.property.ExDate
25-
import net.fortuna.ical4j.model.property.RDate
2618
import net.fortuna.ical4j.model.property.RRule
27-
import net.fortuna.ical4j.model.property.RecurrenceId
28-
import net.fortuna.ical4j.model.property.TzId
2919
import org.junit.Assert.assertArrayEquals
3020
import org.junit.Assert.assertEquals
3121
import org.junit.Assert.assertFalse
@@ -374,116 +364,6 @@ class EventValidatorTest {
374364
)
375365
}
376366

377-
@Test
378-
fun testRemoveRecurrencesOfRecurringWithExceptions() {
379-
// Test manually created event
380-
val tzRegistry = TimeZoneRegistryFactory.getInstance().createRegistry()
381-
val tz = tzRegistry.getTimeZone("Europe/Paris")
382-
val manualEvent = Event().apply {
383-
dtStart = DtStart("20240219T130000", tz)
384-
dtEnd = DtEnd("20240219T140000", tz)
385-
summary = "recurring event"
386-
rRules.add(RRule(Recur.Builder() // Should keep this RRULE
387-
.frequency(Recur.Frequency.DAILY)
388-
.interval(1)
389-
.count(5)
390-
.build()))
391-
sequence = 0
392-
uid = "76c08fb1-99a3-41cf-b482-2d3b06648814"
393-
exceptions.add(Event().apply {
394-
dtStart = DtStart("20240221T110000", tz)
395-
dtEnd = DtEnd("20240221T120000", tz)
396-
recurrenceId = RecurrenceId("20240221T130000", tz)
397-
sequence = 0
398-
summary = "exception of recurring event"
399-
rRules.addAll(listOf(
400-
RRule(Recur.Builder() // but remove this one
401-
.frequency(Recur.Frequency.DAILY)
402-
.count(6)
403-
.interval(2)
404-
.build()),
405-
RRule(Recur.Builder() // and this one
406-
.frequency(Recur.Frequency.DAILY)
407-
.count(6)
408-
.interval(2)
409-
.build())
410-
))
411-
rDates.addAll(listOf(
412-
RDate(DateList(Value("19970714T123000Z"))),
413-
RDate(
414-
DateList(
415-
Value("19960403T020000Z"),
416-
TimeZone(
417-
VTimeZone(
418-
PropertyList<Property>(1).apply {
419-
add(TzId("US-EASTERN"))
420-
}
421-
)
422-
)
423-
)
424-
)
425-
))
426-
exDates.addAll(listOf(
427-
ExDate(DateList(Value("19970714T123000Z"))),
428-
ExDate(
429-
DateList(
430-
Value("19960403T020000Z"),
431-
TimeZone(
432-
VTimeZone(
433-
PropertyList<Property>(1).apply {
434-
add(TzId("US-EASTERN"))
435-
}
436-
)
437-
)
438-
)
439-
)
440-
))
441-
uid = "76c08fb1-99a3-41cf-b482-2d3b06648814"
442-
})
443-
}
444-
assertTrue(manualEvent.rRules.size == 1)
445-
assertTrue(manualEvent.exceptions.first().rRules.size == 2)
446-
assertTrue(manualEvent.exceptions.first().rDates.size == 2)
447-
assertTrue(manualEvent.exceptions.first().exDates.size == 2)
448-
EventValidator.removeRecurrenceOfExceptions(manualEvent.exceptions) // Repair the manually created event
449-
assertTrue(manualEvent.rRules.size == 1)
450-
assertTrue(manualEvent.exceptions.first().rRules.isEmpty())
451-
assertTrue(manualEvent.exceptions.first().rDates.isEmpty())
452-
assertTrue(manualEvent.exceptions.first().exDates.isEmpty())
453-
454-
// Test event from reader, the reader will repair the event itself
455-
val eventFromReader = eventReader.readEvents(StringReader(
456-
"BEGIN:VCALENDAR\n" +
457-
"BEGIN:VEVENT\n" +
458-
"DTSTAMP:20240215T102755Z\n" +
459-
"SUMMARY:recurring event\n" +
460-
"DTSTART;TZID=Europe/Paris:20240219T130000\n" +
461-
"DTEND;TZID=Europe/Paris:20240219T140000\n" +
462-
"RRULE:FREQ=DAILY;INTERVAL=1;COUNT=5\n" + // Should keep this RRULE
463-
"UID:76c08fb1-99a3-41cf-b482-2d3b06648814\n" +
464-
"END:VEVENT\n" +
465-
466-
// Exception for the recurring event above
467-
"BEGIN:VEVENT\n" +
468-
"DTSTAMP:20240215T102908Z\n" +
469-
"RECURRENCE-ID;TZID=Europe/Paris:20240221T130000\n" +
470-
"SUMMARY:exception of recurring event\n" +
471-
"RRULE:FREQ=DAILY;COUNT=6;INTERVAL=2\n" + // but remove this one
472-
"RRULE:FREQ=DAILY;COUNT=6;INTERVAL=2\n" + // and this one
473-
"EXDATE;TZID=Europe/Paris:20240704T193000\n" + // also this
474-
"RDATE;TZID=US-EASTERN:19970714T083000\n" + // and this
475-
"DTSTART;TZID=Europe/Paris:20240221T110000\n" +
476-
"DTEND;TZID=Europe/Paris:20240221T120000\n" +
477-
"UID:76c08fb1-99a3-41cf-b482-2d3b06648814\n" +
478-
"END:VEVENT\n" +
479-
"END:VCALENDAR"
480-
)).first()
481-
assertTrue(eventFromReader.rRules.size == 1)
482-
assertTrue(eventFromReader.exceptions.first().rRules.isEmpty())
483-
assertTrue(eventFromReader.exceptions.first().rDates.isEmpty())
484-
assertTrue(eventFromReader.exceptions.first().exDates.isEmpty())
485-
}
486-
487367
@Test
488368
fun testRemoveRRulesWithUntilBeforeDtStart() {
489369
val dtStart = DtStart(DateTime("20220531T125304"))

lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/builder/RecurrenceFieldsBuilderTest.kt

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ class RecurrenceFieldsBuilderTest {
3232

3333
@Test
3434
fun `Exception event`() {
35+
// Exceptions (of recurring events) must never have recurrence properties themselves.
3536
val result = Entity(ContentValues())
3637
builder.build(
3738
from = Event(dtStart = DtStart()).apply {
@@ -50,6 +51,25 @@ class RecurrenceFieldsBuilderTest {
5051
), result.entityValues)
5152
}
5253

54+
@Test
55+
fun `EXDATE for non-recurring event`() {
56+
val main = Event(dtStart = DtStart()).apply {
57+
exDates += ExDate()
58+
}
59+
val result = Entity(ContentValues())
60+
builder.build(
61+
from = main,
62+
main = main,
63+
to = result
64+
)
65+
assertContentValuesEqual(contentValuesOf(
66+
Events.RRULE to null,
67+
Events.RDATE to null,
68+
Events.EXRULE to null,
69+
Events.EXDATE to null
70+
), result.entityValues)
71+
}
72+
5373
@Test
5474
fun `Single RRULE`() {
5575
val result = Entity(ContentValues())
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
/*
2+
* This file is part of bitfireAT/synctools which is released under GPLv3.
3+
* Copyright © All Contributors. See the LICENSE and AUTHOR files in the root directory for details.
4+
* SPDX-License-Identifier: GPL-3.0-or-later
5+
*/
6+
7+
package at.bitfire.synctools.mapping.calendar.processor
8+
9+
import android.content.ContentValues
10+
import android.content.Entity
11+
import android.provider.CalendarContract.Events
12+
import androidx.core.content.contentValuesOf
13+
import at.bitfire.ical4android.Event
14+
import junit.framework.TestCase.assertEquals
15+
import net.fortuna.ical4j.model.ParameterList
16+
import net.fortuna.ical4j.model.property.RDate
17+
import net.fortuna.ical4j.model.property.RRule
18+
import org.junit.Assert.assertTrue
19+
import org.junit.Test
20+
import org.junit.runner.RunWith
21+
import org.robolectric.RobolectricTestRunner
22+
23+
@RunWith(RobolectricTestRunner::class)
24+
class RecurringFieldsProcessorTest {
25+
26+
private val processor = RecurrenceFieldsProcessor()
27+
28+
@Test
29+
fun `Recurring exception`() {
30+
val result = Event()
31+
val entity = Entity(contentValuesOf(
32+
Events.DTSTART to System.currentTimeMillis(),
33+
Events.RRULE to "FREQ=DAILY;COUNT=10",
34+
Events.RDATE to "20251010T010203Z",
35+
Events.EXRULE to "FREQ=WEEKLY;COUNT=1",
36+
Events.EXDATE to "20260201T010203Z"
37+
))
38+
processor.process(entity, Entity(ContentValues()), result)
39+
// exceptions must never have recurrence properties
40+
assertTrue(result.rRules.isEmpty())
41+
assertTrue(result.rDates.isEmpty())
42+
assertTrue(result.exRules.isEmpty())
43+
assertTrue(result.exDates.isEmpty())
44+
}
45+
46+
@Test
47+
fun `Non-recurring main event`() {
48+
val result = Event()
49+
val entity = Entity(contentValuesOf(
50+
Events.DTSTART to System.currentTimeMillis(),
51+
Events.EXRULE to "FREQ=WEEKLY;COUNT=1",
52+
Events.EXDATE to "20260201T010203Z"
53+
))
54+
processor.process(entity, entity, result)
55+
// non-recurring events must never have recurrence properties
56+
assertTrue(result.rRules.isEmpty())
57+
assertTrue(result.rDates.isEmpty())
58+
assertTrue(result.exRules.isEmpty())
59+
assertTrue(result.exDates.isEmpty())
60+
}
61+
62+
@Test
63+
fun `Recurring main event`() {
64+
val result = Event()
65+
val entity = Entity(contentValuesOf(
66+
Events.DTSTART to System.currentTimeMillis(),
67+
Events.RRULE to "FREQ=DAILY;COUNT=10",
68+
Events.RDATE to "20251010T010203Z",
69+
Events.EXRULE to "FREQ=WEEKLY;COUNT=1",
70+
Events.EXDATE to "20260201T010203Z"
71+
))
72+
processor.process(entity, entity, result)
73+
assertEquals(listOf(RRule("FREQ=DAILY;COUNT=10")), result.rRules)
74+
assertEquals(listOf(RDate(ParameterList(), "20251010T010203Z")), result.rDates)
75+
assertEquals("FREQ=WEEKLY;COUNT=1", result.exRules.joinToString { it.value })
76+
assertEquals("20260201T010203Z", result.exDates.joinToString { it.value })
77+
}
78+
79+
}

0 commit comments

Comments
 (0)