mirror of
https://github.com/bitfireAT/davx5-ose.git
synced 2025-12-23 23:17:50 -05:00
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:
@@ -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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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"))
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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}"
|
||||
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 ->
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user