Skip to content

Commit 7b5680b

Browse files
committed
AndroidRecurringCalendar: make validation / cleaning up more relaxed
1 parent ffcc174 commit 7b5680b

File tree

5 files changed

+239
-52
lines changed

5 files changed

+239
-52
lines changed

lib/src/androidTest/kotlin/at/bitfire/synctools/storage/calendar/AndroidRecurringCalendarTest.kt

Lines changed: 105 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,18 +18,26 @@ import androidx.test.platform.app.InstrumentationRegistry
1818
import androidx.test.rule.GrantPermissionRule
1919
import at.bitfire.ical4android.impl.TestCalendar
2020
import at.bitfire.ical4android.util.MiscUtils.closeCompat
21+
import at.bitfire.synctools.test.assertContentValuesEqual
2122
import at.bitfire.synctools.test.assertEventAndExceptionsEqual
2223
import at.bitfire.synctools.test.withId
24+
import io.mockk.junit4.MockKRule
25+
import io.mockk.spyk
26+
import io.mockk.verify
2327
import net.fortuna.ical4j.util.TimeZones
2428
import org.junit.After
2529
import org.junit.Assert.assertEquals
2630
import org.junit.Assert.assertNull
31+
import org.junit.Assert.assertTrue
2732
import org.junit.Before
2833
import org.junit.Rule
2934
import org.junit.Test
3035

3136
class AndroidRecurringCalendarTest {
3237

38+
@get:Rule
39+
val mockkRule = MockKRule(this)
40+
3341
@get:Rule
3442
val permissonRule = GrantPermissionRule.grant(Manifest.permission.READ_CALENDAR, Manifest.permission.WRITE_CALENDAR)
3543

@@ -38,6 +46,7 @@ class AndroidRecurringCalendarTest {
3846
lateinit var client: ContentProviderClient
3947
lateinit var provider: AndroidCalendarProvider
4048
lateinit var calendar: AndroidCalendar
49+
4150
lateinit var recurringCalendar: AndroidRecurringCalendar
4251

4352
@Before
@@ -46,7 +55,7 @@ class AndroidRecurringCalendarTest {
4655
client = context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)!!
4756
provider = AndroidCalendarProvider(testAccount, client)
4857
calendar = TestCalendar.findOrCreate(testAccount, client)
49-
recurringCalendar = AndroidRecurringCalendar(calendar)
58+
recurringCalendar = spyk(AndroidRecurringCalendar(calendar))
5059
}
5160

5261
@After
@@ -85,6 +94,11 @@ class AndroidRecurringCalendarTest {
8594
val mainEventId = recurringCalendar.addEventAndExceptions(event)
8695
val addedWithId = event.withId(mainEventId)
8796

97+
// verify that cleanUp was called
98+
verify(exactly = 1) {
99+
recurringCalendar.cleanUp(event)
100+
}
101+
88102
// verify
89103
val event2 = recurringCalendar.getById(mainEventId)
90104
assertEventAndExceptionsEqual(addedWithId, event2!!, onlyFieldsInExpected = true)
@@ -142,6 +156,11 @@ class AndroidRecurringCalendarTest {
142156
val updatedEventId = recurringCalendar.updateEventAndExceptions(addedEventId, updatedEventAndExceptions)
143157
assertEquals(updatedEventId, addedEventId)
144158

159+
// verify that cleanUp was called
160+
verify(exactly = 1) {
161+
recurringCalendar.cleanUp(updatedEventAndExceptions)
162+
}
163+
145164
// Verify update
146165
val event2 = recurringCalendar.getById(addedEventId)
147166
assertEventAndExceptionsEqual(
@@ -229,4 +248,89 @@ class AndroidRecurringCalendarTest {
229248
assertNull(deletedEvent)
230249
}
231250

251+
252+
@Test
253+
fun testCleanUp_Recurring_Exceptions_NoSyncId() {
254+
val cleaned = recurringCalendar.cleanUp(EventAndExceptions(
255+
main = Entity(contentValuesOf(
256+
Events.TITLE to "Recurring Main Event",
257+
Events.RRULE to "Some RRULE"
258+
)),
259+
exceptions = listOf(
260+
Entity(contentValuesOf(
261+
Events.TITLE to "Exception"
262+
))
263+
)
264+
))
265+
266+
// verify that exceptions were dropped (because the provider wouldn't be able to associate them without SYNC_ID)
267+
assertTrue(cleaned.exceptions.isEmpty())
268+
}
269+
270+
@Test
271+
fun testCleanUp_Recurring_Exceptions_WithSyncId() {
272+
val original = EventAndExceptions(
273+
main = Entity(contentValuesOf(
274+
Events._SYNC_ID to "SomeSyncId",
275+
Events.TITLE to "Recurring Main Event",
276+
Events.RRULE to "Some RRULE"
277+
)),
278+
exceptions = listOf(
279+
Entity(contentValuesOf(
280+
Events.TITLE to "Exception",
281+
Events.ORIGINAL_SYNC_ID to "SomeSyncId"
282+
))
283+
)
284+
)
285+
val cleaned = recurringCalendar.cleanUp(original)
286+
287+
// verify that cleanUp didn't modify anything
288+
assertEventAndExceptionsEqual(original, cleaned)
289+
}
290+
291+
@Test
292+
fun testCleanUp_NotRecurring_Exceptions() {
293+
val cleaned = recurringCalendar.cleanUp(EventAndExceptions(
294+
main = Entity(contentValuesOf(
295+
Events._SYNC_ID to "SomeSyncID",
296+
Events.TITLE to "Non-Recurring Main Event"
297+
)),
298+
exceptions = listOf(
299+
Entity(contentValuesOf(
300+
Events.TITLE to "Exception"
301+
))
302+
)
303+
))
304+
305+
// verify that exceptions were dropped (because the main event is not recurring)
306+
assertTrue(cleaned.exceptions.isEmpty())
307+
}
308+
309+
@Test
310+
fun testCleanMainEvent_RemovesOriginalFields() {
311+
val result = recurringCalendar.cleanMainEvent(Entity(contentValuesOf(
312+
Events.ORIGINAL_ID to "SomeValue",
313+
Events.ORIGINAL_SYNC_ID to "SomeValue",
314+
Events.ORIGINAL_INSTANCE_TIME to "SomeValue",
315+
Events.ORIGINAL_ALL_DAY to "SomeValue"
316+
)))
317+
assertTrue(result.entityValues.isEmpty)
318+
}
319+
320+
@Test
321+
fun testCleanException_RemovesRecurrenceFields_AddsSyncId() {
322+
val result = recurringCalendar.cleanException(Entity(contentValuesOf(
323+
Events.RRULE to "SomeValue",
324+
Events.RDATE to "SomeValue",
325+
Events.EXRULE to "SomeValue",
326+
Events.EXDATE to "SomeValue"
327+
)), "SomeSyncID")
328+
329+
// all fields should have been dropped, but ORIGINAL_SYNC_ID should have been added
330+
assertContentValuesEqual(
331+
contentValuesOf(Events.ORIGINAL_SYNC_ID to "SomeSyncID"),
332+
result.entityValues
333+
)
334+
}
335+
232336
}

lib/src/main/kotlin/at/bitfire/synctools/storage/ContentValuesHelpers.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ operator fun ContentValues.plusAssign(other: ContentValues) {
1414
putAll(other)
1515
}
1616

17+
fun ContentValues.containsNotNull(field: String): Boolean =
18+
getAsString(field) != null
19+
1720
/**
1821
* Removes blank (empty or only white-space) [String] values from [ContentValues].
1922
*

lib/src/main/kotlin/at/bitfire/synctools/storage/calendar/AndroidCalendar.kt

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -97,8 +97,7 @@ class AndroidCalendar(
9797

9898
fun addEvent(entity: Entity, batch: CalendarBatchOperation) {
9999
// insert main row
100-
batch += CpoBuilder.newInsert(eventsUri)
101-
.withValues(entity.entityValues)
100+
batch += CpoBuilder.newInsert(eventsUri).withValues(entity.entityValues)
102101

103102
// insert data rows (with reference to main row ID)
104103
for (row in entity.subValues)

lib/src/main/kotlin/at/bitfire/synctools/storage/calendar/AndroidRecurringCalendar.kt

Lines changed: 129 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -7,37 +7,61 @@
77
package at.bitfire.synctools.storage.calendar
88

99
import android.content.ContentUris
10+
import android.content.ContentValues
11+
import android.content.Entity
1012
import android.os.RemoteException
1113
import android.provider.CalendarContract.Events
14+
import androidx.annotation.VisibleForTesting
1215
import at.bitfire.synctools.storage.BatchOperation.CpoBuilder
1316
import at.bitfire.synctools.storage.LocalStorageException
17+
import at.bitfire.synctools.storage.containsNotNull
1418

1519
/**
16-
* Decorator for [AndroidCalendar] that adds support for [EventAndExceptions]
17-
* data objects.
20+
* Adds support for [EventAndExceptions] data objects to [AndroidCalendar].
21+
*
22+
* There are basically two methods for inserting an exception event:
23+
*
24+
* 1. Insert it using [Events.CONTENT_EXCEPTION_URI] – then the calendar provider will take care
25+
* of validating and cleaning up various fields, however it's then not possible to set some
26+
* sync fields (all sync fields but [Events.SYNC_DATA1], [Events.SYNC_DATA3] and [Events.SYNC_DATA7] are filtered
27+
* for some reason.) It also supports splitting the main event ("exception from this date"). Usually this method
28+
* is used by calendar apps.
29+
* 2. Insert it directly as normal event (using [Events.CONTENT_URI]). In this case [Events.ORIGINAL_SYNC_ID]
30+
* must be set to the [Events._SYNC_ID] of the original event so that the calendar provider can associate the
31+
* exception with the main event. It's not enough to set [Events.ORIGINAL_ID]!
32+
*
33+
* This class only uses the second method because it needs to support all sync fields.
1834
*/
1935
class AndroidRecurringCalendar(
2036
val calendar: AndroidCalendar
2137
) {
2238

2339
/**
24-
* Adds an event and all its exceptions.
40+
* Inserts an event and all its exceptions. Input data is first cleaned up using [cleanUp].
41+
*
42+
* @param eventAndExceptions event and exceptions to insert)
2543
*
2644
* @return ID of the resulting main event
45+
*
46+
* @throws IllegalArgumentException if [eventAndExceptions] has exceptions, but doesn't have a [Events._SYNC_ID]
2747
*/
2848
fun addEventAndExceptions(eventAndExceptions: EventAndExceptions): Long {
2949
try {
3050
val batch = CalendarBatchOperation(calendar.client)
31-
calendar.addEvent(eventAndExceptions.main, batch)
3251

33-
/* Add exceptions. We don't have to set ORIGINAL_ID of each exception to the ID of
34-
the main event because the content provider associates events with their exceptions
35-
using _SYNC_ID / ORIGINAL_SYNC_ID. */
36-
for (exception in eventAndExceptions.exceptions)
52+
// validate / clean up input
53+
val cleaned = cleanUp(eventAndExceptions)
54+
55+
// add main event
56+
calendar.addEvent(cleaned.main, batch)
57+
58+
// add exceptions
59+
for (exception in cleaned.exceptions)
3760
calendar.addEvent(exception, batch)
3861

3962
batch.commit()
4063

64+
// main event was created as first row (index 0), return its insert result (= ID)
4165
val uri = batch.getResult(0)?.uri ?: throw LocalStorageException("Content provider returned null on insert")
4266
return ContentUris.parseId(uri)
4367
} catch (e: RemoteException) {
@@ -54,7 +78,8 @@ class AndroidRecurringCalendar(
5478
}
5579

5680
/**
57-
* Updates an event and all its exceptions.
81+
* Updates an event and all its exceptions. Input data is first cleaned up using
82+
* [cleanMainEvent] and [cleanException].
5883
*
5984
* @param id ID of the main event row
6085
* @param eventAndExceptions new event (including exceptions)
@@ -69,13 +94,20 @@ class AndroidRecurringCalendar(
6994
return addEventAndExceptions(eventAndExceptions)
7095
}
7196

72-
// update main event
97+
// validate / clean up input
98+
val cleaned = cleanUp(eventAndExceptions)
99+
73100
val batch = CalendarBatchOperation(calendar.client)
74-
calendar.updateEvent(id, eventAndExceptions.main, batch)
75101

76-
// remove and add exceptions again
77-
batch += CpoBuilder.newDelete(calendar.eventsUri).withSelection("${Events.ORIGINAL_ID}=?", arrayOf(id.toString()))
78-
for (exception in eventAndExceptions.exceptions)
102+
// remove old exceptions (because they may be invalid for the updated event)
103+
batch += CpoBuilder.newDelete(calendar.eventsUri)
104+
.withSelection("${Events.ORIGINAL_ID}=?", arrayOf(id.toString()))
105+
106+
// update main event
107+
calendar.updateEvent(id, cleaned.main, batch)
108+
109+
// add updated exceptions
110+
for (exception in cleaned.exceptions)
79111
calendar.addEvent(exception, batch)
80112

81113
batch.commit()
@@ -110,4 +142,87 @@ class AndroidRecurringCalendar(
110142
}
111143
}
112144

145+
146+
// validation / clean-up logic
147+
148+
@VisibleForTesting
149+
internal fun cleanUp(original: EventAndExceptions): EventAndExceptions {
150+
val main = cleanMainEvent(original.main)
151+
152+
val mainValues = main.entityValues
153+
val syncId = mainValues.getAsString(Events._SYNC_ID)
154+
val recurring = mainValues.containsNotNull(Events.RRULE) || mainValues.containsNotNull(Events.RDATE)
155+
156+
if (syncId == null || !recurring) {
157+
// no sync id or main event not recurring → we can't / need to insert exceptions, so ignore them
158+
return EventAndExceptions(main = main, exceptions = emptyList())
159+
}
160+
161+
return EventAndExceptions(
162+
main = main,
163+
exceptions = original.exceptions.map { originalException ->
164+
cleanException(originalException, syncId)
165+
}
166+
)
167+
}
168+
169+
/**
170+
* Prepares a main event for insertion into the calendar provider by making sure it
171+
* doesn't have fields that a main event shouldn't have (original_...).
172+
*
173+
* @param original original event to insert
174+
*
175+
* @return cleaned event that can actually be inserted
176+
*/
177+
@VisibleForTesting
178+
internal fun cleanMainEvent(original: Entity): Entity {
179+
// make a copy (don't modify original entity / values)
180+
val values = ContentValues(original.entityValues)
181+
182+
// remove values that a main event shouldn't have
183+
val originalFields = arrayOf(
184+
Events.ORIGINAL_ID, Events.ORIGINAL_SYNC_ID,
185+
Events.ORIGINAL_INSTANCE_TIME, Events.ORIGINAL_ALL_DAY
186+
)
187+
for (field in originalFields)
188+
values.remove(field)
189+
190+
// create new result with subvalues
191+
val result = Entity(values)
192+
for (subValue in original.subValues)
193+
result.addSubValue(subValue.uri, subValue.values)
194+
return result
195+
}
196+
197+
/**
198+
* Prepares an exception for insertion into the calendar provider:
199+
*
200+
* - Removes values that an exception shouldn't have (`RRULE`, `RDATE`, `EXRULE`, `EXDATE`).
201+
* - Makes sure that the `ORIGINAL_SYNC_ID` is set to [syncId].
202+
*
203+
* @param original original exception
204+
* @param syncId [Events._SYNC_ID] of the main event
205+
*
206+
* @return cleaned exception that can actually be inserted
207+
*/
208+
@VisibleForTesting
209+
internal fun cleanException(original: Entity, syncId: String): Entity {
210+
// make a copy (don't modify original entity / values)
211+
val values = ContentValues(original.entityValues)
212+
213+
// remove values that an exception shouldn't have
214+
val recurrenceFields = arrayOf(Events.RRULE, Events.RDATE, Events.EXRULE, Events.EXDATE)
215+
for (field in recurrenceFields)
216+
values.remove(field)
217+
218+
// make sure that ORIGINAL_SYNC_ID is set so that the exception can be associated to the main event
219+
values.put(Events.ORIGINAL_SYNC_ID, syncId)
220+
221+
// create new result with subvalues
222+
val result = Entity(values)
223+
for (subValue in original.subValues)
224+
result.addSubValue(subValue.uri, subValue.values)
225+
return result
226+
}
227+
113228
}

0 commit comments

Comments
 (0)