Refactor sequence handling in calendar sync (#1789)

* Refactor sequence handling in calendar sync

- Move sequence update logic to SequenceUpdater
- Update LocalCalendar to use new SequenceUpdater
- Remove redundant methods from LocalCalendar
- Update tests and dependencies

* Minor KDoc
This commit is contained in:
Ricki Hirner
2025-11-01 21:55:28 +01:00
committed by GitHub
parent c64cb1e7ec
commit d365a504e8
6 changed files with 19 additions and 175 deletions

View File

@@ -16,7 +16,6 @@ import androidx.test.platform.app.InstrumentationRegistry
import at.bitfire.ical4android.util.MiscUtils.closeCompat
import at.bitfire.synctools.storage.calendar.AndroidCalendar
import at.bitfire.synctools.storage.calendar.AndroidCalendarProvider
import at.bitfire.synctools.storage.calendar.EventAndExceptions
import at.bitfire.synctools.storage.calendar.EventsContract
import at.bitfire.synctools.test.InitCalendarProviderRule
import dagger.hilt.android.testing.HiltAndroidRule
@@ -66,86 +65,6 @@ class LocalCalendarTest {
}
@Test
fun testDeleteDirtyEventsWithoutInstances_NoInstances() {
// create recurring event with only deleted/cancelled instances
val now = System.currentTimeMillis()
val id = calendar.add(EventAndExceptions(
main = Entity(contentValuesOf(
Events._SYNC_ID to "event-without-instances",
Events.CALENDAR_ID to calendar.androidCalendar.id,
Events.ALL_DAY to 0,
Events.DTSTART to now,
Events.RRULE to "FREQ=DAILY;COUNT=3",
Events.DIRTY to 1
)),
exceptions = listOf(
Entity(contentValuesOf( // first instance: cancelled
Events.CALENDAR_ID to calendar.androidCalendar.id,
Events.ORIGINAL_INSTANCE_TIME to now,
Events.ORIGINAL_ALL_DAY to 0,
Events.DTSTART to now + 86400000,
Events.STATUS to Events.STATUS_CANCELED
)),
Entity(contentValuesOf( // second instance: cancelled
Events.CALENDAR_ID to calendar.androidCalendar.id,
Events.ORIGINAL_INSTANCE_TIME to now + 86400000,
Events.ORIGINAL_ALL_DAY to 0,
Events.DTSTART to now + 86400000,
Events.STATUS to Events.STATUS_CANCELED
)),
Entity(contentValuesOf( // third and last instance: cancelled
Events.CALENDAR_ID to calendar.androidCalendar.id,
Events.ORIGINAL_INSTANCE_TIME to now + 2*86400000,
Events.ORIGINAL_ALL_DAY to 0,
Events.DTSTART to now + 2*86400000,
Events.STATUS to Events.STATUS_CANCELED
))
)
))
// this method should mark the event as deleted
calendar.deleteDirtyEventsWithoutInstances()
// verify that event is now marked as deleted
val result = calendar.androidCalendar.getEventRow(id)!!
assertEquals(1, result.getAsInteger(Events.DELETED))
}
@Test
fun testDeleteDirtyEventsWithoutInstances_OneInstanceRemaining() {
// create recurring event with only deleted/cancelled instances
val now = System.currentTimeMillis()
val id = calendar.add(EventAndExceptions(
main = Entity(contentValuesOf(
Events._SYNC_ID to "event-with-instances",
Events.CALENDAR_ID to calendar.androidCalendar.id,
Events.ALL_DAY to 0,
Events.DTSTART to now,
Events.RRULE to "FREQ=DAILY;COUNT=2",
Events.DIRTY to 1
)),
exceptions = listOf(
Entity(contentValuesOf( // first instance: cancelled
Events.CALENDAR_ID to calendar.androidCalendar.id,
Events.ORIGINAL_INSTANCE_TIME to now,
Events.ORIGINAL_ALL_DAY to 0,
Events.DTSTART to now + 86400000,
Events.STATUS to Events.STATUS_CANCELED
))
// however second instance is NOT cancelled
)
))
// this method should mark the event as deleted
calendar.deleteDirtyEventsWithoutInstances()
// verify that event is still marked as dirty, but not as deleted
val result = calendar.androidCalendar.getEventRow(id)!!
assertEquals(1, result.getAsInteger(Events.DIRTY))
assertEquals(0, result.getAsInteger(Events.DELETED))
}
/**
* Verifies that [LocalCalendar.removeNotDirtyMarked] works as expected.
* @param contentValues values to set on the event. Required:

View File

@@ -4,12 +4,10 @@
package at.bitfire.davdroid.resource
import android.content.ContentUris
import android.provider.CalendarContract.Calendars
import android.provider.CalendarContract.Events
import androidx.annotation.VisibleForTesting
import androidx.core.content.contentValuesOf
import at.bitfire.ical4android.util.MiscUtils.asSyncAdapter
import at.bitfire.synctools.storage.BatchOperation
import at.bitfire.synctools.storage.calendar.AndroidCalendar
import at.bitfire.synctools.storage.calendar.AndroidRecurringCalendar
@@ -136,90 +134,4 @@ class LocalCalendar @AssistedInject constructor(
)
}
fun processDirtyExceptions() {
// process deleted exceptions
logger.info("Processing deleted exceptions")
androidCalendar.iterateEventRows(
arrayOf(Events._ID, Events.ORIGINAL_ID, EventsContract.COLUMN_SEQUENCE),
"${Events.CALENDAR_ID}=? AND ${Events.DELETED} AND ${Events.ORIGINAL_ID} IS NOT NULL",
arrayOf(androidCalendar.id.toString())
) { values ->
logger.fine("Found deleted exception, removing and re-scheduling original event (if available)")
val id = values.getAsLong(Events._ID) // can't be null (by definition)
val originalID = values.getAsLong(Events.ORIGINAL_ID) // can't be null (by query)
val batch = CalendarBatchOperation(androidCalendar.client)
// enqueue: increase sequence of main event
val originalEventValues = androidCalendar.getEventRow(originalID, arrayOf(EventsContract.COLUMN_SEQUENCE))
val originalSequence = originalEventValues?.getAsInteger(EventsContract.COLUMN_SEQUENCE) ?: 0
batch += BatchOperation.CpoBuilder
.newUpdate(ContentUris.withAppendedId(Events.CONTENT_URI, originalID).asSyncAdapter(androidCalendar.account))
.withValue(EventsContract.COLUMN_SEQUENCE, originalSequence + 1)
.withValue(Events.DIRTY, 1)
// completely remove deleted exception
batch += BatchOperation.CpoBuilder.newDelete(ContentUris.withAppendedId(Events.CONTENT_URI, id).asSyncAdapter(androidCalendar.account))
batch.commit()
}
// process dirty exceptions
logger.info("Processing dirty exceptions")
androidCalendar.iterateEventRows(
arrayOf(Events._ID, Events.ORIGINAL_ID, EventsContract.COLUMN_SEQUENCE),
"${Events.CALENDAR_ID}=? AND ${Events.DIRTY} AND ${Events.ORIGINAL_ID} IS NOT NULL",
arrayOf(androidCalendar.id.toString())
) { values ->
logger.fine("Found dirty exception, increasing SEQUENCE to re-schedule")
val id = values.getAsLong(Events._ID) // can't be null (by definition)
val originalID = values.getAsLong(Events.ORIGINAL_ID) // can't be null (by query)
val sequence = values.getAsInteger(EventsContract.COLUMN_SEQUENCE) ?: 0
val batch = CalendarBatchOperation(androidCalendar.client)
// enqueue: set original event to DIRTY
batch += BatchOperation.CpoBuilder
.newUpdate(androidCalendar.eventUri(originalID))
.withValue(Events.DIRTY, 1)
// enqueue: increase exception SEQUENCE and set DIRTY to 0
batch += BatchOperation.CpoBuilder
.newUpdate(androidCalendar.eventUri(id))
.withValue(EventsContract.COLUMN_SEQUENCE, sequence + 1)
.withValue(Events.DIRTY, 0)
batch.commit()
}
}
/**
* Marks dirty events (which are not already marked as deleted) which got no valid instances as "deleted"
*
* @return number of affected events
*/
fun deleteDirtyEventsWithoutInstances() {
// Iterate dirty main events without exceptions
androidCalendar.iterateEventRows(
arrayOf(Events._ID),
"${Events.DIRTY} AND NOT ${Events.DELETED} AND ${Events.ORIGINAL_ID} IS NULL",
null
) { values ->
val eventId = values.getAsLong(Events._ID)
// get number of instances
val numEventInstances = androidCalendar.numInstances(eventId)
// delete event if there are no instances
if (numEventInstances == 0) {
logger.fine("Marking event #$eventId without instances as deleted")
androidCalendar.updateEventRow(eventId, contentValuesOf(Events.DELETED to 1))
}
}
}
}

View File

@@ -4,6 +4,11 @@
package at.bitfire.davdroid.resource
/**
* This is an interface between the Syncer/SyncManager and a collection in the local storage.
*
* It defines operations that are used during sync for all sync data types.
*/
interface LocalCollection<out T: LocalResource> {
/** a tag that uniquely identifies the collection (DAVx5-wide) */

View File

@@ -11,7 +11,9 @@ import at.bitfire.davdroid.resource.LocalResource.Companion.FLAG_REMOTELY_PRESEN
import java.util.Optional
/**
* Defines operations that are used by SyncManager for all sync data types.
* This is an interface between the SyncManager and a resource in the local storage.
*
* It defines operations that are used by SyncManager for all sync data types.
*/
interface LocalResource {

View File

@@ -37,6 +37,7 @@ import at.bitfire.synctools.icalendar.ICalendarParser
import at.bitfire.synctools.mapping.calendar.AndroidEventBuilder
import at.bitfire.synctools.mapping.calendar.AndroidEventProcessor
import at.bitfire.synctools.mapping.calendar.DefaultProdIdGenerator
import at.bitfire.synctools.mapping.calendar.SequenceUpdater
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
@@ -98,10 +99,12 @@ class CalendarSyncManager @AssistedInject constructor(
davCollection = DavCalendar(httpClient.okHttpClient, collection.url)
// if there are dirty exceptions for events, mark their master events as dirty, too
localCollection.processDirtyExceptions()
val recurringCalendar = localCollection.recurringCalendar
recurringCalendar.processDeletedExceptions()
recurringCalendar.processDirtyExceptions()
// now find dirty events that have no instances and set them to deleted
localCollection.deleteDirtyEventsWithoutInstances()
localCollection.androidCalendar.deleteDirtyEventsWithoutInstances()
return true
}
@@ -186,7 +189,10 @@ class CalendarSyncManager @AssistedInject constructor(
val localEvent = resource.androidEvent
logger.log(Level.FINE, "Preparing upload of event #${resource.id}", localEvent)
// map Android event to iCalendar (also generates UID and increases SEQUENCE, if necessary)
// increase SEQUENCE of main event and remember value
val updatedSequence = SequenceUpdater().increaseSequence(localEvent.main)
// map Android event to iCalendar (also generates UID, if necessary)
val processor = AndroidEventProcessor(
accountName = resource.recurringCalendar.calendar.account.name,
prodIdGenerator = DefaultProdIdGenerator(Constants.iCalProdId)
@@ -206,7 +212,7 @@ class CalendarSyncManager @AssistedInject constructor(
suggestedFileName = DavUtils.fileNameFromUid(mappedEvents.uid, "ics"),
requestBody = requestBody,
onSuccessContext = GeneratedResource.OnSuccessContext(
sequence = mappedEvents.updatedSequence
sequence = updatedSequence
)
)
}