77package at.bitfire.synctools.storage.calendar
88
99import android.content.ContentUris
10+ import android.content.ContentValues
11+ import android.content.Entity
1012import android.os.RemoteException
1113import android.provider.CalendarContract.Events
14+ import androidx.annotation.VisibleForTesting
1215import at.bitfire.synctools.storage.BatchOperation.CpoBuilder
1316import 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 */
1935class 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