Synchronize without Event data class (#1783)

* [WIP] Proof of concept for syncing without `Event` data class

* Replace AndroidEvent2 with EventsContract

* Update synctools, refactor upload logic in `CalendarSyncManager`

* KDoc

* Update UID immediately in `ContactsSyncManager`, `CalendarSyncManager`, and `TasksSyncManager`

- Remove `OnSuccessContext.uid` from `GeneratedResource`

* Minor changes

* Handle multiple events in a single iCalendar

- Rename `processVEvent` to `processICalendar`
- Add default alarm for non-full-day events again
- Prevent NPE on null flags (used for debug info)

* Fix tests
This commit is contained in:
Ricki Hirner
2025-10-30 14:28:05 +01:00
committed by GitHub
parent a8c8a8d2e0
commit 98aefc4fee
17 changed files with 306 additions and 481 deletions

View File

@@ -6,7 +6,6 @@ package at.bitfire.davdroid.resource
import android.accounts.Account
import android.content.ContentProviderClient
import android.content.ContentUris
import android.content.ContentValues
import android.content.Entity
import android.provider.CalendarContract
@@ -14,22 +13,16 @@ import android.provider.CalendarContract.ACCOUNT_TYPE_LOCAL
import android.provider.CalendarContract.Events
import androidx.core.content.contentValuesOf
import androidx.test.platform.app.InstrumentationRegistry
import at.bitfire.ical4android.Event
import at.bitfire.ical4android.util.MiscUtils.asSyncAdapter
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.AndroidEvent2
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
import dagger.hilt.android.testing.HiltAndroidTest
import net.fortuna.ical4j.model.property.DtStart
import net.fortuna.ical4j.model.property.RRule
import net.fortuna.ical4j.model.property.RecurrenceId
import net.fortuna.ical4j.model.property.Status
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Before
import org.junit.Rule
@@ -74,90 +67,83 @@ class LocalCalendarTest {
@Test
fun testDeleteDirtyEventsWithoutInstances_NoInstances_CancelledExceptions() {
fun testDeleteDirtyEventsWithoutInstances_NoInstances() {
// create recurring event with only deleted/cancelled instances
val event = Event().apply {
dtStart = DtStart("20220120T010203Z")
summary = "Event with 3 instances"
rRules.add(RRule("FREQ=DAILY;COUNT=3"))
exceptions.add(Event().apply {
recurrenceId = RecurrenceId("20220120T010203Z")
dtStart = DtStart("20220120T010203Z")
summary = "Cancelled exception on 1st day"
status = Status.VEVENT_CANCELLED
})
exceptions.add(Event().apply {
recurrenceId = RecurrenceId("20220121T010203Z")
dtStart = DtStart("20220121T010203Z")
summary = "Cancelled exception on 2nd day"
status = Status.VEVENT_CANCELLED
})
exceptions.add(Event().apply {
recurrenceId = RecurrenceId("20220122T010203Z")
dtStart = DtStart("20220122T010203Z")
summary = "Cancelled exception on 3rd day"
status = Status.VEVENT_CANCELLED
})
}
calendar.add(
event = event,
fileName = "filename.ics",
eTag = null,
scheduleTag = null,
flags = LocalResource.FLAG_REMOTELY_PRESENT
)
val localEvent = calendar.findByName("filename.ics")!!
val eventId = localEvent.id
// set event as dirty
client.update(ContentUris.withAppendedId(Events.CONTENT_URI.asSyncAdapter(account), eventId), ContentValues(1).apply {
put(Events.DIRTY, 1)
}, null, null)
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
client.query(
ContentUris.withAppendedId(Events.CONTENT_URI.asSyncAdapter(account), eventId),
arrayOf(Events.DELETED), null, null, null
)!!.use { cursor ->
cursor.moveToNext()
assertEquals(1, cursor.getInt(0))
}
val result = calendar.androidCalendar.getEventRow(id)!!
assertEquals(1, result.getAsInteger(Events.DELETED))
}
@Test
// Needs InitCalendarProviderRule
fun testDeleteDirtyEventsWithoutInstances_Recurring_Instances() {
val event = Event().apply {
dtStart = DtStart("20220120T010203Z")
summary = "Event with 3 instances"
rRules.add(RRule("FREQ=DAILY;COUNT=3"))
}
calendar.add(
event = event,
fileName = "filename.ics",
eTag = null,
scheduleTag = null,
flags = LocalResource.FLAG_REMOTELY_PRESENT
)
val localEvent = calendar.findByName("filename.ics")!!
val eventUrl = androidCalendar.eventUri(localEvent.id)
// set event as dirty
client.update(eventUrl, contentValuesOf(
Events.DIRTY to 1
), null, null)
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 not marked as deleted
client.query(eventUrl, arrayOf(Events.DELETED), null, null, null)!!.use { cursor ->
cursor.moveToNext()
assertEquals(0, cursor.getInt(0))
}
// 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))
}
/**
@@ -167,15 +153,16 @@ class LocalCalendarTest {
* - [Events.DIRTY]
*/
private fun testRemoveNotDirtyMarked(contentValues: ContentValues) {
val id = androidCalendar.addEvent(Entity(
val entity = Entity(
contentValuesOf(
Events.CALENDAR_ID to androidCalendar.id,
Events.DTSTART to System.currentTimeMillis(),
Events.DTEND to System.currentTimeMillis(),
Events.TITLE to "Some Event",
AndroidEvent2.COLUMN_FLAGS to 123
EventsContract.COLUMN_FLAGS to 123
).apply { putAll(contentValues) }
))
)
val id = androidCalendar.addEvent(entity)
calendar.removeNotDirtyMarked(123)
@@ -210,13 +197,13 @@ class LocalCalendarTest {
Events.DTSTART to System.currentTimeMillis(),
Events.DTEND to System.currentTimeMillis(),
Events.TITLE to "Some Event",
AndroidEvent2.COLUMN_FLAGS to 123
EventsContract.COLUMN_FLAGS to 123
).apply { putAll(contentValues) }
))
val updated = calendar.markNotDirty(321)
assertEquals(1, updated)
assertEquals(321, androidCalendar.getEvent(id)?.flags)
assertEquals(321, androidCalendar.getEvent(id)?.entityValues?.getAsInteger(EventsContract.COLUMN_FLAGS))
}
@Test

View File

@@ -1,157 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.resource
import android.Manifest
import android.accounts.Account
import android.content.ContentProviderClient
import android.content.ContentUris
import android.content.ContentValues
import android.provider.CalendarContract
import android.provider.CalendarContract.ACCOUNT_TYPE_LOCAL
import android.provider.CalendarContract.Events
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.rule.GrantPermissionRule
import at.bitfire.ical4android.Event
import at.bitfire.ical4android.util.MiscUtils.closeCompat
import at.bitfire.synctools.storage.calendar.AndroidCalendarProvider
import at.techbee.jtx.JtxContract.asSyncAdapter
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import net.fortuna.ical4j.model.property.DtStart
import net.fortuna.ical4j.model.property.RRule
import net.fortuna.ical4j.model.property.RecurrenceId
import net.fortuna.ical4j.model.property.Status
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import javax.inject.Inject
@HiltAndroidTest
class LocalEventTest {
@get:Rule
val hiltRule = HiltAndroidRule(this)
@get:Rule
val permissionRule = GrantPermissionRule.grant(Manifest.permission.READ_CALENDAR, Manifest.permission.WRITE_CALENDAR)
@Inject
lateinit var localCalendarFactory: LocalCalendar.Factory
private val account = Account("LocalCalendarTest", ACCOUNT_TYPE_LOCAL)
private lateinit var client: ContentProviderClient
private lateinit var calendar: LocalCalendar
@Before
fun setUp() {
hiltRule.inject()
val context = InstrumentationRegistry.getInstrumentation().targetContext
client = context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)!!
val provider = AndroidCalendarProvider(account, client)
calendar = localCalendarFactory.create(provider.createAndGetCalendar(ContentValues()))
}
@After
fun tearDown() {
calendar.androidCalendar.delete()
client.closeCompat()
}
@Test
fun testDeleteDirtyEventsWithoutInstances_NoInstances_CancelledExceptions() {
// create recurring event with only deleted/cancelled instances
val event = Event().apply {
dtStart = DtStart("20220120T010203Z")
summary = "Event with 3 instances"
rRules.add(RRule("FREQ=DAILY;COUNT=3"))
exceptions.add(Event().apply {
recurrenceId = RecurrenceId("20220120T010203Z")
dtStart = DtStart("20220120T010203Z")
summary = "Cancelled exception on 1st day"
status = Status.VEVENT_CANCELLED
})
exceptions.add(Event().apply {
recurrenceId = RecurrenceId("20220121T010203Z")
dtStart = DtStart("20220121T010203Z")
summary = "Cancelled exception on 2nd day"
status = Status.VEVENT_CANCELLED
})
exceptions.add(Event().apply {
recurrenceId = RecurrenceId("20220122T010203Z")
dtStart = DtStart("20220122T010203Z")
summary = "Cancelled exception on 3rd day"
status = Status.VEVENT_CANCELLED
})
}
calendar.add(
event = event,
fileName = "filename.ics",
eTag = null,
scheduleTag = null,
flags = LocalResource.FLAG_REMOTELY_PRESENT
)
val localEvent = calendar.findByName("filename.ics")!!
val eventId = localEvent.id!!
// set event as dirty
client.update(ContentUris.withAppendedId(Events.CONTENT_URI.asSyncAdapter(account), eventId), ContentValues(1).apply {
put(Events.DIRTY, 1)
}, null, null)
// this method should mark the event as deleted
calendar.deleteDirtyEventsWithoutInstances()
// verify that event is now marked as deleted
client.query(
ContentUris.withAppendedId(Events.CONTENT_URI.asSyncAdapter(account), eventId),
arrayOf(Events.DELETED), null, null, null
)!!.use { cursor ->
cursor.moveToNext()
assertEquals(1, cursor.getInt(0))
}
}
@Test
fun testDeleteDirtyEventsWithoutInstances_Recurring_Instances() {
val event = Event().apply {
dtStart = DtStart("20220120T010203Z")
summary = "Event with 3 instances"
rRules.add(RRule("FREQ=DAILY;COUNT=3"))
}
calendar.add(
event = event,
fileName = "filename.ics",
eTag = null,
scheduleTag = null,
flags = LocalResource.FLAG_REMOTELY_PRESENT
)
val localEvent = calendar.findByName("filename.ics")!!
val eventId = localEvent.id!!
// set event as dirty
client.update(ContentUris.withAppendedId(Events.CONTENT_URI.asSyncAdapter(account), eventId), ContentValues(1).apply {
put(Events.DIRTY, 1)
}, null, null)
// this method should mark the event as deleted
calendar.deleteDirtyEventsWithoutInstances()
// verify that event is not marked as deleted
client.query(
ContentUris.withAppendedId(Events.CONTENT_URI.asSyncAdapter(account), eventId),
arrayOf(Events.DELETED), null, null, null
)!!.use { cursor ->
cursor.moveToNext()
assertEquals(0, cursor.getInt(0))
}
}
}

View File

@@ -4,16 +4,27 @@
package at.bitfire.davdroid.sync
import android.Manifest
import android.accounts.Account
import android.content.ContentProviderClient
import android.content.Context
import android.content.Entity
import android.provider.CalendarContract
import android.provider.CalendarContract.Calendars
import android.provider.CalendarContract.Events
import androidx.core.content.contentValuesOf
import androidx.test.rule.GrantPermissionRule
import at.bitfire.davdroid.resource.LocalCalendar
import at.bitfire.davdroid.resource.LocalEvent
import at.bitfire.davdroid.sync.account.TestAccount
import at.bitfire.ical4android.Event
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 dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import io.mockk.every
import io.mockk.mockk
import net.fortuna.ical4j.model.property.DtStart
import okio.Buffer
import org.junit.After
import org.junit.Assert.assertEquals
@@ -29,34 +40,64 @@ class CalendarSyncManagerTest {
@get:Rule
val hiltRule = HiltAndroidRule(this)
@get:Rule
val permissionsRule = GrantPermissionRule.grant(
Manifest.permission.READ_CALENDAR, Manifest.permission.WRITE_CALENDAR
)
@Inject @ApplicationContext
lateinit var context: Context
@Inject
lateinit var localCalendarFactory: LocalCalendar.Factory
@Inject
lateinit var syncManagerFactory: CalendarSyncManager.Factory
lateinit var account: Account
lateinit var providerClient: ContentProviderClient
lateinit var androidCalendar: AndroidCalendar
lateinit var localCalendar: LocalCalendar
@Before
fun setUp() {
hiltRule.inject()
account = TestAccount.create()
providerClient = context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)!!
// create LocalCalendar
val androidCalendarProvider = AndroidCalendarProvider(account, providerClient)
androidCalendar = androidCalendarProvider.createAndGetCalendar(contentValuesOf(
Calendars.NAME to "Sample Calendar"
))
localCalendar = localCalendarFactory.create(androidCalendar)
}
@After
fun tearDown() {
localCalendar.androidCalendar.delete()
providerClient.closeCompat()
TestAccount.remove(account)
}
@Test
fun generateUpload_existingUid() {
val result = syncManager().generateUpload(mockk(relaxed = true) {
every { getCachedEvent() } returns Event(uid = "existing-uid", dtStart = DtStart())
})
fun test_generateUpload_existingUid() {
val result = syncManager().generateUpload(LocalEvent(
localCalendar.recurringCalendar,
EventAndExceptions(
main = Entity(contentValuesOf(
Events._ID to 1,
Events.CALENDAR_ID to androidCalendar.id,
Events.DTSTART to System.currentTimeMillis(),
Events.UID_2445 to "existing-uid"
)),
exceptions = emptyList()
)
))
assertEquals("existing-uid.ics", result.suggestedFileName)
assertTrue(result.onSuccessContext.uid.isEmpty)
val iCal = Buffer().also {
result.requestBody.writeTo(it)
@@ -66,19 +107,26 @@ class CalendarSyncManagerTest {
@Test
fun generateUpload_noUid() {
val result = syncManager().generateUpload(mockk(relaxed = true) {
every { getCachedEvent() } returns Event(dtStart = DtStart())
})
val result = syncManager().generateUpload(LocalEvent(
localCalendar.recurringCalendar,
EventAndExceptions(
main = Entity(contentValuesOf(
Events._ID to 2,
Events.CALENDAR_ID to androidCalendar.id,
Events.DTSTART to System.currentTimeMillis()
)),
exceptions = emptyList()
)
))
assertTrue(result.suggestedFileName.matches(UUID_FILENAME_REGEX))
val uuid = result.suggestedFileName.removeSuffix(".ics")
assertEquals(uuid, result.onSuccessContext.uid.get())
val iCal = Buffer().also {
result.requestBody.writeTo(it)
}.readString(Charsets.UTF_8)
assertTrue(iCal.contains("UID:$uuid\r\n"))
}

View File

@@ -5,7 +5,6 @@ package at.bitfire.davdroid
import at.bitfire.synctools.icalendar.ical4jVersion
import ezvcard.Ezvcard
import net.fortuna.ical4j.model.property.ProdId
/**
* Brand-specific constants like (non-theme) colors, homepage URLs etc.
@@ -17,7 +16,7 @@ object Constants {
// product IDs for iCalendar/vCard
val iCalProdId = ProdId("DAVx5/${BuildConfig.VERSION_NAME} ical4j/$ical4jVersion")
val iCalProdId = "DAVx5/${BuildConfig.VERSION_NAME} ical4j/$ical4jVersion"
const val vCardProdId = "+//IDN bitfire.at//DAVx5/${BuildConfig.VERSION_NAME} ez-vcard/${Ezvcard.VERSION}"
}

View File

@@ -43,6 +43,7 @@ import net.fortuna.ical4j.model.Property
import net.fortuna.ical4j.model.PropertyList
import net.fortuna.ical4j.model.TimeZoneRegistryFactory
import net.fortuna.ical4j.model.component.VTimeZone
import net.fortuna.ical4j.model.property.ProdId
import net.fortuna.ical4j.model.property.Version
import okhttp3.HttpUrl
import java.io.StringWriter
@@ -376,7 +377,7 @@ class DavCollectionRepository @Inject constructor(
Calendar(
PropertyList<Property>().apply {
add(Version.VERSION_2_0)
add(Constants.iCalProdId)
add(ProdId(Constants.iCalProdId))
},
ComponentList(
listOf(vTimezone)

View File

@@ -7,15 +7,15 @@ 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.Event
import at.bitfire.ical4android.util.MiscUtils.asSyncAdapter
import at.bitfire.synctools.mapping.calendar.LegacyAndroidEventBuilder2
import at.bitfire.synctools.storage.BatchOperation
import at.bitfire.synctools.storage.calendar.AndroidCalendar
import at.bitfire.synctools.storage.calendar.AndroidEvent2
import at.bitfire.synctools.storage.calendar.AndroidRecurringCalendar
import at.bitfire.synctools.storage.calendar.CalendarBatchOperation
import at.bitfire.synctools.storage.calendar.EventAndExceptions
import at.bitfire.synctools.storage.calendar.EventsContract
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
@@ -60,52 +60,42 @@ class LocalCalendar @AssistedInject constructor(
androidCalendar.writeSyncState(state.toString())
}
private val recurringCalendar = AndroidRecurringCalendar(androidCalendar)
@VisibleForTesting
internal val recurringCalendar = AndroidRecurringCalendar(androidCalendar)
fun add(event: Event, fileName: String, eTag: String?, scheduleTag: String?, flags: Int) {
val mapped = LegacyAndroidEventBuilder2(
calendar = androidCalendar,
event = event,
syncId = fileName,
eTag = eTag,
scheduleTag = scheduleTag,
flags = flags
).build()
recurringCalendar.addEventAndExceptions(mapped)
fun add(event: EventAndExceptions): Long {
return recurringCalendar.addEventAndExceptions(event)
}
override fun findDeleted(): List<LocalEvent> {
val result = LinkedList<LocalEvent>()
androidCalendar.iterateEvents( "${Events.DELETED} AND ${Events.ORIGINAL_ID} IS NULL", null) { entity ->
result += LocalEvent(recurringCalendar, AndroidEvent2(androidCalendar, entity))
recurringCalendar.iterateEventAndExceptions(
"${Events.DELETED} AND ${Events.ORIGINAL_ID} IS NULL", null
) { eventAndExceptions ->
result += LocalEvent(recurringCalendar, eventAndExceptions)
}
return result
}
override fun findDirty(): List<LocalEvent> {
val dirty = LinkedList<LocalEvent>()
/*
* RFC 5545 3.8.7.4. Sequence Number
* When a calendar component is created, its sequence number is 0. It is monotonically incremented by the "Organizer's"
* CUA each time the "Organizer" makes a significant revision to the calendar component.
*/
androidCalendar.iterateEvents("${Events.DIRTY} AND ${Events.ORIGINAL_ID} IS NULL", null) { values ->
dirty += LocalEvent(recurringCalendar, AndroidEvent2(androidCalendar, values))
recurringCalendar.iterateEventAndExceptions(
"${Events.DIRTY} AND ${Events.ORIGINAL_ID} IS NULL", null
) { eventAndExceptions ->
dirty += LocalEvent(recurringCalendar, eventAndExceptions)
}
return dirty
}
override fun findByName(name: String) =
androidCalendar.findEvent("${Events._SYNC_ID}=? AND ${Events.ORIGINAL_SYNC_ID} IS null", arrayOf(name))?.let {
recurringCalendar.findEventAndExceptions("${Events._SYNC_ID}=? AND ${Events.ORIGINAL_SYNC_ID} IS null", arrayOf(name))?.let {
LocalEvent(recurringCalendar, it)
}
override fun markNotDirty(flags: Int) =
androidCalendar.updateEventRows(
contentValuesOf(AndroidEvent2.COLUMN_FLAGS to flags),
contentValuesOf(EventsContract.COLUMN_FLAGS to flags),
// `dirty` can be 0, 1, or null. "NOT dirty" is not enough.
"""
${Events.CALENDAR_ID}=?
@@ -125,7 +115,7 @@ class LocalCalendar @AssistedInject constructor(
${Events.CALENDAR_ID}=?
AND (${Events.DIRTY} IS NULL OR ${Events.DIRTY}=0)
AND ${Events.ORIGINAL_ID} IS NULL
AND ${AndroidEvent2.COLUMN_FLAGS}=?
AND ${EventsContract.COLUMN_FLAGS}=?
""".trimIndent(),
arrayOf(androidCalendar.id.toString(), flags.toString())
) { values ->
@@ -141,7 +131,7 @@ class LocalCalendar @AssistedInject constructor(
override fun forgetETags() {
androidCalendar.updateEventRows(
contentValuesOf(AndroidEvent2.COLUMN_ETAG to null),
contentValuesOf(EventsContract.COLUMN_ETAG to null),
"${Events.CALENDAR_ID}=?", arrayOf(androidCalendar.id.toString())
)
}
@@ -152,7 +142,7 @@ class LocalCalendar @AssistedInject constructor(
logger.info("Processing deleted exceptions")
androidCalendar.iterateEventRows(
arrayOf(Events._ID, Events.ORIGINAL_ID, AndroidEvent2.COLUMN_SEQUENCE),
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 ->
@@ -164,12 +154,12 @@ class LocalCalendar @AssistedInject constructor(
val batch = CalendarBatchOperation(androidCalendar.client)
// enqueue: increase sequence of main event
val originalEventValues = androidCalendar.getEventRow(originalID, arrayOf(AndroidEvent2.COLUMN_SEQUENCE))
val originalSequence = originalEventValues?.getAsInteger(AndroidEvent2.COLUMN_SEQUENCE) ?: 0
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(AndroidEvent2.COLUMN_SEQUENCE, originalSequence + 1)
.withValue(EventsContract.COLUMN_SEQUENCE, originalSequence + 1)
.withValue(Events.DIRTY, 1)
// completely remove deleted exception
@@ -180,7 +170,7 @@ class LocalCalendar @AssistedInject constructor(
// process dirty exceptions
logger.info("Processing dirty exceptions")
androidCalendar.iterateEventRows(
arrayOf(Events._ID, Events.ORIGINAL_ID, AndroidEvent2.COLUMN_SEQUENCE),
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 ->
@@ -188,7 +178,7 @@ class LocalCalendar @AssistedInject constructor(
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(AndroidEvent2.COLUMN_SEQUENCE) ?: 0
val sequence = values.getAsInteger(EventsContract.COLUMN_SEQUENCE) ?: 0
val batch = CalendarBatchOperation(androidCalendar.client)
@@ -200,7 +190,7 @@ class LocalCalendar @AssistedInject constructor(
// enqueue: increase exception SEQUENCE and set DIRTY to 0
batch += BatchOperation.CpoBuilder
.newUpdate(androidCalendar.eventUri(id))
.withValue(AndroidEvent2.COLUMN_SEQUENCE, sequence + 1)
.withValue(EventsContract.COLUMN_SEQUENCE, sequence + 1)
.withValue(Events.DIRTY, 0)
batch.commit()

View File

@@ -27,7 +27,6 @@ import at.bitfire.vcard4android.AndroidContact
import at.bitfire.vcard4android.AndroidContactFactory
import at.bitfire.vcard4android.CachedGroupMembership
import at.bitfire.vcard4android.Contact
import com.google.common.base.Ascii
import com.google.common.base.MoreObjects
import java.io.FileNotFoundException
import java.util.Optional
@@ -139,13 +138,15 @@ class LocalContact: AndroidContact, LocalAddress {
.add("fileName", fileName)
.add("eTag", eTag)
.add("flags", flags)
.add("contact",
/*.add("contact",
try {
// too dangerous, may contain unknown properties and cause another OOM
Ascii.truncate(getContact().toString(), 1000, "…")
} catch (e: Exception) {
e
}
).toString()
)*/
.toString()
override fun getViewUri(context: Context): Uri? =
id?.let { idNotNull ->

View File

@@ -9,98 +9,70 @@ import android.content.Context
import android.provider.CalendarContract
import android.provider.CalendarContract.Events
import androidx.core.content.contentValuesOf
import at.bitfire.ical4android.Event
import at.bitfire.ical4android.LegacyAndroidCalendar
import at.bitfire.synctools.mapping.calendar.LegacyAndroidEventBuilder2
import at.bitfire.synctools.storage.LocalStorageException
import at.bitfire.synctools.storage.calendar.AndroidEvent2
import at.bitfire.synctools.storage.calendar.AndroidCalendar
import at.bitfire.synctools.storage.calendar.AndroidRecurringCalendar
import com.google.common.base.Ascii
import at.bitfire.synctools.storage.calendar.EventAndExceptions
import at.bitfire.synctools.storage.calendar.EventsContract
import com.google.common.base.MoreObjects
import java.util.Optional
class LocalEvent(
val recurringCalendar: AndroidRecurringCalendar,
val androidEvent: AndroidEvent2
val androidEvent: EventAndExceptions
) : LocalResource {
val calendar: AndroidCalendar
get() = recurringCalendar.calendar
private val mainValues = androidEvent.main.entityValues
override val id: Long
get() = androidEvent.id
get() = mainValues.getAsLong(Events._ID)
override val fileName: String?
get() = androidEvent.syncId
get() = mainValues.getAsString(Events._SYNC_ID)
override val eTag: String?
get() = androidEvent.eTag
get() = mainValues.getAsString(EventsContract.COLUMN_ETAG)
override val scheduleTag: String?
get() = androidEvent.scheduleTag
get() = mainValues.getAsString(EventsContract.COLUMN_SCHEDULE_TAG)
override val flags: Int
get() = androidEvent.flags
get() = mainValues.getAsInteger(EventsContract.COLUMN_FLAGS) ?: 0
fun update(data: Event, fileName: String?, eTag: String?, scheduleTag: String?, flags: Int) {
val eventAndExceptions = LegacyAndroidEventBuilder2(
calendar = androidEvent.calendar,
event = data,
syncId = fileName,
eTag = eTag,
scheduleTag = scheduleTag,
flags = flags
).build()
recurringCalendar.updateEventAndExceptions(id, eventAndExceptions)
}
private var _event: Event? = null
/**
* Retrieves the event from the content provider and converts it to a legacy data object.
*
* Caches the result: the content provider is only queried at the first call and then
* this method always returns the same object.
*
* @throws LocalStorageException if there is no local event with the ID from [androidEvent]
*/
@Synchronized
fun getCachedEvent(): Event {
_event?.let { return it }
val legacyCalendar = LegacyAndroidCalendar(androidEvent.calendar)
val event = legacyCalendar.getEvent(androidEvent.id)
?: throw LocalStorageException("Event ${androidEvent.id} not found")
_event = event
return event
}
override fun updateSequence(sequence: Int) {
androidEvent.update(contentValuesOf(
AndroidEvent2.COLUMN_SEQUENCE to sequence
))
}
override fun updateUid(uid: String) {
androidEvent.update(contentValuesOf(
Events.UID_2445 to uid
))
fun update(data: EventAndExceptions) {
recurringCalendar.updateEventAndExceptions(id, data)
}
override fun clearDirty(fileName: Optional<String>, eTag: String?, scheduleTag: String?) {
val values = contentValuesOf(
Events.DIRTY to 0,
AndroidEvent2.COLUMN_ETAG to eTag,
AndroidEvent2.COLUMN_SCHEDULE_TAG to scheduleTag
EventsContract.COLUMN_ETAG to eTag,
EventsContract.COLUMN_SCHEDULE_TAG to scheduleTag
)
if (fileName.isPresent)
values.put(Events._SYNC_ID, fileName.get())
androidEvent.update(values)
calendar.updateEventRow(id, values)
}
override fun updateFlags(flags: Int) {
androidEvent.update(contentValuesOf(
AndroidEvent2.COLUMN_FLAGS to flags
calendar.updateEventRow(id, contentValuesOf(
EventsContract.COLUMN_FLAGS to flags
))
}
override fun updateSequence(sequence: Int) {
calendar.updateEventRow(id, contentValuesOf(
EventsContract.COLUMN_SEQUENCE to sequence
))
}
override fun updateUid(uid: String) {
calendar.updateEventRow(id, contentValuesOf(
Events.UID_2445 to uid
))
}
@@ -109,7 +81,7 @@ class LocalEvent(
}
override fun resetDeleted() {
androidEvent.update(contentValuesOf(
calendar.updateEventRow(id, contentValuesOf(
Events.DELETED to 0
))
}
@@ -123,7 +95,8 @@ class LocalEvent(
.add("flags", flags)
.add("event",
try {
Ascii.truncate(getCachedEvent().toString(), 1000, "")
// only include truncated main event row (won't contain attachments, unknown properties etc.)
androidEvent.main.entityValues.toString().take(1000)
} catch (e: Exception) {
e
}

View File

@@ -15,7 +15,6 @@ import at.bitfire.ical4android.DmfsTaskList
import at.bitfire.ical4android.Task
import at.bitfire.ical4android.TaskProvider
import at.bitfire.synctools.storage.BatchOperation
import com.google.common.base.Ascii
import com.google.common.base.MoreObjects
import org.dmfs.tasks.contract.TaskContract.Tasks
import java.util.Optional
@@ -122,13 +121,15 @@ class LocalTask: DmfsTask, LocalResource {
.add("eTag", eTag)
.add("scheduleTag", scheduleTag)
.add("flags", flags)
.add("task",
/*.add("task",
try {
// too dangerous, may contain unknown properties and cause another OOM
Ascii.truncate(task.toString(), 1000, "…")
} catch (e: Exception) {
e
}
).toString()
)*/
.toString()
override fun getViewUri(context: Context): Uri? {
val idNotNull = id ?: return null

View File

@@ -13,7 +13,7 @@ import android.util.Base64
import androidx.core.content.ContextCompat
import androidx.core.content.contentValuesOf
import at.bitfire.ical4android.UnknownProperty
import at.bitfire.synctools.storage.calendar.AndroidEvent2
import at.bitfire.synctools.storage.calendar.EventsContract
import at.techbee.jtx.JtxContract.asSyncAdapter
import dagger.Binds
import dagger.Module
@@ -69,7 +69,7 @@ class AccountSettingsMigration12 @Inject constructor(
val property = UnknownProperty.fromJsonString(rawValue)
if (property is Url) { // rewrite to MIMETYPE_URL
val newValues = contentValuesOf(
CalendarContract.ExtendedProperties.NAME to AndroidEvent2.EXTNAME_URL,
CalendarContract.ExtendedProperties.NAME to EventsContract.EXTNAME_URL,
CalendarContract.ExtendedProperties.VALUE to property.value
)
provider.update(uri, newValues, null, null)
@@ -77,7 +77,7 @@ class AccountSettingsMigration12 @Inject constructor(
} catch (e: Exception) {
logger.log(
Level.WARNING,
"Couldn't rewrite URL from unknown property to ${AndroidEvent2.EXTNAME_URL}",
"Couldn't rewrite URL from unknown property to ${EventsContract.EXTNAME_URL}",
e
)
}

View File

@@ -29,11 +29,14 @@ import at.bitfire.davdroid.resource.SyncState
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.davdroid.util.DavUtils
import at.bitfire.davdroid.util.DavUtils.lastSegment
import at.bitfire.ical4android.Event
import at.bitfire.ical4android.EventReader
import at.bitfire.ical4android.EventWriter
import at.bitfire.ical4android.util.DateUtils
import at.bitfire.synctools.exception.InvalidICalendarException
import at.bitfire.synctools.icalendar.CalendarUidSplitter
import at.bitfire.synctools.icalendar.ICalendarGenerator
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 dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
@@ -41,6 +44,7 @@ import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.runInterruptible
import net.fortuna.ical4j.model.Component
import net.fortuna.ical4j.model.component.VAlarm
import net.fortuna.ical4j.model.component.VEvent
import net.fortuna.ical4j.model.property.Action
import okhttp3.HttpUrl
import okhttp3.RequestBody.Companion.toRequestBody
@@ -179,47 +183,30 @@ class CalendarSyncManager @AssistedInject constructor(
}
override fun generateUpload(resource: LocalEvent): GeneratedResource {
val event = resource.getCachedEvent()
logger.log(Level.FINE, "Preparing upload of iCalendar ${resource.fileName}", event)
val localEvent = resource.androidEvent
logger.log(Level.FINE, "Preparing upload of event #${resource.id}", localEvent)
// get/create UID
val (uid, uidIsGenerated) = DavUtils.generateUidIfNecessary(event.uid)
if (uidIsGenerated)
event.uid = uid
// map Android event to iCalendar (also generates UID and increases SEQUENCE, if necessary)
val processor = AndroidEventProcessor(
accountName = resource.recurringCalendar.calendar.account.name,
prodIdGenerator = DefaultProdIdGenerator(Constants.iCalProdId)
)
val mappedEvents = processor.mapToVEvents(localEvent)
// Increase sequence, if necessary:
// - If it's null, the event has just been created in the database, so we can start with SEQUENCE:0 (default).
// - If it's non-null, the event already exists on the server, so increase by one.
val groupScheduled = event.attendees.isNotEmpty()
val weAreOrganizer = event.isOrganizer == true
val sequence = event.sequence
val newSequence: Optional<Int> = when {
// first upload, set to 0 after upload
sequence == null ->
Optional.of(0)
// re-upload of group-scheduled event (and we're ORGANIZER), increase sequence in iCalendar and after upload
groupScheduled && weAreOrganizer -> {
event.sequence = sequence + 1
Optional.of(sequence + 1)
}
// standard re-upload, don't update sequence
else ->
Optional.empty()
}
// persist UID if it was generated
if (mappedEvents.generatedUid)
resource.updateUid(mappedEvents.uid)
// generate iCalendar and convert to request body
val iCalWriter = StringWriter()
EventWriter(Constants.iCalProdId).write(event, iCalWriter)
ICalendarGenerator().write(mappedEvents.associatedEvents, iCalWriter)
val requestBody = iCalWriter.toString().toRequestBody(DavCalendar.MIME_ICALENDAR_UTF8)
return GeneratedResource(
suggestedFileName = DavUtils.fileNameFromUid(uid, "ics"),
suggestedFileName = DavUtils.fileNameFromUid(mappedEvents.uid, "ics"),
requestBody = requestBody,
onSuccessContext = GeneratedResource.OnSuccessContext(
uid = if (uidIsGenerated) Optional.of(uid) else Optional.empty(),
sequence = newSequence
sequence = mappedEvents.updatedSequence
)
)
}
@@ -272,11 +259,11 @@ class CalendarSyncManager @AssistedInject constructor(
?: throw DavException("Received multi-get response without ETag")
val scheduleTag = response[ScheduleTag::class.java]?.scheduleTag
processVEvent(
response.href.lastSegment,
eTag,
scheduleTag,
StringReader(iCal)
processICalendar(
fileName = response.href.lastSegment,
eTag = eTag,
scheduleTag = scheduleTag,
reader = StringReader(iCal)
)
}
}
@@ -289,56 +276,56 @@ class CalendarSyncManager @AssistedInject constructor(
// helpers
private fun processVEvent(fileName: String, eTag: String, scheduleTag: String?, reader: Reader) {
val events: List<Event>
try {
events = EventReader().readEvents(reader)
} catch (e: InvalidICalendarException) {
logger.log(Level.SEVERE, "Received invalid iCalendar, ignoring", e)
notifyInvalidResource(e, fileName)
private fun processICalendar(fileName: String, eTag: String, scheduleTag: String?, reader: Reader) {
val calendar =
try {
ICalendarParser().parse(reader)
} catch (e: InvalidICalendarException) {
logger.log(Level.WARNING, "Received invalid iCalendar, ignoring", e)
notifyInvalidResource(e, fileName)
return
}
val uidsAndEvents = CalendarUidSplitter<VEvent>().associateByUid(calendar, Component.VEVENT)
if (uidsAndEvents.size != 1) {
logger.warning("Received iCalendar with not exactly one UID; ignoring $fileName")
return
}
// Event: main VEVENT and potentially attached exceptions (further VEVENTs with RECURRENCE-ID)
val event = uidsAndEvents.values.first()
if (events.size == 1) {
val event = events.first()
// set default reminder for non-full-day events, if requested
val defaultAlarmMinBefore = accountSettings.getDefaultAlarm()
if (defaultAlarmMinBefore != null && DateUtils.isDateTime(event.dtStart) && event.alarms.isEmpty()) {
val alarm = VAlarm(Duration.ofMinutes(-defaultAlarmMinBefore.toLong())).apply {
// Sets METHOD_ALERT instead of METHOD_DEFAULT in the calendar provider.
// Needed for calendars to actually show a notification.
properties += Action.DISPLAY
}
logger.log(Level.FINE, "${event.uid}: Adding default alarm", alarm)
event.alarms += alarm
val defaultAlarmMinBefore = accountSettings.getDefaultAlarm()
val mainEvent = event.main
if (mainEvent != null && defaultAlarmMinBefore != null && DateUtils.isDateTime(mainEvent.startDate) && mainEvent.alarms.isEmpty()) {
val alarm = VAlarm(Duration.ofMinutes(-defaultAlarmMinBefore.toLong())).apply {
// Sets METHOD_ALERT instead of METHOD_DEFAULT in the calendar provider.
// Needed for calendars to actually show a notification.
properties += Action.DISPLAY
}
logger.log(Level.FINE, "${mainEvent.uid}: Adding default alarm", alarm)
mainEvent.components += alarm
}
// update local event, if it exists
val local = localCollection.findByName(fileName)
// map AssociatedEvents (VEVENTs) to EventAndExceptions (Android events)
val androidEvent = AndroidEventBuilder(
calendar = localCollection.androidCalendar,
syncId = fileName,
eTag = eTag,
scheduleTag = scheduleTag,
flags = LocalResource.FLAG_REMOTELY_PRESENT
).build(event)
// update local event, if it exists
val local = localCollection.findByName(fileName)
if (local != null) {
SyncException.wrapWithLocalResource(local) {
if (local != null) {
logger.log(Level.INFO, "Updating $fileName in local calendar", event)
local.update(
data = event,
fileName = fileName,
eTag = eTag,
scheduleTag = scheduleTag,
flags = LocalResource.FLAG_REMOTELY_PRESENT
)
} else {
logger.log(Level.INFO, "Adding $fileName to local calendar", event)
localCollection.add(
event = event,
fileName = fileName,
eTag = eTag,
scheduleTag = scheduleTag,
flags = LocalResource.FLAG_REMOTELY_PRESENT
)
}
logger.log(Level.INFO, "Updating $fileName in local calendar", event)
local.update(androidEvent)
}
} else
logger.info("Received VCALENDAR with not exactly one VEVENT with UID and without RECURRENCE-ID; ignoring $fileName")
} else {
logger.log(Level.INFO, "Adding $fileName to local calendar", event)
localCollection.add(androidEvent)
}
}
override fun notifyInvalidResourceTitle(): String =

View File

@@ -277,12 +277,15 @@ class ContactsSyncManager @AssistedInject constructor(
is LocalGroup -> resource.getContact()
else -> throw IllegalArgumentException("resource must be LocalContact or LocalGroup")
}
logger.log(Level.FINE, "Preparing upload of vCard ${resource.fileName}", contact)
logger.log(Level.FINE, "Preparing upload of vCard #${resource.id}", contact)
// get/create UID
val (uid, uidIsGenerated) = DavUtils.generateUidIfNecessary(contact.uid)
if (uidIsGenerated)
if (uidIsGenerated) {
// modify in Contact and persist to contacts provider
contact.uid = uid
resource.updateUid(uid)
}
// generate vCard and convert to request body
val os = ByteArrayOutputStream()
@@ -304,10 +307,7 @@ class ContactsSyncManager @AssistedInject constructor(
return GeneratedResource(
suggestedFileName = DavUtils.fileNameFromUid(uid, "vcf"),
requestBody = os.toByteArray().toRequestBody(mimeType),
onSuccessContext = GeneratedResource.OnSuccessContext(
uid = if (uidIsGenerated) Optional.of(uid) else Optional.empty()
)
requestBody = os.toByteArray().toRequestBody(mimeType)
)
}

View File

@@ -5,30 +5,28 @@
package at.bitfire.davdroid.sync
import okhttp3.RequestBody
import java.util.Optional
/**
* Represents a resource that has been generated for the purpose of being uploaded.
*
* @param suggestedFileName file name that can be used for uploading if there's no existing name
* @param requestBody resource body (including MIME type)
* @param onSuccessContext context that must be passed to [SyncManager.onSuccessfulUpload] on successful upload
* @param onSuccessContext context that must be passed to [SyncManager.onSuccessfulUpload]
* on successful upload in order to persist the changes made during mapping
*/
class GeneratedResource(
val suggestedFileName: String,
val requestBody: RequestBody,
val onSuccessContext: OnSuccessContext
val onSuccessContext: OnSuccessContext? = null
) {
/**
* Contains information that has been created for a [GeneratedResource], but has not been saved yet.
*
* @param uid new UID to persist on successful upload (empty: UID not modified)
* @param sequence new SEQUENCE to persist on successful upload (empty: SEQUENCE not modified)
* @param sequence new SEQUENCE to persist on successful upload (*null*: SEQUENCE not modified)
*/
data class OnSuccessContext(
val uid: Optional<String> = Optional.empty(),
val sequence: Optional<Int> = Optional.empty()
val sequence: Int? = null
)
}

View File

@@ -34,6 +34,7 @@ import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.runInterruptible
import net.fortuna.ical4j.model.property.ProdId
import okhttp3.HttpUrl
import okhttp3.RequestBody.Companion.toRequestBody
import java.io.ByteArrayOutputStream
@@ -97,15 +98,14 @@ class JtxSyncManager @AssistedInject constructor(
}
override fun generateUpload(resource: LocalJtxICalObject): GeneratedResource {
logger.log(Level.FINE, "Preparing upload of icalobject ${resource.uid}")
logger.log(Level.FINE, "Preparing upload of icalobject #${resource.id}")
val os = ByteArrayOutputStream()
resource.write(os, Constants.iCalProdId)
resource.write(os, ProdId(Constants.iCalProdId))
return GeneratedResource(
suggestedFileName = DavUtils.fileNameFromUid(resource.uid, "ics"),
requestBody = os.toByteArray().toRequestBody(DavCalendar.MIME_ICALENDAR_UTF8),
onSuccessContext = GeneratedResource.OnSuccessContext() // nothing special to update after upload
requestBody = os.toByteArray().toRequestBody(DavCalendar.MIME_ICALENDAR_UTF8)
)
}

View File

@@ -506,7 +506,7 @@ abstract class SyncManager<ResourceType: LocalResource, out CollectionType: Loca
newFileName: String,
eTag: String?,
scheduleTag: String?,
context: GeneratedResource.OnSuccessContext
context: GeneratedResource.OnSuccessContext?
) {
logger.log(Level.FINE, "Upload successful", arrayOf(
"File name = $newFileName",
@@ -515,12 +515,9 @@ abstract class SyncManager<ResourceType: LocalResource, out CollectionType: Loca
"context = $context"
))
// update local UID and SEQUENCE, if necessary
if (context.uid.isPresent)
local.updateUid(context.uid.get())
if (context.sequence.isPresent)
local.updateSequence(context.sequence.get())
// update SEQUENCE, if necessary
if (context?.sequence != null)
local.updateSequence(context.sequence)
// clear dirty flag and update ETag/Schedule-Tag
local.clearDirty(Optional.of(newFileName), eTag, scheduleTag)

View File

@@ -33,12 +33,12 @@ import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.runInterruptible
import net.fortuna.ical4j.model.property.ProdId
import okhttp3.HttpUrl
import okhttp3.RequestBody.Companion.toRequestBody
import java.io.ByteArrayOutputStream
import java.io.Reader
import java.io.StringReader
import java.util.Optional
import java.util.logging.Level
/**
@@ -104,23 +104,23 @@ class TasksSyncManager @AssistedInject constructor(
override fun generateUpload(resource: LocalTask): GeneratedResource {
val task = requireNotNull(resource.task)
logger.log(Level.FINE, "Preparing upload of task ${resource.fileName}", task)
logger.log(Level.FINE, "Preparing upload of task ${resource.id}", task)
// get/create UID
val (uid, uidIsGenerated) = DavUtils.generateUidIfNecessary(task.uid)
if (uidIsGenerated)
if (uidIsGenerated) {
// modify in Task and persist to tasks provider
task.uid = uid
resource.updateUid(uid)
}
// generate iCalendar and convert to request body
val os = ByteArrayOutputStream()
task.write(os, Constants.iCalProdId)
task.write(os, ProdId(Constants.iCalProdId))
return GeneratedResource(
suggestedFileName = DavUtils.fileNameFromUid(uid, "ics"),
requestBody = os.toByteArray().toRequestBody(DavCalendar.MIME_ICALENDAR_UTF8),
onSuccessContext = GeneratedResource.OnSuccessContext(
uid = if (uidIsGenerated) Optional.of(uid) else Optional.empty()
)
requestBody = os.toByteArray().toRequestBody(DavCalendar.MIME_ICALENDAR_UTF8)
)
}

View File

@@ -20,7 +20,7 @@ androidx-test-junit = "1.3.0"
androidx-work = "2.11.0"
bitfire-cert4android = "41009d48ed"
bitfire-dav4jvm = "f11523619b"
bitfire-synctools = "1a7f70b1a0"
bitfire-synctools = "e48bdbd330"
compose-accompanist = "0.37.3"
compose-bom = "2025.10.01"
dnsjava = "3.6.3"