| /* |
| * 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.calendarcommon.DateException; |
| import com.android.calendarcommon.EventRecurrence; |
| import com.android.calendarcommon.RecurrenceProcessor; |
| import com.android.calendarcommon.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<String, CalendarInstancesHelper.InstancesList> { |
| 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<ContentValues> { |
| } |
| |
| 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<String> 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); |
| } |
| } |