Skip to content

Commit 095f960

Browse files
authored
Workaround: don't UPDATE eventStatus to null (#85)
* Add behavior test for updating eventStatus from null to null * Add issue reference * [WIP] Another test * Provide two workarounds and tests * Fix AndroidRecurringCalendar
1 parent c740cb1 commit 095f960

File tree

5 files changed

+192
-36
lines changed

5 files changed

+192
-36
lines changed

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

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,8 +55,11 @@ class AndroidCalendarProviderBehaviorTest {
5555
}
5656

5757

58+
/**
59+
* Reported as https://issuetracker.google.com/issues/446730408.
60+
*/
5861
@Test(expected = NullPointerException::class)
59-
fun testUpdateEventStatusToNull() {
62+
fun testUpdateEventStatusFromNonNullToNull() {
6063
val id = calendar.addEvent(Entity(contentValuesOf(
6164
Events.CALENDAR_ID to calendar.id,
6265
Events.DTSTART to System.currentTimeMillis(),
@@ -71,4 +74,40 @@ class AndroidCalendarProviderBehaviorTest {
7174
))
7275
}
7376

77+
@Test
78+
fun testUpdateEventStatusFromNullToNotPresent() {
79+
val id = calendar.addEvent(Entity(contentValuesOf(
80+
Events.CALENDAR_ID to calendar.id,
81+
Events.DTSTART to System.currentTimeMillis(),
82+
Events.DTEND to System.currentTimeMillis() + 3600000,
83+
Events.TITLE to "Some Event (Status tentative)",
84+
Events.STATUS to null
85+
)))
86+
87+
// No problem because STATUS is not explicitly set.
88+
calendar.updateEventRow(id, contentValuesOf(
89+
//Events.STATUS to null,
90+
Events.TITLE to "Some Event (Status null)"
91+
))
92+
}
93+
94+
/**
95+
* Reported as https://issuetracker.google.com/issues/446730408.
96+
*/
97+
@Test(expected = NullPointerException::class)
98+
fun testUpdateEventStatusFromNullToNull() {
99+
val id = calendar.addEvent(Entity(contentValuesOf(
100+
Events.CALENDAR_ID to calendar.id,
101+
Events.DTSTART to System.currentTimeMillis(),
102+
Events.DTEND to System.currentTimeMillis() + 3600000,
103+
Events.TITLE to "Some Event (Status tentative)",
104+
Events.STATUS to null
105+
)))
106+
107+
calendar.updateEventRow(id, contentValuesOf(
108+
Events.STATUS to null, // updating status to null causes NullPointerException
109+
Events.TITLE to "Some Event (Status null)"
110+
))
111+
}
112+
74113
}

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

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,52 @@ class AndroidCalendarTest {
246246
)
247247
}
248248

249+
@Test
250+
fun testGetStatusUpdateWorkaround_NoStatusUpdate() {
251+
assertEquals(
252+
AndroidCalendar.StatusUpdateWorkaround.NO_WORKAROUND,
253+
calendar.getStatusUpdateWorkaround(0, ContentValues())
254+
)
255+
}
256+
257+
@Test
258+
fun testGetStatusUpdateWorkaround_UpdateStatusToNonNull() {
259+
assertEquals(
260+
AndroidCalendar.StatusUpdateWorkaround.NO_WORKAROUND,
261+
calendar.getStatusUpdateWorkaround(0, contentValuesOf(Events.STATUS to Events.STATUS_TENTATIVE))
262+
)
263+
}
264+
265+
@Test
266+
fun testGetStatusUpdateWorkaround_UpdateStatusFromNullToNull() {
267+
val id = calendar.addEvent(Entity(contentValuesOf(
268+
Events.CALENDAR_ID to calendar.id,
269+
Events.DTSTART to now,
270+
Events.DTEND to now + 3600000,
271+
Events.TITLE to "Event without status",
272+
Events.STATUS to null
273+
)))
274+
assertEquals(
275+
AndroidCalendar.StatusUpdateWorkaround.DONT_UPDATE_STATUS,
276+
calendar.getStatusUpdateWorkaround(id, contentValuesOf(Events.STATUS to null))
277+
)
278+
}
279+
280+
@Test
281+
fun testGetStatusUpdateWorkaround_UpdateStatusFromNonNullToNull() {
282+
val id = calendar.addEvent(Entity(contentValuesOf(
283+
Events.CALENDAR_ID to calendar.id,
284+
Events.DTSTART to now,
285+
Events.DTEND to now + 3600000,
286+
Events.TITLE to "Event without status",
287+
Events.STATUS to Events.STATUS_TENTATIVE
288+
)))
289+
assertEquals(
290+
AndroidCalendar.StatusUpdateWorkaround.REBUILD_EVENT,
291+
calendar.getStatusUpdateWorkaround(id, contentValuesOf(Events.STATUS to null))
292+
)
293+
}
294+
249295
@Test
250296
fun testUpdateEventRow() {
251297
val id = calendar.addEvent(Entity(contentValuesOf(
@@ -266,7 +312,8 @@ class AndroidCalendarTest {
266312
Events.CALENDAR_ID to calendar.id,
267313
Events.DTSTART to now,
268314
Events.DTEND to now + 3600000,
269-
Events.TITLE to "Some Event"
315+
Events.TITLE to "Some Event",
316+
Events.STATUS to null
270317
)
271318
val entity = Entity(values)
272319
val reminder = contentValuesOf(

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

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import io.mockk.verify
2727
import net.fortuna.ical4j.util.TimeZones
2828
import org.junit.After
2929
import org.junit.Assert.assertEquals
30+
import org.junit.Assert.assertNotEquals
3031
import org.junit.Assert.assertNull
3132
import org.junit.Assert.assertTrue
3233
import org.junit.Before
@@ -190,7 +191,8 @@ class AndroidRecurringCalendarTest {
190191
Events.ORIGINAL_SYNC_ID to "recur3",
191192
Events.DTSTART to now + 86400000,
192193
Events.DTEND to now + 86400000 + 2*3600000,
193-
Events.TITLE to "Initial Exception"
194+
Events.TITLE to "Initial Exception",
195+
Events.STATUS to Events.STATUS_CONFIRMED
194196
))
195197
)
196198
val initialEventAndExceptions = EventAndExceptions(main = initialEvent, exceptions = initialExceptions)
@@ -199,19 +201,22 @@ class AndroidRecurringCalendarTest {
199201
// Create updated event with null STATUS (requires rebuild)
200202
val updatedEvent = Entity(initialEvent.entityValues.apply {
201203
put(Events.TITLE, "Updated Event")
202-
remove(Events.STATUS)
204+
putNull(Events.STATUS)
203205
})
204206
val updatedEventAndExceptions = EventAndExceptions(main = updatedEvent, exceptions = initialExceptions)
205207

206208
// Update event (should trigger rebuild)
207209
val updatedEventId = recurringCalendar.updateEventAndExceptions(mainEventId, updatedEventAndExceptions)
210+
assertNotEquals(mainEventId, updatedEventId)
208211

209212
// Verify update = deletion + re-creation
210213
assertNull(recurringCalendar.getById(mainEventId))
211-
val updatedEvent2 = recurringCalendar.getById(updatedEventId)
214+
val updatedEvent2 = recurringCalendar.getById(updatedEventId)!!
215+
if (!updatedEvent2.main.entityValues.containsKey(Events.STATUS)) // STATUS will not be returned if it's null
216+
updatedEvent2.main.entityValues.putNull(Events.STATUS) // add for equality check
212217
assertEventAndExceptionsEqual(
213218
updatedEventAndExceptions.withId(updatedEventId),
214-
updatedEvent2!!,
219+
updatedEvent2,
215220
onlyFieldsInExpected = true
216221
)
217222
}

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

Lines changed: 83 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -295,7 +295,7 @@ class AndroidCalendar(
295295
* Updates a specific event's main row with the given values. Doesn't influence data rows.
296296
*
297297
* This method always uses the update method of the content provider and does not
298-
* re-create rows, as it is required for some operations (see [updateEvent] and [eventUpdateNeedsRebuild]
298+
* re-create rows, as it is required for some operations (see [updateEvent] and [getStatusUpdateWorkaround]
299299
* for more information).
300300
*
301301
* @param id event ID
@@ -311,40 +311,81 @@ class AndroidCalendar(
311311
}
312312
}
313313

314+
/**
315+
* Updates an event and applies the eventStatus=null workaround, if necessary.
316+
*
317+
* While the event row can be updated, sub-values (data rows) are always deleted and created from scratch.
318+
*
319+
* @param id ID of the event to update
320+
* @param entity new values of the event
321+
*
322+
* @return ID of the updated event (not necessarily the same as the original event)
323+
*/
314324
fun updateEvent(id: Long, entity: Entity): Long {
315325
try {
316-
val rebuild = eventUpdateNeedsRebuild(id, entity.entityValues) ?: true
317-
if (rebuild) {
318-
deleteEvent(id)
319-
return addEvent(entity)
320-
}
321-
322-
// remove existing data rows which are created by us (don't touch 3rd-party calendar apps rows)
323326
val batch = CalendarBatchOperation(client)
324-
updateEvent(id, entity, batch)
327+
val newEventIdIdx = updateEvent(id, entity, batch)
325328
batch.commit()
326329

327-
return id
330+
if (newEventIdIdx == null)
331+
// event was updated
332+
return id
333+
else {
334+
// event was re-built
335+
val result = batch.getResult(newEventIdIdx)
336+
val newEventUri = result?.uri ?: throw LocalStorageException("Content provider returned null on insert")
337+
return ContentUris.parseId(newEventUri)
338+
}
328339
} catch (e: RemoteException) {
329340
throw LocalStorageException("Couldn't update event $id", e)
330341
}
331342
}
332343

333-
internal fun updateEvent(id: Long, entity: Entity, batch: CalendarBatchOperation) {
344+
/**
345+
* Enqueues an update of an event and applies the eventStatus=null workaround, if necessary.
346+
*
347+
* While the event row can be updated, sub-values (data rows) are always deleted and created from scratch.
348+
*
349+
* @param id ID of the event to update
350+
* @param batch batch operation in which the update is enqueued
351+
* @param entity new values of the event
352+
*
353+
* @return `null` if an event update was enqueued so that its ID won't change;
354+
* otherwise (if re-build is needed) the result index of the new event ID.
355+
*/
356+
internal fun updateEvent(id: Long, entity: Entity, batch: CalendarBatchOperation): Int? {
357+
val workaround = getStatusUpdateWorkaround(id, entity.entityValues)
358+
if (workaround == StatusUpdateWorkaround.REBUILD_EVENT) {
359+
deleteEvent(id, batch)
360+
361+
val idx = batch.nextBackrefIdx()
362+
addEvent(entity, batch)
363+
return idx
364+
}
365+
366+
// remove existing data rows which are created by us (don't touch 3rd-party calendar apps rows)
334367
deleteDataRows(id, batch)
335368

336369
// update main row
370+
val newValues = ContentValues(entity.entityValues).apply {
371+
// don't update event ID
372+
remove(Events._ID)
373+
374+
// don't update status if that is our required workaround
375+
if (workaround == StatusUpdateWorkaround.DONT_UPDATE_STATUS)
376+
remove(Events.STATUS)
377+
}
337378
batch += CpoBuilder.newUpdate(eventUri(id))
338-
.withValues(ContentValues(entity.entityValues).apply {
339-
remove(Events._ID) // don't update ID
340-
})
379+
.withValues(newValues)
341380

342381
// insert data rows (with reference to main row ID)
343382
for (row in entity.subValues)
344383
batch += CpoBuilder.newInsert(row.uri.asSyncAdapter(account))
345384
.withValues(ContentValues(row.values).apply {
346385
put(AndroidEvent2.DATA_ROW_EVENT_ID, id) // always keep reference to main row ID
347386
})
387+
388+
return null
348389
}
349390

350391
/**
@@ -375,7 +416,7 @@ class AndroidCalendar(
375416

376417
/**
377418
* There is a bug in the calendar provider that prevent events from being updated from a non-null STATUS value
378-
* to STATUS=null (see AndroidCalendarProviderBehaviorTest.testUpdateEventStatusToNull).
419+
* to STATUS=null (see `AndroidCalendarProviderBehaviorTest` test class).
379420
*
380421
* In that case we can't update the event, so we completely re-create it.
381422
*
@@ -384,9 +425,20 @@ class AndroidCalendar(
384425
*
385426
* @return whether the event can't be updated/needs to be re-created; or `null` if existing values couldn't be determined
386427
*/
387-
internal fun eventUpdateNeedsRebuild(id: Long, newValues: ContentValues): Boolean? {
388-
val existingValues = getEventRow(id, arrayOf(Events.STATUS)) ?: return null
389-
return existingValues.getAsInteger(Events.STATUS) != null && newValues.getAsInteger(Events.STATUS) == null
428+
internal fun getStatusUpdateWorkaround(id: Long, newValues: ContentValues): StatusUpdateWorkaround {
429+
// No workaround needed if STATUS is a) not updated at all, or b) updated to a non-null value.
430+
if (!newValues.containsKey(Events.STATUS) || newValues.getAsInteger(Events.STATUS) != null)
431+
return StatusUpdateWorkaround.NO_WORKAROUND
432+
// We're now sure that STATUS shall be updated to null.
433+
434+
// If STATUS is null before the update, just don't include the STATUS in the update.
435+
// In case that the old values can't be determined, rebuild the row to be on the safe side.
436+
val existingValues = getEventRow(id, arrayOf(Events.STATUS)) ?: return StatusUpdateWorkaround.REBUILD_EVENT
437+
if (existingValues.getAsInteger(Events.STATUS) == null)
438+
return StatusUpdateWorkaround.DONT_UPDATE_STATUS
439+
440+
// Update from non-null to null → rebuild (delete/insert) event instead of updating it.
441+
return StatusUpdateWorkaround.REBUILD_EVENT
390442
}
391443

392444
/**
@@ -422,6 +474,10 @@ class AndroidCalendar(
422474
}
423475
}
424476

477+
internal fun deleteEvent(id: Long, batch: CalendarBatchOperation) {
478+
batch += CpoBuilder.newDelete(eventUri(id))
479+
}
480+
425481

426482

427483
// event instances (these methods operate directly with event IDs and without the events
@@ -516,6 +572,15 @@ class AndroidCalendar(
516572

517573
// helpers
518574

575+
enum class StatusUpdateWorkaround {
576+
/** no workaround needed */
577+
NO_WORKAROUND,
578+
/** don't update eventStatus (no need to change value) */
579+
DONT_UPDATE_STATUS,
580+
/** rebuild event (delete+insert instead of update) */
581+
REBUILD_EVENT
582+
}
583+
519584
val account
520585
get() = provider.account
521586

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

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -93,32 +93,32 @@ class AndroidRecurringCalendar(
9393
*/
9494
fun updateEventAndExceptions(id: Long, eventAndExceptions: EventAndExceptions): Long {
9595
try {
96-
val rebuild = calendar.eventUpdateNeedsRebuild(id, eventAndExceptions.main.entityValues) ?: true
97-
if (rebuild) {
98-
deleteEventAndExceptions(id)
99-
return addEventAndExceptions(eventAndExceptions)
100-
}
101-
10296
// validate / clean up input
10397
val cleaned = cleanUp(eventAndExceptions)
10498

105-
val batch = CalendarBatchOperation(calendar.client)
106-
10799
// remove old exceptions (because they may be invalid for the updated event)
100+
val batch = CalendarBatchOperation(calendar.client)
108101
batch += CpoBuilder.newDelete(calendar.eventsUri)
109102
.withSelection("${Events.ORIGINAL_ID}=?", arrayOf(id.toString()))
110103

111-
// update main event
112-
calendar.updateEvent(id, cleaned.main, batch)
104+
// update main event (also applies eventStatus workaround, if needed)
105+
val newEventIdIdx = calendar.updateEvent(id, cleaned.main, batch)
113106

114107
// add updated exceptions
115108
for (exception in cleaned.exceptions)
116109
calendar.addEvent(exception, batch)
117110

118111
batch.commit()
119112

120-
// original row was updated, so return original ID
121-
return id
113+
if (newEventIdIdx == null) {
114+
// original row was updated, so return original ID
115+
return id
116+
} else {
117+
// event was re-built
118+
val result = batch.getResult(newEventIdIdx)
119+
val newEventUri = result?.uri ?: throw LocalStorageException("Content provider returned null on insert")
120+
return ContentUris.parseId(newEventUri)
121+
}
122122
} catch (e: RemoteException) {
123123
throw LocalStorageException("Couldn't update event/exceptions", e)
124124
}

0 commit comments

Comments
 (0)