/* * Copyright (C) 2011 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.android.providers.calendar; import com.android.calendarcommon2.DateException; import com.android.calendarcommon2.Duration; import com.android.calendarcommon2.EventRecurrence; import com.android.calendarcommon2.RecurrenceProcessor; import com.android.calendarcommon2.RecurrenceSet; import com.android.providers.calendar.CalendarDatabaseHelper.Tables; import android.content.ContentValues; import android.database.Cursor; import android.database.DatabaseUtils; import android.database.sqlite.SQLiteDatabase; import android.database.sqlite.SQLiteQueryBuilder; import android.os.Debug; import android.provider.CalendarContract.Calendars; import android.provider.CalendarContract.Events; import android.provider.CalendarContract.Instances; import android.text.TextUtils; import android.text.format.Time; import android.util.Log; import android.util.TimeFormatException; import java.util.ArrayList; import java.util.HashMap; import java.util.Set; public class CalendarInstancesHelper { public static final class EventInstancesMap extends HashMap { public void add(String syncIdKey, ContentValues values) { CalendarInstancesHelper.InstancesList instances = get(syncIdKey); if (instances == null) { instances = new CalendarInstancesHelper.InstancesList(); put(syncIdKey, instances); } instances.add(values); } } public static final class InstancesList extends ArrayList { } private static final String TAG = "CalInstances"; private CalendarDatabaseHelper mDbHelper; private SQLiteDatabase mDb; private MetaData mMetaData; private CalendarCache mCalendarCache; private static final String SQL_WHERE_GET_EVENTS_ENTRIES = "((" + Events.DTSTART + " <= ? AND " + "(" + Events.LAST_DATE + " IS NULL OR " + Events.LAST_DATE + " >= ?)) OR " + "(" + Events.ORIGINAL_INSTANCE_TIME + " IS NOT NULL AND " + Events.ORIGINAL_INSTANCE_TIME + " <= ? AND " + Events.ORIGINAL_INSTANCE_TIME + " >= ?)) AND " + "(" + Calendars.SYNC_EVENTS + " != ?) AND " + "(" + Events.LAST_SYNCED + " = ?)"; /** * Determines the set of Events where the _id matches the first query argument, or the * originalId matches the second argument. Returns the _id field from the set of * Instances whose event_id field matches one of those events. */ private static final String SQL_WHERE_ID_FROM_INSTANCES_NOT_SYNCED = Instances._ID + " IN " + "(SELECT " + Tables.INSTANCES + "." + Instances._ID + " as _id" + " FROM " + Tables.INSTANCES + " INNER JOIN " + Tables.EVENTS + " ON (" + Tables.EVENTS + "." + Events._ID + "=" + Tables.INSTANCES + "." + Instances.EVENT_ID + ")" + " WHERE " + Tables.EVENTS + "." + Events._ID + "=? OR " + Tables.EVENTS + "." + Events.ORIGINAL_ID + "=?)"; /** * Determines the set of Events where the _sync_id matches the first query argument, or the * originalSyncId matches the second argument. Returns the _id field from the set of * Instances whose event_id field matches one of those events. */ private static final String SQL_WHERE_ID_FROM_INSTANCES_SYNCED = Instances._ID + " IN " + "(SELECT " + Tables.INSTANCES + "." + Instances._ID + " as _id" + " FROM " + Tables.INSTANCES + " INNER JOIN " + Tables.EVENTS + " ON (" + Tables.EVENTS + "." + Events._ID + "=" + Tables.INSTANCES + "." + Instances.EVENT_ID + ")" + " WHERE " + Tables.EVENTS + "." + Events._SYNC_ID + "=?" + " OR " + Tables.EVENTS + "." + Events.ORIGINAL_SYNC_ID + "=?)"; private static final String[] EXPAND_COLUMNS = new String[] { Events._ID, Events._SYNC_ID, Events.STATUS, Events.DTSTART, Events.DTEND, Events.EVENT_TIMEZONE, Events.RRULE, Events.RDATE, Events.EXRULE, Events.EXDATE, Events.DURATION, Events.ALL_DAY, Events.ORIGINAL_SYNC_ID, Events.ORIGINAL_INSTANCE_TIME, Events.CALENDAR_ID, Events.DELETED }; // To determine if a recurrence exception originally overlapped the // window, we need to assume a maximum duration, since we only know // the original start time. private static final int MAX_ASSUMED_DURATION = 7 * 24 * 60 * 60 * 1000; public CalendarInstancesHelper(CalendarDatabaseHelper calendarDbHelper, MetaData metaData) { mDbHelper = calendarDbHelper; mDb = mDbHelper.getWritableDatabase(); mMetaData = metaData; mCalendarCache = new CalendarCache(mDbHelper); } /** * Extract the value from the specifed row and column of the Events table. * * @param db The database to access. * @param rowId The Event's _id. * @param columnName The name of the column to access. * @return The value in string form. */ private static String getEventValue(SQLiteDatabase db, long rowId, String columnName) { String where = "SELECT " + columnName + " FROM " + Tables.EVENTS + " WHERE " + Events._ID + "=?"; return DatabaseUtils.stringForQuery(db, where, new String[] { String.valueOf(rowId) }); } /** * Perform instance expansion on the given entries. * * @param begin Window start (ms). * @param end Window end (ms). * @param localTimezone * @param entries The entries to process. */ protected void performInstanceExpansion(long begin, long end, String localTimezone, Cursor entries) { // TODO: this only knows how to work with events that have been synced with the server RecurrenceProcessor rp = new RecurrenceProcessor(); // Key into the instance values to hold the original event concatenated // with calendar id. final String ORIGINAL_EVENT_AND_CALENDAR = "ORIGINAL_EVENT_AND_CALENDAR"; int statusColumn = entries.getColumnIndex(Events.STATUS); int dtstartColumn = entries.getColumnIndex(Events.DTSTART); int dtendColumn = entries.getColumnIndex(Events.DTEND); int eventTimezoneColumn = entries.getColumnIndex(Events.EVENT_TIMEZONE); int durationColumn = entries.getColumnIndex(Events.DURATION); int rruleColumn = entries.getColumnIndex(Events.RRULE); int rdateColumn = entries.getColumnIndex(Events.RDATE); int exruleColumn = entries.getColumnIndex(Events.EXRULE); int exdateColumn = entries.getColumnIndex(Events.EXDATE); int allDayColumn = entries.getColumnIndex(Events.ALL_DAY); int idColumn = entries.getColumnIndex(Events._ID); int syncIdColumn = entries.getColumnIndex(Events._SYNC_ID); int originalEventColumn = entries.getColumnIndex(Events.ORIGINAL_SYNC_ID); int originalInstanceTimeColumn = entries.getColumnIndex(Events.ORIGINAL_INSTANCE_TIME); int calendarIdColumn = entries.getColumnIndex(Events.CALENDAR_ID); int deletedColumn = entries.getColumnIndex(Events.DELETED); ContentValues initialValues; CalendarInstancesHelper.EventInstancesMap instancesMap = new CalendarInstancesHelper.EventInstancesMap(); Duration duration = new Duration(); Time eventTime = new Time(); // Invariant: entries contains all events that affect the current // window. It consists of: // a) Individual events that fall in the window. These will be // displayed. // b) Recurrences that included the window. These will be displayed // if not canceled. // c) Recurrence exceptions that fall in the window. These will be // displayed if not cancellations. // d) Recurrence exceptions that modify an instance inside the // window (subject to 1 week assumption above), but are outside // the window. These will not be displayed. Cases c and d are // distinguished by the start / end time. while (entries.moveToNext()) { try { initialValues = null; boolean allDay = entries.getInt(allDayColumn) != 0; String eventTimezone = entries.getString(eventTimezoneColumn); if (allDay || TextUtils.isEmpty(eventTimezone)) { // in the events table, allDay events start at midnight. // this forces them to stay at midnight for all day events // TODO: check that this actually does the right thing. eventTimezone = Time.TIMEZONE_UTC; } long dtstartMillis = entries.getLong(dtstartColumn); Long eventId = Long.valueOf(entries.getLong(idColumn)); String durationStr = entries.getString(durationColumn); if (durationStr != null) { try { duration.parse(durationStr); } catch (DateException e) { if (Log.isLoggable(CalendarProvider2.TAG, Log.ERROR)) { Log.w(CalendarProvider2.TAG, "error parsing duration for event " + eventId + "'" + durationStr + "'", e); } duration.sign = 1; duration.weeks = 0; duration.days = 0; duration.hours = 0; duration.minutes = 0; duration.seconds = 0; durationStr = "+P0S"; } } String syncId = entries.getString(syncIdColumn); String originalEvent = entries.getString(originalEventColumn); long originalInstanceTimeMillis = -1; if (!entries.isNull(originalInstanceTimeColumn)) { originalInstanceTimeMillis= entries.getLong(originalInstanceTimeColumn); } int status = entries.getInt(statusColumn); boolean deleted = (entries.getInt(deletedColumn) != 0); String rruleStr = entries.getString(rruleColumn); String rdateStr = entries.getString(rdateColumn); String exruleStr = entries.getString(exruleColumn); String exdateStr = entries.getString(exdateColumn); long calendarId = entries.getLong(calendarIdColumn); // key into instancesMap String syncIdKey = CalendarInstancesHelper.getSyncIdKey(syncId, calendarId); RecurrenceSet recur = null; try { recur = new RecurrenceSet(rruleStr, rdateStr, exruleStr, exdateStr); } catch (EventRecurrence.InvalidFormatException e) { if (Log.isLoggable(CalendarProvider2.TAG, Log.ERROR)) { Log.w(CalendarProvider2.TAG, "Could not parse RRULE recurrence string: " + rruleStr, e); } continue; } if (null != recur && recur.hasRecurrence()) { // the event is repeating if (status == Events.STATUS_CANCELED) { // should not happen! if (Log.isLoggable(CalendarProvider2.TAG, Log.ERROR)) { Log.e(CalendarProvider2.TAG, "Found canceled recurring event in " + "Events table. Ignoring."); } continue; } if (deleted) { if (Log.isLoggable(CalendarProvider2.TAG, Log.DEBUG)) { Log.d(CalendarProvider2.TAG, "Found deleted recurring event in " + "Events table. Ignoring."); } continue; } // need to parse the event into a local calendar. eventTime.timezone = eventTimezone; eventTime.set(dtstartMillis); eventTime.allDay = allDay; if (durationStr == null) { // should not happen. if (Log.isLoggable(CalendarProvider2.TAG, Log.ERROR)) { Log.e(CalendarProvider2.TAG, "Repeating event has no duration -- " + "should not happen."); } if (allDay) { // set to one day. duration.sign = 1; duration.weeks = 0; duration.days = 1; duration.hours = 0; duration.minutes = 0; duration.seconds = 0; durationStr = "+P1D"; } else { // compute the duration from dtend, if we can. // otherwise, use 0s. duration.sign = 1; duration.weeks = 0; duration.days = 0; duration.hours = 0; duration.minutes = 0; if (!entries.isNull(dtendColumn)) { long dtendMillis = entries.getLong(dtendColumn); duration.seconds = (int) ((dtendMillis - dtstartMillis) / 1000); durationStr = "+P" + duration.seconds + "S"; } else { duration.seconds = 0; durationStr = "+P0S"; } } } long[] dates; dates = rp.expand(eventTime, recur, begin, end); // Initialize the "eventTime" timezone outside the loop. // This is used in computeTimezoneDependentFields(). if (allDay) { eventTime.timezone = Time.TIMEZONE_UTC; } else { eventTime.timezone = localTimezone; } long durationMillis = duration.getMillis(); for (long date : dates) { initialValues = new ContentValues(); initialValues.put(Instances.EVENT_ID, eventId); initialValues.put(Instances.BEGIN, date); long dtendMillis = date + durationMillis; initialValues.put(Instances.END, dtendMillis); CalendarInstancesHelper.computeTimezoneDependentFields(date, dtendMillis, eventTime, initialValues); instancesMap.add(syncIdKey, initialValues); } } else { // the event is not repeating initialValues = new ContentValues(); // if this event has an "original" field, then record // that we need to cancel the original event (we can't // do that here because the order of this loop isn't // defined) if (originalEvent != null && originalInstanceTimeMillis != -1) { // The ORIGINAL_EVENT_AND_CALENDAR holds the // calendar id concatenated with the ORIGINAL_EVENT to form // a unique key, matching the keys for instancesMap. initialValues.put(ORIGINAL_EVENT_AND_CALENDAR, CalendarInstancesHelper.getSyncIdKey(originalEvent, calendarId)); initialValues.put(Events.ORIGINAL_INSTANCE_TIME, originalInstanceTimeMillis); initialValues.put(Events.STATUS, status); } long dtendMillis = dtstartMillis; if (durationStr == null) { if (!entries.isNull(dtendColumn)) { dtendMillis = entries.getLong(dtendColumn); } } else { dtendMillis = duration.addTo(dtstartMillis); } // this non-recurring event might be a recurrence exception that doesn't // actually fall within our expansion window, but instead was selected // so we can correctly cancel expanded recurrence instances below. do not // add events to the instances map if they don't actually fall within our // expansion window. if ((dtendMillis < begin) || (dtstartMillis > end)) { if (originalEvent != null && originalInstanceTimeMillis != -1) { initialValues.put(Events.STATUS, Events.STATUS_CANCELED); } else { if (Log.isLoggable(CalendarProvider2.TAG, Log.ERROR)) { Log.w(CalendarProvider2.TAG, "Unexpected event outside window: " + syncId); } continue; } } initialValues.put(Instances.EVENT_ID, eventId); initialValues.put(Instances.BEGIN, dtstartMillis); initialValues.put(Instances.END, dtendMillis); // we temporarily store the DELETED status (will be cleaned later) initialValues.put(Events.DELETED, deleted); if (allDay) { eventTime.timezone = Time.TIMEZONE_UTC; } else { eventTime.timezone = localTimezone; } CalendarInstancesHelper.computeTimezoneDependentFields(dtstartMillis, dtendMillis, eventTime, initialValues); instancesMap.add(syncIdKey, initialValues); } } catch (DateException e) { if (Log.isLoggable(CalendarProvider2.TAG, Log.ERROR)) { Log.w(CalendarProvider2.TAG, "RecurrenceProcessor error ", e); } } catch (TimeFormatException e) { if (Log.isLoggable(CalendarProvider2.TAG, Log.ERROR)) { Log.w(CalendarProvider2.TAG, "RecurrenceProcessor error ", e); } } } // Invariant: instancesMap contains all instances that affect the // window, indexed by original sync id concatenated with calendar id. // It consists of: // a) Individual events that fall in the window. They have: // EVENT_ID, BEGIN, END // b) Instances of recurrences that fall in the window. They may // be subject to exceptions. They have: // EVENT_ID, BEGIN, END // c) Exceptions that fall in the window. They have: // ORIGINAL_EVENT_AND_CALENDAR, ORIGINAL_INSTANCE_TIME, STATUS (since they can // be a modification or cancellation), EVENT_ID, BEGIN, END // d) Recurrence exceptions that modify an instance inside the // window but fall outside the window. They have: // ORIGINAL_EVENT_AND_CALENDAR, ORIGINAL_INSTANCE_TIME, STATUS = // STATUS_CANCELED, EVENT_ID, BEGIN, END // First, delete the original instances corresponding to recurrence // exceptions. We do this by iterating over the list and for each // recurrence exception, we search the list for an instance with a // matching "original instance time". If we find such an instance, // we remove it from the list. If we don't find such an instance // then we cancel the recurrence exception. Set keys = instancesMap.keySet(); for (String syncIdKey : keys) { CalendarInstancesHelper.InstancesList list = instancesMap.get(syncIdKey); for (ContentValues values : list) { // If this instance is not a recurrence exception, then // skip it. if (!values.containsKey(ORIGINAL_EVENT_AND_CALENDAR)) { continue; } String originalEventPlusCalendar = values.getAsString(ORIGINAL_EVENT_AND_CALENDAR); long originalTime = values.getAsLong(Events.ORIGINAL_INSTANCE_TIME); CalendarInstancesHelper.InstancesList originalList = instancesMap .get(originalEventPlusCalendar); if (originalList == null) { // The original recurrence is not present, so don't try canceling it. continue; } // Search the original event for a matching original // instance time. If there is a matching one, then remove // the original one. We do this both for exceptions that // change the original instance as well as for exceptions // that delete the original instance. for (int num = originalList.size() - 1; num >= 0; num--) { ContentValues originalValues = originalList.get(num); long beginTime = originalValues.getAsLong(Instances.BEGIN); if (beginTime == originalTime) { // We found the original instance, so remove it. originalList.remove(num); } } } } // Invariant: instancesMap contains filtered instances. // It consists of: // a) Individual events that fall in the window. // b) Instances of recurrences that fall in the window and have not // been subject to exceptions. // c) Exceptions that fall in the window. They will have // STATUS_CANCELED if they are cancellations. // d) Recurrence exceptions that modify an instance inside the // window but fall outside the window. These are STATUS_CANCELED. // Now do the inserts. Since the db lock is held when this method is executed, // this will be done in a transaction. // NOTE: if there is lock contention (e.g., a sync is trying to merge into the db // while the calendar app is trying to query the db (expanding instances)), we will // not be "polite" and yield the lock until we're done. This will favor local query // operations over sync/write operations. for (String syncIdKey : keys) { CalendarInstancesHelper.InstancesList list = instancesMap.get(syncIdKey); for (ContentValues values : list) { // If this instance was cancelled or deleted then don't create a new // instance. Integer status = values.getAsInteger(Events.STATUS); boolean deleted = values.containsKey(Events.DELETED) ? values.getAsBoolean(Events.DELETED) : false; if ((status != null && status == Events.STATUS_CANCELED) || deleted) { continue; } // We remove this useless key (not valid in the context of Instances table) values.remove(Events.DELETED); // Remove these fields before inserting a new instance values.remove(ORIGINAL_EVENT_AND_CALENDAR); values.remove(Events.ORIGINAL_INSTANCE_TIME); values.remove(Events.STATUS); mDbHelper.instancesReplace(values); } } } /** * Make instances for the given range. */ protected void expandInstanceRangeLocked(long begin, long end, String localTimezone) { if (CalendarProvider2.PROFILE) { Debug.startMethodTracing("expandInstanceRangeLocked"); } if (Log.isLoggable(TAG, Log.VERBOSE)) { Log.v(TAG, "Expanding events between " + begin + " and " + end); } Cursor entries = getEntries(begin, end); try { performInstanceExpansion(begin, end, localTimezone, entries); } finally { if (entries != null) { entries.close(); } } if (CalendarProvider2.PROFILE) { Debug.stopMethodTracing(); } } /** * Get all entries affecting the given window. * * @param begin Window start (ms). * @param end Window end (ms). * @return Cursor for the entries; caller must close it. */ private Cursor getEntries(long begin, long end) { SQLiteQueryBuilder qb = new SQLiteQueryBuilder(); qb.setTables(CalendarDatabaseHelper.Views.EVENTS); qb.setProjectionMap(CalendarProvider2.sEventsProjectionMap); String beginString = String.valueOf(begin); String endString = String.valueOf(end); // grab recurrence exceptions that fall outside our expansion window but // modify // recurrences that do fall within our window. we won't insert these // into the output // set of instances, but instead will just add them to our cancellations // list, so we // can cancel the correct recurrence expansion instances. // we don't have originalInstanceDuration or end time. for now, assume // the original // instance lasts no longer than 1 week. // also filter with syncable state (we dont want the entries from a non // syncable account) // also filter with last_synced=0 so we don't expand events that were // dup'ed for partial updates. // TODO: compute the originalInstanceEndTime or get this from the // server. qb.appendWhere(SQL_WHERE_GET_EVENTS_ENTRIES); String selectionArgs[] = new String[] { endString, beginString, endString, String.valueOf(begin - MAX_ASSUMED_DURATION), "0", // Calendars.SYNC_EVENTS "0", // Events.LAST_SYNCED }; Cursor c = qb.query(mDb, EXPAND_COLUMNS, null /* selection */, selectionArgs, null /* groupBy */, null /* having */, null /* sortOrder */); if (Log.isLoggable(TAG, Log.VERBOSE)) { Log.v(TAG, "Instance expansion: got " + c.getCount() + " entries"); } return c; } /** * Updates the instances table when an event is added or updated. * * @param values The new values of the event. * @param rowId The database row id of the event. * @param newEvent true if the event is new. * @param db The database */ public void updateInstancesLocked(ContentValues values, long rowId, boolean newEvent, SQLiteDatabase db) { /* * This may be a recurring event (has an RRULE or RDATE), an exception to a recurring * event (has ORIGINAL_ID or ORIGINAL_SYNC_ID), or a regular event. Recurring events * and exceptions require additional handling. * * If this is not a new event, it may already have entries in Instances, so we want * to delete those before we do any additional work. */ // If there are no expanded Instances, then return. MetaData.Fields fields = mMetaData.getFieldsLocked(); if (fields.maxInstance == 0) { return; } Long dtstartMillis = values.getAsLong(Events.DTSTART); if (dtstartMillis == null) { if (newEvent) { // must be present for a new event. throw new RuntimeException("DTSTART missing."); } if (Log.isLoggable(TAG, Log.VERBOSE)) { Log.v(TAG, "Missing DTSTART. No need to update instance."); } return; } if (!newEvent) { // Want to do this for regular event, recurrence, or exception. // For recurrence or exception, more deletion may happen below if we // do an instance expansion. This deletion will suffice if the // exception // is moved outside the window, for instance. db.delete(Tables.INSTANCES, Instances.EVENT_ID + "=?", new String[] { String.valueOf(rowId) }); } String rrule = values.getAsString(Events.RRULE); String rdate = values.getAsString(Events.RDATE); String originalId = values.getAsString(Events.ORIGINAL_ID); String originalSyncId = values.getAsString(Events.ORIGINAL_SYNC_ID); if (CalendarProvider2.isRecurrenceEvent(rrule, rdate, originalId, originalSyncId)) { Long lastDateMillis = values.getAsLong(Events.LAST_DATE); Long originalInstanceTime = values.getAsLong(Events.ORIGINAL_INSTANCE_TIME); // The recurrence or exception needs to be (re-)expanded if: // a) Exception or recurrence that falls inside window boolean insideWindow = dtstartMillis <= fields.maxInstance && (lastDateMillis == null || lastDateMillis >= fields.minInstance); // b) Exception that affects instance inside window // These conditions match the query in getEntries // See getEntries comment for explanation of subtracting 1 week. boolean affectsWindow = originalInstanceTime != null && originalInstanceTime <= fields.maxInstance && originalInstanceTime >= fields.minInstance - MAX_ASSUMED_DURATION; if (CalendarProvider2.DEBUG_INSTANCES) { Log.d(TAG + "-i", "Recurrence: inside=" + insideWindow + ", affects=" + affectsWindow); } if (insideWindow || affectsWindow) { updateRecurrenceInstancesLocked(values, rowId, db); } // TODO: an exception creation or update could be optimized by // updating just the affected instances, instead of regenerating // the recurrence. return; } Long dtendMillis = values.getAsLong(Events.DTEND); if (dtendMillis == null) { dtendMillis = dtstartMillis; } // if the event is in the expanded range, insert // into the instances table. // TODO: deal with durations. currently, durations are only used in // recurrences. if (dtstartMillis <= fields.maxInstance && dtendMillis >= fields.minInstance) { ContentValues instanceValues = new ContentValues(); instanceValues.put(Instances.EVENT_ID, rowId); instanceValues.put(Instances.BEGIN, dtstartMillis); instanceValues.put(Instances.END, dtendMillis); boolean allDay = false; Integer allDayInteger = values.getAsInteger(Events.ALL_DAY); if (allDayInteger != null) { allDay = allDayInteger != 0; } // Update the timezone-dependent fields. Time local = new Time(); if (allDay) { local.timezone = Time.TIMEZONE_UTC; } else { local.timezone = fields.timezone; } CalendarInstancesHelper.computeTimezoneDependentFields(dtstartMillis, dtendMillis, local, instanceValues); mDbHelper.instancesInsert(instanceValues); } } /** * Do incremental Instances update of a recurrence or recurrence exception. * This method does performInstanceExpansion on just the modified * recurrence, to avoid the overhead of recomputing the entire instance * table. * * @param values The new values of the event. * @param rowId The database row id of the event. * @param db The database */ private void updateRecurrenceInstancesLocked(ContentValues values, long rowId, SQLiteDatabase db) { /* * There are two categories of event that "rowId" may refer to: * (1) Recurrence event. * (2) Exception to recurrence event. Has non-empty originalId (if it originated * locally), originalSyncId (if it originated from the server), or both (if * it's fully synchronized). * * Exceptions may arrive from the server before the recurrence event, which means: * - We could find an originalSyncId but a lookup on originalSyncId could fail (in * which case we can just ignore the exception for now). * - There may be a brief period between the time we receive a recurrence and the * time we set originalId in related exceptions where originalSyncId is the only * way to find exceptions for a recurrence. Thus, an empty originalId field may * not be used to decide if an event is an exception. */ MetaData.Fields fields = mMetaData.getFieldsLocked(); String instancesTimezone = mCalendarCache.readTimezoneInstances(); // Get the originalSyncId. If it's not in "values", check the database. String originalSyncId = values.getAsString(Events.ORIGINAL_SYNC_ID); if (originalSyncId == null) { originalSyncId = getEventValue(db, rowId, Events.ORIGINAL_SYNC_ID); } String recurrenceSyncId; if (originalSyncId != null) { // This event is an exception; set recurrenceSyncId to the original. recurrenceSyncId = originalSyncId; } else { // This could be a recurrence or an exception. If it has been synced with the // server we can get the _sync_id and know for certain that it's a recurrence. // If not, we'll deal with it below. recurrenceSyncId = values.getAsString(Events._SYNC_ID); if (recurrenceSyncId == null) { // Not in "values", check the database. recurrenceSyncId = getEventValue(db, rowId, Events._SYNC_ID); } } // Clear out old instances int delCount; if (recurrenceSyncId == null) { // We're creating or updating a recurrence or exception that hasn't been to the // server. If this is a recurrence event, the event ID is simply the rowId. If // it's an exception, we will find the value in the originalId field. String originalId = values.getAsString(Events.ORIGINAL_ID); if (originalId == null) { // Not in "values", check the database. originalId = getEventValue(db, rowId, Events.ORIGINAL_ID); } String recurrenceId; if (originalId != null) { // This event is an exception; set recurrenceId to the original. recurrenceId = originalId; } else { // This event is a recurrence, so we just use the ID that was passed in. recurrenceId = String.valueOf(rowId); } // Delete Instances entries for this Event (_id == recurrenceId) and for exceptions // to this Event (originalId == recurrenceId). String where = SQL_WHERE_ID_FROM_INSTANCES_NOT_SYNCED; delCount = db.delete(Tables.INSTANCES, where, new String[] { recurrenceId, recurrenceId }); } else { // We're creating or updating a recurrence or exception that has been synced with // the server. Delete Instances entries for this Event (_sync_id == recurrenceSyncId) // and for exceptions to this Event (originalSyncId == recurrenceSyncId). String where = SQL_WHERE_ID_FROM_INSTANCES_SYNCED; delCount = db.delete(Tables.INSTANCES, where, new String[] { recurrenceSyncId, recurrenceSyncId }); } //Log.d(TAG, "Recurrence: deleted " + delCount + " instances"); //dumpInstancesTable(db); // Now do instance expansion // TODO: passing "rowId" is wrong if this is an exception - need originalId then Cursor entries = getRelevantRecurrenceEntries(recurrenceSyncId, rowId); try { performInstanceExpansion(fields.minInstance, fields.maxInstance, instancesTimezone, entries); } finally { if (entries != null) { entries.close(); } } } /** * Determines the recurrence entries associated with a particular * recurrence. This set is the base recurrence and any exception. Normally * the entries are indicated by the sync id of the base recurrence (which is * the originalSyncId in the exceptions). However, a complication is that a * recurrence may not yet have a sync id. In that case, the recurrence is * specified by the rowId. * * @param recurrenceSyncId The sync id of the base recurrence, or null. * @param rowId The row id of the base recurrence. * @return the relevant entries. */ private Cursor getRelevantRecurrenceEntries(String recurrenceSyncId, long rowId) { SQLiteQueryBuilder qb = new SQLiteQueryBuilder(); qb.setTables(CalendarDatabaseHelper.Views.EVENTS); qb.setProjectionMap(CalendarProvider2.sEventsProjectionMap); String selectionArgs[]; if (recurrenceSyncId == null) { String where = CalendarProvider2.SQL_WHERE_ID; qb.appendWhere(where); selectionArgs = new String[] { String.valueOf(rowId) }; } else { // don't expand events that were dup'ed for partial updates String where = "(" + Events._SYNC_ID + "=? OR " + Events.ORIGINAL_SYNC_ID + "=?) AND " + Events.LAST_SYNCED + " = ?"; qb.appendWhere(where); selectionArgs = new String[] { recurrenceSyncId, recurrenceSyncId, "0", // Events.LAST_SYNCED }; } if (Log.isLoggable(TAG, Log.VERBOSE)) { Log.v(TAG, "Retrieving events to expand: " + qb.toString()); } return qb.query(mDb, EXPAND_COLUMNS, null /* selection */, selectionArgs, null /* groupBy */, null /* having */, null /* sortOrder */); } /** * Generates a unique key from the syncId and calendarId. The purpose of * this is to prevent collisions if two different calendars use the same * sync id. This can happen if a Google calendar is accessed by two * different accounts, or with Exchange, where ids are not unique between * calendars. * * @param syncId Id for the event * @param calendarId Id for the calendar * @return key */ static String getSyncIdKey(String syncId, long calendarId) { return calendarId + ":" + syncId; } /** * Computes the timezone-dependent fields of an instance of an event and * updates the "values" map to contain those fields. * * @param begin the start time of the instance (in UTC milliseconds) * @param end the end time of the instance (in UTC milliseconds) * @param local a Time object with the timezone set to the local timezone * @param values a map that will contain the timezone-dependent fields */ static void computeTimezoneDependentFields(long begin, long end, Time local, ContentValues values) { local.set(begin); int startDay = Time.getJulianDay(begin, local.gmtoff); int startMinute = local.hour * 60 + local.minute; local.set(end); int endDay = Time.getJulianDay(end, local.gmtoff); int endMinute = local.hour * 60 + local.minute; // Special case for midnight, which has endMinute == 0. Change // that to +24 hours on the previous day to make everything simpler. // Exception: if start and end minute are both 0 on the same day, // then leave endMinute alone. if (endMinute == 0 && endDay > startDay) { endMinute = 24 * 60; endDay -= 1; } values.put(Instances.START_DAY, startDay); values.put(Instances.END_DAY, endDay); values.put(Instances.START_MINUTE, startMinute); values.put(Instances.END_MINUTE, endMinute); } /** * Dumps the contents of the Instances table to the log file. */ private static void dumpInstancesTable(SQLiteDatabase db) { Cursor cursor = db.query(Tables.INSTANCES, null, null, null, null, null, null); DatabaseUtils.dumpCursor(cursor); if (cursor != null) { cursor.close(); } } }