diff --git a/lib/src/androidTest/kotlin/at/bitfire/ical4android/AndroidCalendarTest.kt b/lib/src/androidTest/kotlin/at/bitfire/ical4android/AndroidCalendarTest.kt index 7f5f2a5a..3e0ebbb4 100644 --- a/lib/src/androidTest/kotlin/at/bitfire/ical4android/AndroidCalendarTest.kt +++ b/lib/src/androidTest/kotlin/at/bitfire/ical4android/AndroidCalendarTest.kt @@ -91,14 +91,14 @@ class AndroidCalendarTest { @Test fun testInsertColors() { AndroidCalendar.insertColors(provider, testAccount) - assertEquals(Css3Color.values().size, countColors(testAccount)) + assertEquals(Css3Color.entries.size, countColors(testAccount)) } @Test fun testInsertColors_AlreadyThere() { AndroidCalendar.insertColors(provider, testAccount) AndroidCalendar.insertColors(provider, testAccount) - assertEquals(Css3Color.values().size, countColors(testAccount)) + assertEquals(Css3Color.entries.size, countColors(testAccount)) } @Test diff --git a/lib/src/androidTest/kotlin/at/bitfire/ical4android/AndroidEventTest.kt b/lib/src/androidTest/kotlin/at/bitfire/ical4android/AndroidEventTest.kt index 3c01f129..c206d8f8 100644 --- a/lib/src/androidTest/kotlin/at/bitfire/ical4android/AndroidEventTest.kt +++ b/lib/src/androidTest/kotlin/at/bitfire/ical4android/AndroidEventTest.kt @@ -63,14 +63,6 @@ import java.net.URI import java.time.Duration import java.time.Period import java.util.logging.Logger -import kotlin.collections.Map -import kotlin.collections.component1 -import kotlin.collections.component2 -import kotlin.collections.emptyMap -import kotlin.collections.first -import kotlin.collections.firstOrNull -import kotlin.collections.iterator -import kotlin.collections.mapOf import kotlin.collections.plusAssign class AndroidEventTest { @@ -126,6 +118,25 @@ class AndroidEventTest { } + @Test + fun testConstructor_ContentValues() { + val e = AndroidEvent( + calendar, contentValuesOf( + Events._ID to 123, + Events._SYNC_ID to "some-ical.ics", + AndroidEvent.COLUMN_ETAG to "some-etag", + AndroidEvent.COLUMN_SCHEDULE_TAG to "some-schedule-tag", + AndroidEvent.COLUMN_FLAGS to 45 + ) + ) + assertEquals(123L, e.id) + assertEquals("some-ical.ics", e.syncId) + assertEquals("some-etag", e.eTag) + assertEquals("some-schedule-tag", e.scheduleTag) + assertEquals(45, e.flags) + } + + /** * buildEvent() BASIC TEST MATRIX: * @@ -1522,6 +1533,87 @@ class AndroidEventTest { ).event!! } + @Test + fun testPopulateEvent_Uid_iCalUid() { + populateEvent( + true, + extendedProperties = mapOf( + AndroidEvent.EXTNAME_ICAL_UID to "event1@example.com" + ) + ).let { result -> + assertEquals("event1@example.com", result.uid) + } + } + + @Test + fun testPopulateEvent_Uid_UID_2445() { + populateEvent(true) { + put(Events.UID_2445, "event1@example.com") + }.let { result -> + assertEquals("event1@example.com", result.uid) + } + } + + @Test + fun testPopulateEvent_Uid_UID_2445_and_iCalUid() { + populateEvent( + true, + extendedProperties = mapOf( + AndroidEvent.EXTNAME_ICAL_UID to "event1@example.com" + ) + ) { + put(Events.UID_2445, "event2@example.com") + }.let { result -> + assertEquals("event2@example.com", result.uid) + } + } + + + @Test + fun testPopulateEvent_Sequence_Int() { + populateEvent(true, asSyncAdapter = true) { + put(AndroidEvent.COLUMN_SEQUENCE, 5) + }.let { result -> + assertEquals(5, result.sequence) + } + } + + @Test + fun testPopulateEvent_Sequence_Null() { + populateEvent(true, asSyncAdapter = true) { + putNull(AndroidEvent.COLUMN_SEQUENCE) + }.let { result -> + assertNull(result.sequence) + } + } + + @Test + fun testPopulateEvent_IsOrganizer_False() { + populateEvent(true, asSyncAdapter = true) { + put(Events.IS_ORGANIZER, "0") + }.let { result -> + assertFalse(result.isOrganizer!!) + } + } + + @Test + fun testPopulateEvent_IsOrganizer_Null() { + populateEvent(true, asSyncAdapter = true) { + putNull(Events.IS_ORGANIZER) + }.let { result -> + assertNull(result.isOrganizer) + } + } + + @Test + fun testPopulateEvent_IsOrganizer_True() { + populateEvent(true, asSyncAdapter = true) { + put(Events.IS_ORGANIZER, "1") + }.let { result -> + assertTrue(result.isOrganizer!!) + } + } + @Test fun testPopulateEvent_NonAllDay_NonRecurring() { populateEvent(false) { @@ -1886,39 +1978,6 @@ class AndroidEventTest { } } - @Test - fun testPopulateEvent_Uid_iCalUid() { - populateEvent( - true, - extendedProperties = mapOf( - AndroidEvent.EXTNAME_ICAL_UID to "event1@example.com" - ) - ).let { result -> - assertEquals("event1@example.com", result.uid) - } - } - - @Test - fun testPopulateEvent_Uid_UID_2445() { - populateEvent(true) { - put(Events.UID_2445, "event1@example.com") - }.let { result -> - assertEquals("event1@example.com", result.uid) - } - } - - @Test - fun testPopulateEvent_Uid_UID_2445_and_iCalUid() { - populateEvent(true, - extendedProperties = mapOf( - AndroidEvent.EXTNAME_ICAL_UID to "event1@example.com" - )) { - put(Events.UID_2445, "event2@example.com") - }.let { result -> - assertEquals("event2@example.com", result.uid) - } - } - private fun populateReminder(destinationCalendar: TestCalendar = calendar, builder: ContentValues.() -> Unit): VAlarm? { populateEvent(true, destinationCalendar = destinationCalendar, insertCallback = { id -> @@ -2340,7 +2399,6 @@ class AndroidEventTest { } - @Test fun testUpdateEvent() { // add test event without reminder @@ -2480,4 +2538,4 @@ class AndroidEventTest { } } -} +} \ No newline at end of file diff --git a/lib/src/androidTest/kotlin/at/bitfire/ical4android/impl/TestCalendar.kt b/lib/src/androidTest/kotlin/at/bitfire/ical4android/impl/TestCalendar.kt index e586295a..a834b333 100644 --- a/lib/src/androidTest/kotlin/at/bitfire/ical4android/impl/TestCalendar.kt +++ b/lib/src/androidTest/kotlin/at/bitfire/ical4android/impl/TestCalendar.kt @@ -15,9 +15,9 @@ import at.bitfire.ical4android.AndroidCalendar import at.bitfire.ical4android.AndroidCalendarFactory class TestCalendar( - account: Account, - providerClient: ContentProviderClient, - id: Long + account: Account, + providerClient: ContentProviderClient, + id: Long ): AndroidCalendar(account, providerClient, TestEvent.Factory, id) { companion object { diff --git a/lib/src/androidTest/kotlin/at/bitfire/ical4android/impl/TestEvent.kt b/lib/src/androidTest/kotlin/at/bitfire/ical4android/impl/TestEvent.kt index 571f0430..ce8c39db 100644 --- a/lib/src/androidTest/kotlin/at/bitfire/ical4android/impl/TestEvent.kt +++ b/lib/src/androidTest/kotlin/at/bitfire/ical4android/impl/TestEvent.kt @@ -7,12 +7,10 @@ package at.bitfire.ical4android.impl import android.content.ContentValues -import android.provider.CalendarContract.Events import at.bitfire.ical4android.AndroidCalendar import at.bitfire.ical4android.AndroidEvent import at.bitfire.ical4android.AndroidEventFactory import at.bitfire.ical4android.Event -import at.bitfire.synctools.storage.BatchOperation import java.util.UUID class TestEvent: AndroidEvent { @@ -21,19 +19,7 @@ class TestEvent: AndroidEvent { : super(calendar, values) constructor(calendar: TestCalendar, event: Event) - : super(calendar, event) - - val syncId by lazy { UUID.randomUUID().toString() } - - - override fun buildEvent(recurrence: Event?, builder: BatchOperation.CpoBuilder) { - if (recurrence != null) - builder.withValue(Events.ORIGINAL_SYNC_ID, syncId) - else - builder.withValue(Events._SYNC_ID, syncId) - - super.buildEvent(recurrence, builder) - } + : super(calendar, event, UUID.randomUUID().toString(), null, null, 0) object Factory: AndroidEventFactory { diff --git a/lib/src/main/kotlin/at/bitfire/ical4android/AndroidCalendar.kt b/lib/src/main/kotlin/at/bitfire/ical4android/AndroidCalendar.kt index 44800ac2..05f820ad 100644 --- a/lib/src/main/kotlin/at/bitfire/ical4android/AndroidCalendar.kt +++ b/lib/src/main/kotlin/at/bitfire/ical4android/AndroidCalendar.kt @@ -17,7 +17,8 @@ import android.provider.CalendarContract.Calendars import android.provider.CalendarContract.Colors import android.provider.CalendarContract.Events import android.provider.CalendarContract.Reminders -import androidx.annotation.CallSuper +import androidx.core.content.contentValuesOf +import at.bitfire.ical4android.AndroidCalendar.Companion.find import at.bitfire.ical4android.util.MiscUtils.asSyncAdapter import at.bitfire.ical4android.util.MiscUtils.toValues import java.io.FileNotFoundException @@ -30,7 +31,7 @@ import java.util.logging.Logger * Communicates with the Android Contacts Provider which uses an SQLite * database to store the events. */ -abstract class AndroidCalendar( +open class AndroidCalendar( val account: Account, val provider: ContentProviderClient, val eventFactory: AndroidEventFactory, @@ -39,11 +40,105 @@ abstract class AndroidCalendar( val id: Long ) { + var name: String? = null + var displayName: String? = null + var accessLevel: Int? = null + var color: Int? = null + var isSynced = true + var isVisible = true + + var ownerAccount: String? = null + + var syncId: String? = null + + + /** + * Sets the calendar properties ([name], [displayName] etc.) from the passed argument, + * which is usually directly taken from the Calendar Provider. + * + * Called when an instance is created from a Calendar Provider data row, for example + * using [find]. + * + * @param info values from Calendar Provider + */ + private fun populate(info: ContentValues) { + name = info.getAsString(Calendars.NAME) + displayName = info.getAsString(Calendars.CALENDAR_DISPLAY_NAME) + accessLevel = info.getAsInteger(Calendars.CALENDAR_ACCESS_LEVEL) + + color = info.getAsInteger(Calendars.CALENDAR_COLOR) + + isSynced = info.getAsInteger(Calendars.SYNC_EVENTS) != 0 + isVisible = info.getAsInteger(Calendars.VISIBLE) != 0 + + ownerAccount = info.getAsString(Calendars.OWNER_ACCOUNT) + + syncId = info.getAsString(Calendars._SYNC_ID) + } + + + fun update(info: ContentValues): Int { + logger.log(Level.FINE, "Updating local calendar (#$id)", info) + return provider.update(calendarSyncURI(), info, null, null) + } + + /** + * Deletes this calendar from the local calendar provider. + * + * @return `true` if the calendar was deleted, `false` otherwise (like it was not there before the call) + */ + fun delete(): Boolean { + logger.log(Level.FINE, "Deleting local calendar (#$id)") + return provider.delete(calendarSyncURI(), null, null) > 0 + } + + + /** + * Queries events from this calendar. Adds a WHERE clause that restricts the + * query to [Events.CALENDAR_ID] = [id]. + * @param _where selection + * @param _whereArgs arguments for selection + * @return events from this calendar which match the selection + */ + fun queryEvents(_where: String? = null, _whereArgs: Array? = null): List { + val where = "(${_where ?: "1"}) AND " + Events.CALENDAR_ID + "=?" + val whereArgs = (_whereArgs ?: arrayOf()) + id.toString() + + val events = LinkedList() + provider.query(Events.CONTENT_URI.asSyncAdapter(account), null, where, whereArgs, null)?.use { cursor -> + while (cursor.moveToNext()) + events += eventFactory.fromProvider(this, cursor.toValues()) + } + return events + } + + fun findById(id: Long) = queryEvents("${Events._ID}=?", arrayOf(id.toString())).firstOrNull() + ?: throw FileNotFoundException() + + + fun readSyncState(): String? = + provider.query(calendarSyncURI(), arrayOf(COLUMN_SYNC_STATE), null, null, null)?.use { cursor -> + if (cursor.moveToNext()) + return cursor.getString(0) + else + null + } + + fun writeSyncState(state: String?) { + update(contentValuesOf(COLUMN_SYNC_STATE to state)) + } + + + fun calendarSyncURI() = ContentUris.withAppendedId(Calendars.CONTENT_URI, id).asSyncAdapter(account) + + companion object { - + + private const val COLUMN_SYNC_STATE = Calendars.CAL_SYNC1 + private val logger get() = Logger.getLogger(AndroidCalendar::class.java.name) - + /** * Recommended initial values when creating Android [Calendars]. */ @@ -72,13 +167,13 @@ abstract class AndroidCalendar( logger.log(Level.FINE, "Creating local calendar", info) return provider.insert(Calendars.CONTENT_URI.asSyncAdapter(account), info) ?: - throw Exception("Couldn't create calendar: provider returned null") + throw Exception("Couldn't create calendar: provider returned null") } fun insertColors(provider: ContentProviderClient, account: Account) { provider.query(Colors.CONTENT_URI.asSyncAdapter(account), arrayOf(Colors.COLOR_KEY), null, null, null)?.use { cursor -> - if (cursor.count == Css3Color.values().size) - // colors already inserted and up to date + if (cursor.count == Css3Color.entries.size) + // colors already inserted and up to date return } @@ -87,7 +182,7 @@ abstract class AndroidCalendar( values.put(Colors.ACCOUNT_NAME, account.name) values.put(Colors.ACCOUNT_TYPE, account.type) values.put(Colors.COLOR_TYPE, Colors.TYPE_EVENT) - for (color in Css3Color.values()) { + for (color in Css3Color.entries) { values.put(Colors.COLOR_KEY, color.name) values.put(Colors.COLOR, color.argb) try { @@ -113,7 +208,7 @@ abstract class AndroidCalendar( val values = ContentValues(1) values.putNull(Events.EVENT_COLOR_KEY) provider.update(Events.CONTENT_URI.asSyncAdapter(account), values, - "${Events.EVENT_COLOR_KEY} IS NOT NULL AND ${Events.CALENDAR_ID}=?", arrayOf(calId.toString())) + "${Events.EVENT_COLOR_KEY} IS NOT NULL AND ${Events.CALENDAR_ID}=?", arrayOf(calId.toString())) } } @@ -123,7 +218,7 @@ abstract class AndroidCalendar( fun> findByID(account: Account, provider: ContentProviderClient, factory: AndroidCalendarFactory, id: Long): T { val iterCalendars = CalendarEntity.newEntityIterator( - provider.query(ContentUris.withAppendedId(CalendarEntity.CONTENT_URI, id).asSyncAdapter(account), null, null, null, null) + provider.query(ContentUris.withAppendedId(CalendarEntity.CONTENT_URI, id).asSyncAdapter(account), null, null, null, null) ) try { if (iterCalendars.hasNext()) { @@ -140,7 +235,7 @@ abstract class AndroidCalendar( fun> find(account: Account, provider: ContentProviderClient, factory: AndroidCalendarFactory, where: String?, whereArgs: Array?): List { val iterCalendars = CalendarEntity.newEntityIterator( - provider.query(CalendarEntity.CONTENT_URI.asSyncAdapter(account), null, where, whereArgs, null) + provider.query(CalendarEntity.CONTENT_URI.asSyncAdapter(account), null, where, whereArgs, null) ) try { val calendars = LinkedList() @@ -158,82 +253,4 @@ abstract class AndroidCalendar( } - - var name: String? = null - var displayName: String? = null - var color: Int? = null - var isSynced = true - var isVisible = true - - var ownerAccount: String? = null - - var syncId: String? = null - - - /** - * Sets the calendar properties ([name], [displayName] etc.) from the passed argument, - * which is usually directly taken from the Calendar Provider. - * - * Called when an instance is created from a Calendar Provider data row, for example - * using [find]. - * - * @param info values from Calendar Provider - */ - @CallSuper - protected open fun populate(info: ContentValues) { - name = info.getAsString(Calendars.NAME) - displayName = info.getAsString(Calendars.CALENDAR_DISPLAY_NAME) - - color = info.getAsInteger(Calendars.CALENDAR_COLOR) - - isSynced = info.getAsInteger(Calendars.SYNC_EVENTS) != 0 - isVisible = info.getAsInteger(Calendars.VISIBLE) != 0 - - ownerAccount = info.getAsString(Calendars.OWNER_ACCOUNT) - - syncId = info.getAsString(Calendars._SYNC_ID) - } - - - fun update(info: ContentValues): Int { - logger.log(Level.FINE, "Updating local calendar (#$id)", info) - return provider.update(calendarSyncURI(), info, null, null) - } - - /** - * Deletes this calendar from the local calendar provider. - * - * @return `true` if the calendar was deleted, `false` otherwise (like it was not there before the call) - */ - fun delete(): Boolean { - logger.log(Level.FINE, "Deleting local calendar (#$id)") - return provider.delete(calendarSyncURI(), null, null) > 0 - } - - - /** - * Queries events from this calendar. Adds a WHERE clause that restricts the - * query to [Events.CALENDAR_ID] = [id]. - * @param _where selection - * @param _whereArgs arguments for selection - * @return events from this calendar which match the selection - */ - fun queryEvents(_where: String? = null, _whereArgs: Array? = null): List { - val where = "(${_where ?: "1"}) AND " + Events.CALENDAR_ID + "=?" - val whereArgs = (_whereArgs ?: arrayOf()) + id.toString() - - val events = LinkedList() - provider.query(Events.CONTENT_URI.asSyncAdapter(account), null, where, whereArgs, null)?.use { cursor -> - while (cursor.moveToNext()) - events += eventFactory.fromProvider(this, cursor.toValues()) - } - return events - } - - fun findById(id: Long) = queryEvents("${Events._ID}=?", arrayOf(id.toString())).firstOrNull() - ?: throw FileNotFoundException() - - - fun calendarSyncURI() = ContentUris.withAppendedId(Calendars.CONTENT_URI, id).asSyncAdapter(account) - } \ No newline at end of file diff --git a/lib/src/main/kotlin/at/bitfire/ical4android/AndroidEvent.kt b/lib/src/main/kotlin/at/bitfire/ical4android/AndroidEvent.kt index de00cdab..85b86ea7 100644 --- a/lib/src/main/kotlin/at/bitfire/ical4android/AndroidEvent.kt +++ b/lib/src/main/kotlin/at/bitfire/ical4android/AndroidEvent.kt @@ -84,63 +84,44 @@ import java.util.logging.Logger * Important: To use recurrence exceptions, you MUST set _SYNC_ID and ORIGINAL_SYNC_ID * in populateEvent() / buildEvent. Setting _ID and ORIGINAL_ID is not sufficient. */ -abstract class AndroidEvent( +open class AndroidEvent( val calendar: AndroidCalendar ) { - companion object { - - const val MUTATORS_SEPARATOR = ',' - - /** - * VEVENT CATEGORIES are stored as an extended property with this [ExtendedProperties.NAME]. - * - * The [ExtendedProperties.VALUE] format is the same as used by the AOSP Exchange ActiveSync adapter: - * the category values are stored as list, separated by [CATEGORIES_SEPARATOR]. (If a category - * value contains [CATEGORIES_SEPARATOR], [CATEGORIES_SEPARATOR] will be dropped.) - * - * Example: `Cat1\Cat2` - */ - const val EXTNAME_CATEGORIES = "categories" - const val CATEGORIES_SEPARATOR = '\\' - - /** - * Google Calendar uses an extended property called `iCalUid` for storing the event's UID, instead of the - * standard [Events.UID_2445]. - * - * @see GitHub Issue - */ - const val EXTNAME_ICAL_UID = "iCalUid" - - /** - * VEVENT URL is stored as an extended property with this [ExtendedProperties.NAME]. - * The URL is directly put into [ExtendedProperties.VALUE]. - */ - const val EXTNAME_URL = ContentResolver.CURSOR_ITEM_BASE_TYPE + "/vnd.ical4android.url" - - } - protected val logger: Logger by lazy { Logger.getLogger(AndroidEvent::class.java.name) } var id: Long? = null protected set + open var syncId: String? = null + + var eTag: String? = null + var scheduleTag: String? = null + var flags: Int = 0 /** * Creates a new object from an event which already exists in the calendar storage. + * * @param values database row with all columns, as returned by the calendar provider */ - constructor(calendar: AndroidCalendar, values: ContentValues) : this(calendar) { + constructor(calendar: AndroidCalendar<*>, values: ContentValues) : this(calendar) { this.id = values.getAsLong(Events._ID) - // derived classes process SYNC1 etc. + this.syncId = values.getAsString(Events._SYNC_ID) + this.eTag = values.getAsString(COLUMN_ETAG) + this.scheduleTag = values.getAsString(COLUMN_SCHEDULE_TAG) + this.flags = values.getAsInteger(COLUMN_FLAGS) ?: 0 } /** * Creates a new object from an event which doesn't exist in the calendar storage yet. * @param event event that can be saved into the calendar storage */ - constructor(calendar: AndroidCalendar, event: Event) : this(calendar) { + constructor(calendar: AndroidCalendar<*>, event: Event, syncId: String?, eTag: String?, scheduleTag: String?, flags: Int) : this(calendar) { this.event = event + this.syncId = syncId + this.eTag = eTag + this.scheduleTag = scheduleTag + this.flags = flags } private var _event: Event? = null @@ -212,9 +193,7 @@ abstract class AndroidEvent( * * @param row values of an [Events] row, as returned by the calendar provider */ - @Suppress("UNUSED_VALUE") - @CallSuper - protected open fun populateEvent(row: ContentValues, groupScheduled: Boolean) { + protected fun populateEvent(row: ContentValues, groupScheduled: Boolean) { logger.log(Level.FINE, "Read event entity from calender provider", row) val event = requireNotNull(event) @@ -224,8 +203,7 @@ abstract class AndroidEvent( } val allDay = (row.getAsInteger(Events.ALL_DAY) ?: 0) != 0 - val tsStart = row.getAsLong(Events.DTSTART) - ?: throw LocalStorageException("Found event without DTSTART") + val tsStart = row.getAsLong(Events.DTSTART) ?: throw LocalStorageException("Found event without DTSTART") var tsEnd = row.getAsLong(Events.DTEND) var duration = // only use DURATION of DTEND is not defined @@ -338,6 +316,9 @@ abstract class AndroidEvent( } event.uid = row.getAsString(Events.UID_2445) + event.sequence = row.getAsInteger(COLUMN_SEQUENCE) + event.isOrganizer = row.getAsBoolean(Events.IS_ORGANIZER) + event.summary = row.getAsString(Events.TITLE) event.location = row.getAsString(Events.EVENT_LOCATION) event.description = row.getAsString(Events.DESCRIPTION) @@ -731,14 +712,14 @@ abstract class AndroidEvent( batch += CpoBuilder .newDelete(ExtendedProperties.CONTENT_URI.asSyncAdapter(calendar.account)) .withSelection( - "${ExtendedProperties.EVENT_ID}=? AND ${ExtendedProperties.NAME} IN (?,?,?,?)", - arrayOf( - existingId.toString(), - EXTNAME_CATEGORIES, - EXTNAME_ICAL_UID, // UID is stored in UID_2445, don't leave iCalUid rows in events that we have written - EXTNAME_URL, - UnknownProperty.CONTENT_ITEM_TYPE - ) + "${ExtendedProperties.EVENT_ID}=? AND ${ExtendedProperties.NAME} IN (?,?,?,?)", + arrayOf( + existingId.toString(), + EXTNAME_CATEGORIES, + EXTNAME_ICAL_UID, // UID is stored in UID_2445, don't leave iCalUid rows in events that we have written + EXTNAME_URL, + UnknownProperty.CONTENT_ITEM_TYPE + ) ) addOrUpdateRows(batch) @@ -748,6 +729,10 @@ abstract class AndroidEvent( } } + fun update(values: ContentValues) { + calendar.provider.update(eventSyncURI(), values, null, null) + } + /** * Deletes an existing event from the calendar storage. * @@ -771,19 +756,21 @@ abstract class AndroidEvent( protected fun deleteExceptions(batch: CalendarBatchOperation) { val existingId = requireNotNull(id) batch += CpoBuilder - .newDelete(Events.CONTENT_URI.asSyncAdapter(calendar.account)) - .withSelection("${Events.ORIGINAL_ID}=?", arrayOf(existingId.toString())) + .newDelete(Events.CONTENT_URI.asSyncAdapter(calendar.account)) + .withSelection("${Events.ORIGINAL_ID}=?", arrayOf(existingId.toString())) } /** - * Builds an Android [Events] row for a given ical4android [Event]. + * Builds an Android [Events] row for a given event. Takes information from + * + * - this [AndroidEvent] object: fields like calendar ID, sync ID, eTag etc, + * - the [event]: all other fields. * * @param recurrence event to be used as data source; *null*: use this AndroidEvent's main [event] as source * @param builder data row builder to be used */ - @CallSuper - protected open fun buildEvent(recurrence: Event?, builder: CpoBuilder) { + protected fun buildEvent(recurrence: Event?, builder: CpoBuilder) { val event = recurrence ?: requireNotNull(event) val dtStart = event.dtStart ?: throw InvalidCalendarException("Events must have DTSTART") @@ -803,8 +790,25 @@ abstract class AndroidEvent( - eventTimezone - a calendar_id */ + // object-level (AndroidEvent) fields builder .withValue(Events.CALENDAR_ID, calendar.id) - .withValue(Events.DTSTART, dtStart.date.time) + .withValue(Events.DIRTY, 0) // newly created event rows shall not be marked as dirty + .withValue(Events.DELETED, 0) // or deleted + .withValue(COLUMN_FLAGS, flags) + + if (recurrence == null) + builder.withValue(Events._SYNC_ID, syncId) + .withValue(COLUMN_ETAG, eTag) + .withValue(COLUMN_SCHEDULE_TAG, scheduleTag) + else + builder.withValue(Events.ORIGINAL_SYNC_ID, syncId) + + // UID, sequence + builder .withValue(Events.UID_2445, event.uid) + .withValue(COLUMN_SEQUENCE, event.sequence) + + // time fields + builder .withValue(Events.DTSTART, dtStart.date.time) .withValue(Events.ALL_DAY, if (allDay) 1 else 0) .withValue(Events.EVENT_TIMEZONE, AndroidTimeUtils.storageTzId(dtStart)) @@ -938,11 +942,12 @@ abstract class AndroidEvent( .withValue(Events.EXDATE, null) } - builder.withValue(Events.UID_2445, event.uid) + // text fields builder.withValue(Events.TITLE, event.summary) - builder.withValue(Events.EVENT_LOCATION, event.location) - builder.withValue(Events.DESCRIPTION, event.description) + .withValue(Events.EVENT_LOCATION, event.location) + .withValue(Events.DESCRIPTION, event.description) + // color val color = event.color if (color != null) { // set event color (if it's available for this account) @@ -1124,4 +1129,57 @@ abstract class AndroidEvent( @CallSuper override fun toString(): String = "AndroidEvent(calendar=$calendar, id=$id, event=$_event)" -} + + companion object { + + const val MUTATORS_SEPARATOR = ',' + + /** + * Custom sync column to store the last known ETag of an event. + */ + const val COLUMN_ETAG = Events.SYNC_DATA1 + + /** + * Custom sync column to store sync flags of an event. + */ + const val COLUMN_FLAGS = Events.SYNC_DATA2 + + /** + * Custom sync column to store the SEQUENCE of an event. + */ + const val COLUMN_SEQUENCE = Events.SYNC_DATA3 + + /** + * Custom sync column to store the Schedule-Tag of an event. + */ + const val COLUMN_SCHEDULE_TAG = Events.SYNC_DATA4 + + /** + * VEVENT CATEGORIES are stored as an extended property with this [ExtendedProperties.NAME]. + * + * The [ExtendedProperties.VALUE] format is the same as used by the AOSP Exchange ActiveSync adapter: + * the category values are stored as list, separated by [CATEGORIES_SEPARATOR]. (If a category + * value contains [CATEGORIES_SEPARATOR], [CATEGORIES_SEPARATOR] will be dropped.) + * + * Example: `Cat1\Cat2` + */ + const val EXTNAME_CATEGORIES = "categories" + const val CATEGORIES_SEPARATOR = '\\' + + /** + * Google Calendar uses an extended property called `iCalUid` for storing the event's UID, instead of the + * standard [Events.UID_2445]. + * + * @see GitHub Issue + */ + const val EXTNAME_ICAL_UID = "iCalUid" + + /** + * VEVENT URL is stored as an extended property with this [ExtendedProperties.NAME]. + * The URL is directly put into [ExtendedProperties.VALUE]. + */ + const val EXTNAME_URL = ContentResolver.CURSOR_ITEM_BASE_TYPE + "/vnd.ical4android.url" + + } + +} \ No newline at end of file diff --git a/lib/src/main/kotlin/at/bitfire/ical4android/Event.kt b/lib/src/main/kotlin/at/bitfire/ical4android/Event.kt index bdf156ef..ec12145b 100644 --- a/lib/src/main/kotlin/at/bitfire/ical4android/Event.kt +++ b/lib/src/main/kotlin/at/bitfire/ical4android/Event.kt @@ -56,6 +56,7 @@ import java.util.logging.Logger data class Event( override var uid: String? = null, override var sequence: Int? = null, + var isOrganizer: Boolean? = null, /** list of Calendar User Agents which have edited the event since last sync */ override var userAgents: LinkedList = LinkedList(), @@ -96,6 +97,137 @@ data class Event( val unknownProperties: LinkedList = LinkedList() ) : ICalendar() { + fun write(os: OutputStream) { + val ical = Calendar() + ical.properties += Version.VERSION_2_0 + ical.properties += prodId() + + val dtStart = dtStart ?: throw InvalidCalendarException("Won't generate event without start time") + + EventValidator.repair(this) // repair this event before creating the VEVENT + + // "main event" (without exceptions) + val components = ical.components + val mainEvent = toVEvent() + components += mainEvent + + // remember used time zones + val usedTimeZones = mutableSetOf() + dtStart.timeZone?.let(usedTimeZones::add) + dtEnd?.timeZone?.let(usedTimeZones::add) + + // recurrence exceptions + for (exception in exceptions) { + // exceptions must always have the same UID as the main event + exception.uid = uid + + val recurrenceId = exception.recurrenceId + if (recurrenceId == null) { + logger.warning("Ignoring exception without recurrenceId") + continue + } + + /* Exceptions must always have the same value type as DTSTART [RFC 5545 3.8.4.4]. + If this is not the case, we don't add the exception to the event because we're + strict in what we send (and servers may reject such a case). + */ + if (isDateTime(recurrenceId) != isDateTime(dtStart)) { + logger.warning("Ignoring exception $recurrenceId with other date type than dtStart: $dtStart") + continue + } + + // for simplicity and compatibility, rewrite date-time exceptions to the same time zone as DTSTART + if (isDateTime(recurrenceId) && recurrenceId.timeZone != dtStart.timeZone) { + logger.fine("Changing timezone of $recurrenceId to same time zone as dtStart: $dtStart") + recurrenceId.timeZone = dtStart.timeZone + } + + // create and add VEVENT for exception + val vException = exception.toVEvent() + components += vException + + // remember used time zones + exception.dtStart?.timeZone?.let(usedTimeZones::add) + exception.dtEnd?.timeZone?.let(usedTimeZones::add) + } + + // determine first dtStart (there may be exceptions with an earlier DTSTART that the main event) + val dtStarts = mutableListOf(dtStart.date) + dtStarts.addAll(exceptions.mapNotNull { it.dtStart?.date }) + val earliest = dtStarts.minOrNull() + // add VTIMEZONE components + for (tz in usedTimeZones) + ical.components += minifyVTimeZone(tz.vTimeZone, earliest) + + softValidate(ical) + CalendarOutputter(false).output(ical, os) + } + + /** + * Generates a VEvent representation of this event. + * + * @return generated VEvent + */ + private fun toVEvent(): VEvent { + val event = VEvent(/* generates DTSTAMP */) + val props = event.properties + props += Uid(uid) + + recurrenceId?.let { props += it } + sequence?.let { + if (it != 0) + props += Sequence(it) + } + + summary?.let { props += Summary(it) } + location?.let { props += Location(it) } + url?.let { props += Url(it) } + description?.let { props += Description(it) } + color?.let { props += Color(null, it.name) } + + dtStart?.let { props += it } + dtEnd?.let { props += it } + duration?.let { props += it } + + props.addAll(rRules) + props.addAll(rDates) + props.addAll(exRules) + props.addAll(exDates) + + classification?.let { props += it } + status?.let { props += it } + if (!opaque) + props += Transp.TRANSPARENT + + organizer?.let { props += it } + props.addAll(attendees) + + if (categories.isNotEmpty()) + props += Categories(TextList(categories.toTypedArray())) + props.addAll(unknownProperties) + + lastModified?.let { props += it } + + event.components.addAll(alarms) + + return event + } + + + val organizerEmail: String? + get() { + var email: String? = null + organizer?.let { organizer -> + val uri = organizer.calAddress + email = if (uri.scheme.equals("mailto", true)) + uri.schemeSpecificPart + else + organizer.getParameter(Parameter.EMAIL)?.value + } + return email + } + + companion object { private val logger @@ -242,136 +374,4 @@ data class Event( return e } } - - - fun write(os: OutputStream) { - val ical = Calendar() - ical.properties += Version.VERSION_2_0 - ical.properties += prodId() - - val dtStart = dtStart ?: throw InvalidCalendarException("Won't generate event without start time") - - EventValidator.repair(this) // repair this event before creating the VEVENT - - // "main event" (without exceptions) - val components = ical.components - val mainEvent = toVEvent() - components += mainEvent - - // remember used time zones - val usedTimeZones = mutableSetOf() - dtStart.timeZone?.let(usedTimeZones::add) - dtEnd?.timeZone?.let(usedTimeZones::add) - - // recurrence exceptions - for (exception in exceptions) { - // exceptions must always have the same UID as the main event - exception.uid = uid - - val recurrenceId = exception.recurrenceId - if (recurrenceId == null) { - logger.warning("Ignoring exception without recurrenceId") - continue - } - - /* Exceptions must always have the same value type as DTSTART [RFC 5545 3.8.4.4]. - If this is not the case, we don't add the exception to the event because we're - strict in what we send (and servers may reject such a case). - */ - if (isDateTime(recurrenceId) != isDateTime(dtStart)) { - logger.warning("Ignoring exception $recurrenceId with other date type than dtStart: $dtStart") - continue - } - - // for simplicity and compatibility, rewrite date-time exceptions to the same time zone as DTSTART - if (isDateTime(recurrenceId) && recurrenceId.timeZone != dtStart.timeZone) { - logger.fine("Changing timezone of $recurrenceId to same time zone as dtStart: $dtStart") - recurrenceId.timeZone = dtStart.timeZone - } - - // create and add VEVENT for exception - val vException = exception.toVEvent() - components += vException - - // remember used time zones - exception.dtStart?.timeZone?.let(usedTimeZones::add) - exception.dtEnd?.timeZone?.let(usedTimeZones::add) - } - - // determine first dtStart (there may be exceptions with an earlier DTSTART that the main event) - val dtStarts = mutableListOf(dtStart.date) - dtStarts.addAll(exceptions.mapNotNull { it.dtStart?.date }) - val earliest = dtStarts.minOrNull() - // add VTIMEZONE components - for (tz in usedTimeZones) - ical.components += minifyVTimeZone(tz.vTimeZone, earliest) - - softValidate(ical) - CalendarOutputter(false).output(ical, os) - } - - /** - * Generates a VEvent representation of this event. - * - * @return generated VEvent - */ - private fun toVEvent(): VEvent { - val event = VEvent(/* generates DTSTAMP */) - val props = event.properties - props += Uid(uid) - - recurrenceId?.let { props += it } - sequence?.let { - if (it != 0) - props += Sequence(it) - } - - summary?.let { props += Summary(it) } - location?.let { props += Location(it) } - url?.let { props += Url(it) } - description?.let { props += Description(it) } - color?.let { props += Color(null, it.name) } - - dtStart?.let { props += it } - dtEnd?.let { props += it } - duration?.let { props += it } - - props.addAll(rRules) - props.addAll(rDates) - props.addAll(exRules) - props.addAll(exDates) - - classification?.let { props += it } - status?.let { props += it } - if (!opaque) - props += Transp.TRANSPARENT - - organizer?.let { props += it } - props.addAll(attendees) - - if (categories.isNotEmpty()) - props += Categories(TextList(categories.toTypedArray())) - props.addAll(unknownProperties) - - lastModified?.let { props += it } - - event.components.addAll(alarms) - - return event - } - - - val organizerEmail: String? - get() { - var email: String? = null - organizer?.let { organizer -> - val uri = organizer.calAddress - email = if (uri.scheme.equals("mailto", true)) - uri.schemeSpecificPart - else - organizer.getParameter(Parameter.EMAIL)?.value - } - return email - } - -} +} \ No newline at end of file