/* * Copyright © 2013 – 2015 Ricki Hirner (bitfire web engineering). * All rights reserved. This program and the accompanying materials * are made available under the terms of the GNU Public License v3.0 * which accompanies this distribution, and is available at * http://www.gnu.org/licenses/gpl.html */ package com.etesync.syncadapter.resource import android.accounts.Account import android.content.ContentProviderClient import android.content.ContentUris import android.content.ContentValues import android.net.Uri import android.os.RemoteException import android.provider.CalendarContract import android.provider.CalendarContract.* import at.bitfire.ical4android.* import com.etebase.client.CollectionAccessLevel import com.etesync.syncadapter.CachedCollection import com.etesync.syncadapter.log.Logger import com.etesync.syncadapter.model.JournalEntity import com.etesync.syncadapter.resource.LocalEvent.Companion.COLUMN_UID import org.apache.commons.lang3.StringUtils import java.util.* import java.util.logging.Level class LocalCalendar private constructor( account: Account, provider: ContentProviderClient, id: Long ): AndroidCalendar(account, provider, LocalEvent.Factory, id), LocalCollection { companion object { val defaultColor = -0x743cb6 // light green 500 - should be "8BC349"? fun parseColor(color_: String?): Int { if (color_.isNullOrBlank()) { return defaultColor } val color = color_.replaceFirst("^#".toRegex(), "") if (color.length == 8) { return (color.substring(0, 6).toLong(16) or (color.substring(6, 8).toLong(16) shl 24)).toInt() } else if (color.length == 6) { return (color.toLong(16) or (0xFF000000)).toInt() } else { return defaultColor } } val COLUMN_CTAG = Calendars.CAL_SYNC1 fun create(account: Account, provider: ContentProviderClient, journalEntity: JournalEntity): Uri { val values = valuesFromCollectionInfo(journalEntity, true) // ACCOUNT_NAME and ACCOUNT_TYPE are required (see docs)! If it's missing, other apps will crash. values.put(Calendars.ACCOUNT_NAME, account.name) values.put(Calendars.ACCOUNT_TYPE, account.type) values.put(Calendars.OWNER_ACCOUNT, account.name) // flag as visible & synchronizable at creation, might be changed by user at any time values.put(Calendars.VISIBLE, 1) values.put(Calendars.SYNC_EVENTS, 1) return AndroidCalendar.create(account, provider, values) } fun create(account: Account, provider: ContentProviderClient, cachedCollection: CachedCollection): Uri { val values = valuesFromCachedCollection(cachedCollection, true) // ACCOUNT_NAME and ACCOUNT_TYPE are required (see docs)! If it's missing, other apps will crash. values.put(Calendars.ACCOUNT_NAME, account.name) values.put(Calendars.ACCOUNT_TYPE, account.type) values.put(Calendars.OWNER_ACCOUNT, account.name) // flag as visible & synchronizable at creation, might be changed by user at any time values.put(Calendars.VISIBLE, 1) values.put(Calendars.SYNC_EVENTS, 1) return AndroidCalendar.create(account, provider, values) } fun findByName(account: Account, provider: ContentProviderClient, factory: Factory, name: String): LocalCalendar? = AndroidCalendar.find(account, provider, factory, Calendars.NAME + "==?", arrayOf(name)).firstOrNull() private fun valuesFromCollectionInfo(journalEntity: JournalEntity, withColor: Boolean): ContentValues { val info = journalEntity.info val values = ContentValues() values.put(Calendars.NAME, info.uid) values.put(Calendars.CALENDAR_DISPLAY_NAME, info.displayName) if (withColor) values.put(Calendars.CALENDAR_COLOR, if (info.color != null) info.color else defaultColor) if (journalEntity.isReadOnly) values.put(Calendars.CALENDAR_ACCESS_LEVEL, Calendars.CAL_ACCESS_READ) else { values.put(Calendars.CALENDAR_ACCESS_LEVEL, Calendars.CAL_ACCESS_OWNER) values.put(Calendars.CAN_MODIFY_TIME_ZONE, 1) values.put(Calendars.CAN_ORGANIZER_RESPOND, 1) } info.timeZone?.let { tzData -> try { val timeZone = DateUtils.parseVTimeZone(tzData) timeZone.timeZoneId?.let { tzId -> values.put(Calendars.CALENDAR_TIME_ZONE, DateUtils.findAndroidTimezoneID(tzId.value)) } } catch(e: IllegalArgumentException) { Logger.log.log(Level.WARNING, "Couldn't parse calendar default time zone", e) } } values.put(Calendars.ALLOWED_REMINDERS, Reminders.METHOD_ALERT) values.put(Calendars.ALLOWED_AVAILABILITY, StringUtils.join(intArrayOf(Reminders.AVAILABILITY_TENTATIVE, Reminders.AVAILABILITY_FREE, Reminders.AVAILABILITY_BUSY), ",")) values.put(Calendars.ALLOWED_ATTENDEE_TYPES, StringUtils.join(intArrayOf(CalendarContract.Attendees.TYPE_OPTIONAL, CalendarContract.Attendees.TYPE_REQUIRED, CalendarContract.Attendees.TYPE_RESOURCE), ", ")) return values } private fun valuesFromCachedCollection(cachedCollection: CachedCollection, withColor: Boolean): ContentValues { val values = ContentValues() val col = cachedCollection.col val meta = cachedCollection.meta values.put(Calendars.NAME, col.uid) values.put(Calendars.CALENDAR_DISPLAY_NAME, meta.name) if (withColor) values.put(Calendars.CALENDAR_COLOR, parseColor(meta.color)) if (col.accessLevel == CollectionAccessLevel.ReadOnly) values.put(Calendars.CALENDAR_ACCESS_LEVEL, Calendars.CAL_ACCESS_READ) else { values.put(Calendars.CALENDAR_ACCESS_LEVEL, Calendars.CAL_ACCESS_OWNER) values.put(Calendars.CAN_MODIFY_TIME_ZONE, 1) values.put(Calendars.CAN_ORGANIZER_RESPOND, 1) } values.put(Calendars.ALLOWED_REMINDERS, Reminders.METHOD_ALERT) values.put(Calendars.ALLOWED_AVAILABILITY, StringUtils.join(intArrayOf(Reminders.AVAILABILITY_TENTATIVE, Reminders.AVAILABILITY_FREE, Reminders.AVAILABILITY_BUSY), ",")) values.put(Calendars.ALLOWED_ATTENDEE_TYPES, StringUtils.join(intArrayOf(CalendarContract.Attendees.TYPE_OPTIONAL, CalendarContract.Attendees.TYPE_REQUIRED, CalendarContract.Attendees.TYPE_RESOURCE), ", ")) return values } } override val url: String? get() = name fun update(journalEntity: JournalEntity, updateColor: Boolean) = update(valuesFromCollectionInfo(journalEntity, updateColor)) fun update(cachedCollection: CachedCollection, updateColor: Boolean) = update(valuesFromCachedCollection(cachedCollection, updateColor)) override fun findDeleted() = queryEvents("${Events.DELETED}!=0 AND ${Events.ORIGINAL_ID} IS NULL", null) override fun findDirty(limit: Int?): List { val dirty = LinkedList() val sortOrder = if (limit != null) "${Events._ID} ASC LIMIT $limit" else null // get dirty events which are required to have an increased SEQUENCE value for (localEvent in queryEvents("${Events.DIRTY}!=0 AND ${Events.DELETED}==0 AND ${Events.ORIGINAL_ID} IS NULL", null, sortOrder)) { val event = localEvent.event!! val sequence = event.sequence if (event.sequence == null) // sequence has not been assigned yet (i.e. this event was just locally created) event.sequence = 0 else if (localEvent.weAreOrganizer) event.sequence = sequence!! + 1 dirty += localEvent } return dirty } override fun findWithoutFileName(): List = queryEvents(Events._SYNC_ID + " IS NULL AND " + Events.ORIGINAL_ID + " IS NULL", null) override fun findAll(): List = queryEvents(null, null) override fun findByUid(uid: String): LocalEvent? = queryEvents(COLUMN_UID + " =? ", arrayOf(uid)).firstOrNull() override fun findByFilename(filename: String): LocalEvent? = queryEvents(Events._SYNC_ID + " =? ", arrayOf(filename)).firstOrNull() fun processDirtyExceptions() { // process deleted exceptions Logger.log.info("Processing deleted exceptions") try { val cursor = provider.query( syncAdapterURI(Events.CONTENT_URI), arrayOf(Events._ID, Events.ORIGINAL_ID, LocalEvent.COLUMN_SEQUENCE), Events.DELETED + "!=0 AND " + Events.ORIGINAL_ID + " IS NOT NULL", null, null) while (cursor != null && cursor.moveToNext()) { Logger.log.fine("Found deleted exception, removing; then re-schuling original event") val id = cursor.getLong(0) // can't be null (by definition) val originalID = cursor.getLong(1) // can't be null (by query) // get original event's SEQUENCE val cursor2 = provider.query( syncAdapterURI(ContentUris.withAppendedId(Events.CONTENT_URI, originalID)), arrayOf(LocalEvent.COLUMN_SEQUENCE), null, null, null) val originalSequence = if (cursor2 == null || cursor2.isNull(0)) 0 else cursor2.getInt(0) cursor2!!.close() val batch = BatchOperation(provider) // re-schedule original event and set it to DIRTY batch.enqueue( BatchOperation.CpoBuilder.newUpdate(syncAdapterURI(ContentUris.withAppendedId(Events.CONTENT_URI, originalID))) .withValue(LocalEvent.COLUMN_SEQUENCE, originalSequence + 1) .withValue(Events.DIRTY, 1) ) // remove exception batch.enqueue( BatchOperation.CpoBuilder.newDelete(syncAdapterURI(ContentUris.withAppendedId(Events.CONTENT_URI, id))) ) batch.commit() } cursor!!.close() } catch (e: RemoteException) { throw CalendarStorageException("Couldn't process locally modified exception", e) } // process dirty exceptions Logger.log.info("Processing dirty exceptions") try { val cursor = provider.query( syncAdapterURI(Events.CONTENT_URI), arrayOf(Events._ID, Events.ORIGINAL_ID, LocalEvent.COLUMN_SEQUENCE), Events.DIRTY + "!=0 AND " + Events.ORIGINAL_ID + " IS NOT NULL", null, null) while (cursor != null && cursor.moveToNext()) { Logger.log.fine("Found dirty exception, increasing SEQUENCE to re-schedule") val id = cursor.getLong(0) // can't be null (by definition) val originalID = cursor.getLong(1) // can't be null (by query) val sequence = if (cursor.isNull(2)) 0 else cursor.getInt(2) val batch = BatchOperation(provider) // original event to DIRTY batch.enqueue(BatchOperation.CpoBuilder.newUpdate(syncAdapterURI(ContentUris.withAppendedId(Events.CONTENT_URI, originalID))) .withValue(Events.DIRTY, 1) ) // increase SEQUENCE and set DIRTY to 0 batch.enqueue(BatchOperation.CpoBuilder.newUpdate(syncAdapterURI(ContentUris.withAppendedId(Events.CONTENT_URI, id))) .withValue(LocalEvent.COLUMN_SEQUENCE, sequence + 1) .withValue(Events.DIRTY, 0) ) batch.commit() } cursor!!.close() } catch (e: RemoteException) { throw CalendarStorageException("Couldn't process locally modified exception", e) } } override fun count(): Long { try { val cursor = provider.query( syncAdapterURI(Events.CONTENT_URI), null, Events.CALENDAR_ID + "=?", arrayOf(id.toString()), null) try { return cursor?.count?.toLong()!! } finally { cursor?.close() } } catch (e: RemoteException) { throw CalendarStorageException("Couldn't query calendar events", e) } } /** Fix all of the etags of all of the non-dirty events to be non-null. * Currently set to all ones.. */ @Throws(CalendarStorageException::class) fun fixEtags() { val newEtag = "1111111111111111111111111111111111111111111111111111111111111111" val where = Events.CALENDAR_ID + "=? AND " + Events.DIRTY + "=0 AND " + LocalEvent.COLUMN_ETAG + " IS NULL" val whereArgs = arrayOf(id.toString()) val values = ContentValues(1) values.put(LocalEvent.COLUMN_ETAG, newEtag) try { val fixed = provider.update(syncAdapterURI(Events.CONTENT_URI), values, where, whereArgs) Logger.log.info("Fixed entries: " + fixed.toString()) } catch (e: RemoteException) { throw CalendarStorageException("Couldn't fix etags", e) } } object Factory: AndroidCalendarFactory { override fun newInstance(account: Account, provider: ContentProviderClient, id: Long) = LocalCalendar(account, provider, id) } }