CalendarInstancesHelper.java revision f5930ab5c4c0fe728cb8fdf923d482b4f272eb1f
1/*
2 * Copyright (C) 2011 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 *      http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17package com.android.providers.calendar;
18
19import com.android.providers.calendar.CalendarDatabaseHelper.Tables;
20import android.content.ContentValues;
21import android.database.Cursor;
22import android.database.DatabaseUtils;
23import android.database.sqlite.SQLiteDatabase;
24import android.database.sqlite.SQLiteQueryBuilder;
25import android.os.Debug;
26import android.pim.EventRecurrence;
27import android.pim.RecurrenceSet;
28import android.provider.BaseColumns;
29import android.provider.Calendar.Calendars;
30import android.provider.Calendar.Events;
31import android.provider.Calendar.Instances;
32import android.text.TextUtils;
33import android.text.format.Time;
34import android.util.Log;
35import android.util.TimeFormatException;
36
37import java.util.ArrayList;
38import java.util.HashMap;
39import java.util.Set;
40
41public class CalendarInstancesHelper {
42    public static final class EventInstancesMap extends
43            HashMap<String, CalendarInstancesHelper.InstancesList> {
44        public void add(String syncIdKey, ContentValues values) {
45            CalendarInstancesHelper.InstancesList instances = get(syncIdKey);
46            if (instances == null) {
47                instances = new CalendarInstancesHelper.InstancesList();
48                put(syncIdKey, instances);
49            }
50            instances.add(values);
51        }
52    }
53
54    public static final class InstancesList extends ArrayList<ContentValues> {
55    }
56
57    private static final String TAG = "CalInstances";
58    private CalendarDatabaseHelper mDbHelper;
59    private SQLiteDatabase mDb;
60    private MetaData mMetaData;
61    private CalendarCache mCalendarCache;
62
63    private static final String SQL_WHERE_GET_EVENTS_ENTRIES =
64            "((" + Events.DTSTART + " <= ? AND "
65                    + "(" + Events.LAST_DATE + " IS NULL OR " + Events.LAST_DATE + " >= ?)) OR "
66            + "(" + Events.ORIGINAL_INSTANCE_TIME + " IS NOT NULL AND "
67                    + Events.ORIGINAL_INSTANCE_TIME
68                    + " <= ? AND " + Events.ORIGINAL_INSTANCE_TIME + " >= ?)) AND "
69            + "(" + Calendars.SYNC_EVENTS + " != 0)";
70
71    private static final String SQL_SELECT_EVENTS_SYNC_ID =
72            "SELECT " + Events._SYNC_ID +
73            " FROM " + Tables.EVENTS +
74            " WHERE " + CalendarProvider2.SQL_WHERE_ID;
75
76    private static final String SQL_WHERE_ID_FROM_INSTANCES_NOT_SYNCED =
77            BaseColumns._ID + " IN " +
78            "(SELECT " + Tables.INSTANCES + "." + BaseColumns._ID + " as _id" +
79            " FROM " + Tables.INSTANCES +
80            " INNER JOIN " + Tables.EVENTS +
81            " ON (" +
82            Tables.EVENTS + "." + Events._ID + "=" + Tables.INSTANCES + "." + Instances.EVENT_ID +
83            ")" +
84            " WHERE " + Tables.EVENTS + "." + Events._ID + "=?)";
85
86    private static final String SQL_WHERE_ID_FROM_INSTANCES_SYNCED =
87            BaseColumns._ID + " IN " +
88            "(SELECT " + Tables.INSTANCES + "." + BaseColumns._ID + " as _id" +
89            " FROM " + Tables.INSTANCES +
90            " INNER JOIN " + Tables.EVENTS +
91            " ON (" +
92            Tables.EVENTS + "." + Events._ID + "=" + Tables.INSTANCES + "." + Instances.EVENT_ID +
93            ")" +
94            " WHERE " + Tables.EVENTS + "." + Events._SYNC_ID + "=?" + " OR " +
95                    Tables.EVENTS + "." + Events.ORIGINAL_EVENT + "=?)";
96
97    private static final String[] EXPAND_COLUMNS = new String[] {
98            Events._ID,
99            Events._SYNC_ID,
100            Events.STATUS,
101            Events.DTSTART,
102            Events.DTEND,
103            Events.EVENT_TIMEZONE,
104            Events.RRULE,
105            Events.RDATE,
106            Events.EXRULE,
107            Events.EXDATE,
108            Events.DURATION,
109            Events.ALL_DAY,
110            Events.ORIGINAL_EVENT,
111            Events.ORIGINAL_INSTANCE_TIME,
112            Events.CALENDAR_ID,
113            Events.DELETED
114    };
115
116    // To determine if a recurrence exception originally overlapped the
117    // window, we need to assume a maximum duration, since we only know
118    // the original start time.
119    private static final int MAX_ASSUMED_DURATION = 7 * 24 * 60 * 60 * 1000;
120
121    public CalendarInstancesHelper(CalendarDatabaseHelper calendarDbHelper) {
122        mDbHelper = calendarDbHelper;
123        mDb = mDbHelper.getWritableDatabase();
124        mMetaData = new MetaData(mDbHelper);
125        mCalendarCache = new CalendarCache(mDbHelper);
126    }
127
128    /**
129     * Perform instance expansion on the given entries.
130     *
131     * @param begin Window start (ms).
132     * @param end Window end (ms).
133     * @param localTimezone
134     * @param entries The entries to process.
135     */
136    protected void performInstanceExpansion(long begin, long end, String localTimezone,
137            Cursor entries) {
138        RecurrenceProcessor rp = new RecurrenceProcessor();
139
140        // Key into the instance values to hold the original event concatenated
141        // with calendar id.
142        final String ORIGINAL_EVENT_AND_CALENDAR = "ORIGINAL_EVENT_AND_CALENDAR";
143
144        int statusColumn = entries.getColumnIndex(Events.STATUS);
145        int dtstartColumn = entries.getColumnIndex(Events.DTSTART);
146        int dtendColumn = entries.getColumnIndex(Events.DTEND);
147        int eventTimezoneColumn = entries.getColumnIndex(Events.EVENT_TIMEZONE);
148        int durationColumn = entries.getColumnIndex(Events.DURATION);
149        int rruleColumn = entries.getColumnIndex(Events.RRULE);
150        int rdateColumn = entries.getColumnIndex(Events.RDATE);
151        int exruleColumn = entries.getColumnIndex(Events.EXRULE);
152        int exdateColumn = entries.getColumnIndex(Events.EXDATE);
153        int allDayColumn = entries.getColumnIndex(Events.ALL_DAY);
154        int idColumn = entries.getColumnIndex(Events._ID);
155        int syncIdColumn = entries.getColumnIndex(Events._SYNC_ID);
156        int originalEventColumn = entries.getColumnIndex(Events.ORIGINAL_EVENT);
157        int originalInstanceTimeColumn = entries.getColumnIndex(Events.ORIGINAL_INSTANCE_TIME);
158        int calendarIdColumn = entries.getColumnIndex(Events.CALENDAR_ID);
159        int deletedColumn = entries.getColumnIndex(Events.DELETED);
160
161        ContentValues initialValues;
162        CalendarInstancesHelper.EventInstancesMap instancesMap =
163            new CalendarInstancesHelper.EventInstancesMap();
164
165        Duration duration = new Duration();
166        Time eventTime = new Time();
167
168        // Invariant: entries contains all events that affect the current
169        // window.  It consists of:
170        // a) Individual events that fall in the window.  These will be
171        //    displayed.
172        // b) Recurrences that included the window.  These will be displayed
173        //    if not canceled.
174        // c) Recurrence exceptions that fall in the window.  These will be
175        //    displayed if not cancellations.
176        // d) Recurrence exceptions that modify an instance inside the
177        //    window (subject to 1 week assumption above), but are outside
178        //    the window.  These will not be displayed.  Cases c and d are
179        //    distingushed by the start / end time.
180
181        while (entries.moveToNext()) {
182            try {
183                initialValues = null;
184
185                boolean allDay = entries.getInt(allDayColumn) != 0;
186
187                String eventTimezone = entries.getString(eventTimezoneColumn);
188                if (allDay || TextUtils.isEmpty(eventTimezone)) {
189                    // in the events table, allDay events start at midnight.
190                    // this forces them to stay at midnight for all day events
191                    // TODO: check that this actually does the right thing.
192                    eventTimezone = Time.TIMEZONE_UTC;
193                }
194
195                long dtstartMillis = entries.getLong(dtstartColumn);
196                Long eventId = Long.valueOf(entries.getLong(idColumn));
197
198                String durationStr = entries.getString(durationColumn);
199                if (durationStr != null) {
200                    try {
201                        duration.parse(durationStr);
202                    }
203                    catch (DateException e) {
204                        if (Log.isLoggable(CalendarProvider2.TAG, Log.ERROR)) {
205                            Log.w(CalendarProvider2.TAG, "error parsing duration for event "
206                                    + eventId + "'" + durationStr + "'", e);
207                        }
208                        duration.sign = 1;
209                        duration.weeks = 0;
210                        duration.days = 0;
211                        duration.hours = 0;
212                        duration.minutes = 0;
213                        duration.seconds = 0;
214                        durationStr = "+P0S";
215                    }
216                }
217
218                String syncId = entries.getString(syncIdColumn);
219                String originalEvent = entries.getString(originalEventColumn);
220
221                long originalInstanceTimeMillis = -1;
222                if (!entries.isNull(originalInstanceTimeColumn)) {
223                    originalInstanceTimeMillis= entries.getLong(originalInstanceTimeColumn);
224                }
225                int status = entries.getInt(statusColumn);
226                boolean deleted = (entries.getInt(deletedColumn) != 0);
227
228                String rruleStr = entries.getString(rruleColumn);
229                String rdateStr = entries.getString(rdateColumn);
230                String exruleStr = entries.getString(exruleColumn);
231                String exdateStr = entries.getString(exdateColumn);
232                long calendarId = entries.getLong(calendarIdColumn);
233                // key into instancesMap
234                String syncIdKey = CalendarInstancesHelper.getSyncIdKey(syncId, calendarId);
235
236                RecurrenceSet recur = null;
237                try {
238                    recur = new RecurrenceSet(rruleStr, rdateStr, exruleStr, exdateStr);
239                } catch (EventRecurrence.InvalidFormatException e) {
240                    if (Log.isLoggable(CalendarProvider2.TAG, Log.ERROR)) {
241                        Log.w(CalendarProvider2.TAG, "Could not parse RRULE recurrence string: "
242                                + rruleStr, e);
243                    }
244                    continue;
245                }
246
247                if (null != recur && recur.hasRecurrence()) {
248                    // the event is repeating
249
250                    if (status == Events.STATUS_CANCELED) {
251                        // should not happen!
252                        if (Log.isLoggable(CalendarProvider2.TAG, Log.ERROR)) {
253                            Log.e(CalendarProvider2.TAG, "Found canceled recurring event in "
254                                    + "Events table.  Ignoring.");
255                        }
256                        continue;
257                    }
258                    if (deleted) {
259                        if (Log.isLoggable(CalendarProvider2.TAG, Log.DEBUG)) {
260                            Log.d(CalendarProvider2.TAG, "Found deleted recurring event in "
261                                    + "Events table.  Ignoring.");
262                        }
263                        continue;
264                    }
265
266                    // need to parse the event into a local calendar.
267                    eventTime.timezone = eventTimezone;
268                    eventTime.set(dtstartMillis);
269                    eventTime.allDay = allDay;
270
271                    if (durationStr == null) {
272                        // should not happen.
273                        if (Log.isLoggable(CalendarProvider2.TAG, Log.ERROR)) {
274                            Log.e(CalendarProvider2.TAG, "Repeating event has no duration -- "
275                                    + "should not happen.");
276                        }
277                        if (allDay) {
278                            // set to one day.
279                            duration.sign = 1;
280                            duration.weeks = 0;
281                            duration.days = 1;
282                            duration.hours = 0;
283                            duration.minutes = 0;
284                            duration.seconds = 0;
285                            durationStr = "+P1D";
286                        } else {
287                            // compute the duration from dtend, if we can.
288                            // otherwise, use 0s.
289                            duration.sign = 1;
290                            duration.weeks = 0;
291                            duration.days = 0;
292                            duration.hours = 0;
293                            duration.minutes = 0;
294                            if (!entries.isNull(dtendColumn)) {
295                                long dtendMillis = entries.getLong(dtendColumn);
296                                duration.seconds = (int) ((dtendMillis - dtstartMillis) / 1000);
297                                durationStr = "+P" + duration.seconds + "S";
298                            } else {
299                                duration.seconds = 0;
300                                durationStr = "+P0S";
301                            }
302                        }
303                    }
304
305                    long[] dates;
306                    dates = rp.expand(eventTime, recur, begin, end);
307
308                    // Initialize the "eventTime" timezone outside the loop.
309                    // This is used in computeTimezoneDependentFields().
310                    if (allDay) {
311                        eventTime.timezone = Time.TIMEZONE_UTC;
312                    } else {
313                        eventTime.timezone = localTimezone;
314                    }
315
316                    long durationMillis = duration.getMillis();
317                    for (long date : dates) {
318                        initialValues = new ContentValues();
319                        initialValues.put(Instances.EVENT_ID, eventId);
320
321                        initialValues.put(Instances.BEGIN, date);
322                        long dtendMillis = date + durationMillis;
323                        initialValues.put(Instances.END, dtendMillis);
324
325                        CalendarInstancesHelper.computeTimezoneDependentFields(date, dtendMillis,
326                                eventTime, initialValues);
327                        instancesMap.add(syncIdKey, initialValues);
328                    }
329                } else {
330                    // the event is not repeating
331                    initialValues = new ContentValues();
332
333                    // if this event has an "original" field, then record
334                    // that we need to cancel the original event (we can't
335                    // do that here because the order of this loop isn't
336                    // defined)
337                    if (originalEvent != null && originalInstanceTimeMillis != -1) {
338                        // The ORIGINAL_EVENT_AND_CALENDAR holds the
339                        // calendar id concatenated with the ORIGINAL_EVENT to form
340                        // a unique key, matching the keys for instancesMap.
341                        initialValues.put(ORIGINAL_EVENT_AND_CALENDAR,
342                                CalendarInstancesHelper.getSyncIdKey(originalEvent, calendarId));
343                        initialValues.put(Events.ORIGINAL_INSTANCE_TIME,
344                                originalInstanceTimeMillis);
345                        initialValues.put(Events.STATUS, status);
346                    }
347
348                    long dtendMillis = dtstartMillis;
349                    if (durationStr == null) {
350                        if (!entries.isNull(dtendColumn)) {
351                            dtendMillis = entries.getLong(dtendColumn);
352                        }
353                    } else {
354                        dtendMillis = duration.addTo(dtstartMillis);
355                    }
356
357                    // this non-recurring event might be a recurrence exception that doesn't
358                    // actually fall within our expansion window, but instead was selected
359                    // so we can correctly cancel expanded recurrence instances below.  do not
360                    // add events to the instances map if they don't actually fall within our
361                    // expansion window.
362                    if ((dtendMillis < begin) || (dtstartMillis > end)) {
363                        if (originalEvent != null && originalInstanceTimeMillis != -1) {
364                            initialValues.put(Events.STATUS, Events.STATUS_CANCELED);
365                        } else {
366                            if (Log.isLoggable(CalendarProvider2.TAG, Log.ERROR)) {
367                                Log.w(CalendarProvider2.TAG, "Unexpected event outside window: "
368                                        + syncId);
369                            }
370                            continue;
371                        }
372                    }
373
374                    initialValues.put(Instances.EVENT_ID, eventId);
375
376                    initialValues.put(Instances.BEGIN, dtstartMillis);
377                    initialValues.put(Instances.END, dtendMillis);
378
379                    // we temporarily store the DELETED status (will be cleaned later)
380                    initialValues.put(Events.DELETED, deleted);
381
382                    if (allDay) {
383                        eventTime.timezone = Time.TIMEZONE_UTC;
384                    } else {
385                        eventTime.timezone = localTimezone;
386                    }
387                    CalendarInstancesHelper.computeTimezoneDependentFields(dtstartMillis,
388                            dtendMillis, eventTime, initialValues);
389
390                    instancesMap.add(syncIdKey, initialValues);
391                }
392            } catch (DateException e) {
393                if (Log.isLoggable(CalendarProvider2.TAG, Log.ERROR)) {
394                    Log.w(CalendarProvider2.TAG, "RecurrenceProcessor error ", e);
395                }
396            } catch (TimeFormatException e) {
397                if (Log.isLoggable(CalendarProvider2.TAG, Log.ERROR)) {
398                    Log.w(CalendarProvider2.TAG, "RecurrenceProcessor error ", e);
399                }
400            }
401        }
402
403        // Invariant: instancesMap contains all instances that affect the
404        // window, indexed by original sync id concatenated with calendar id.
405        // It consists of:
406        // a) Individual events that fall in the window.  They have:
407        //   EVENT_ID, BEGIN, END
408        // b) Instances of recurrences that fall in the window.  They may
409        //   be subject to exceptions.  They have:
410        //   EVENT_ID, BEGIN, END
411        // c) Exceptions that fall in the window.  They have:
412        //   ORIGINAL_EVENT_AND_CALENDAR, ORIGINAL_INSTANCE_TIME, STATUS (since they can
413        //   be a modification or cancellation), EVENT_ID, BEGIN, END
414        // d) Recurrence exceptions that modify an instance inside the
415        //   window but fall outside the window.  They have:
416        //   ORIGINAL_EVENT_AND_CALENDAR, ORIGINAL_INSTANCE_TIME, STATUS =
417        //   STATUS_CANCELED, EVENT_ID, BEGIN, END
418
419        // First, delete the original instances corresponding to recurrence
420        // exceptions.  We do this by iterating over the list and for each
421        // recurrence exception, we search the list for an instance with a
422        // matching "original instance time".  If we find such an instance,
423        // we remove it from the list.  If we don't find such an instance
424        // then we cancel the recurrence exception.
425        Set<String> keys = instancesMap.keySet();
426        for (String syncIdKey : keys) {
427            CalendarInstancesHelper.InstancesList list = instancesMap.get(syncIdKey);
428            for (ContentValues values : list) {
429
430                // If this instance is not a recurrence exception, then
431                // skip it.
432                if (!values.containsKey(ORIGINAL_EVENT_AND_CALENDAR)) {
433                    continue;
434                }
435
436                String originalEventPlusCalendar = values.getAsString(ORIGINAL_EVENT_AND_CALENDAR);
437                long originalTime = values.getAsLong(Events.ORIGINAL_INSTANCE_TIME);
438                CalendarInstancesHelper.InstancesList originalList = instancesMap
439                        .get(originalEventPlusCalendar);
440                if (originalList == null) {
441                    // The original recurrence is not present, so don't try canceling it.
442                    continue;
443                }
444
445                // Search the original event for a matching original
446                // instance time.  If there is a matching one, then remove
447                // the original one.  We do this both for exceptions that
448                // change the original instance as well as for exceptions
449                // that delete the original instance.
450                for (int num = originalList.size() - 1; num >= 0; num--) {
451                    ContentValues originalValues = originalList.get(num);
452                    long beginTime = originalValues.getAsLong(Instances.BEGIN);
453                    if (beginTime == originalTime) {
454                        // We found the original instance, so remove it.
455                        originalList.remove(num);
456                    }
457                }
458            }
459        }
460
461        // Invariant: instancesMap contains filtered instances.
462        // It consists of:
463        // a) Individual events that fall in the window.
464        // b) Instances of recurrences that fall in the window and have not
465        //   been subject to exceptions.
466        // c) Exceptions that fall in the window.  They will have
467        //   STATUS_CANCELED if they are cancellations.
468        // d) Recurrence exceptions that modify an instance inside the
469        //   window but fall outside the window.  These are STATUS_CANCELED.
470
471        // Now do the inserts.  Since the db lock is held when this method is executed,
472        // this will be done in a transaction.
473        // NOTE: if there is lock contention (e.g., a sync is trying to merge into the db
474        // while the calendar app is trying to query the db (expanding instances)), we will
475        // not be "polite" and yield the lock until we're done.  This will favor local query
476        // operations over sync/write operations.
477        for (String syncIdKey : keys) {
478            CalendarInstancesHelper.InstancesList list = instancesMap.get(syncIdKey);
479            for (ContentValues values : list) {
480
481                // If this instance was cancelled or deleted then don't create a new
482                // instance.
483                Integer status = values.getAsInteger(Events.STATUS);
484                boolean deleted = values.containsKey(Events.DELETED) ?
485                        values.getAsBoolean(Events.DELETED) : false;
486                if ((status != null && status == Events.STATUS_CANCELED) || deleted) {
487                    continue;
488                }
489
490                // We remove this useless key (not valid in the context of Instances table)
491                values.remove(Events.DELETED);
492
493                // Remove these fields before inserting a new instance
494                values.remove(ORIGINAL_EVENT_AND_CALENDAR);
495                values.remove(Events.ORIGINAL_INSTANCE_TIME);
496                values.remove(Events.STATUS);
497
498                mDbHelper.instancesReplace(values);
499            }
500        }
501    }
502
503    /**
504     * Make instances for the given range.
505     */
506    protected void expandInstanceRangeLocked(long begin, long end, String localTimezone) {
507
508        if (CalendarProvider2.PROFILE) {
509            Debug.startMethodTracing("expandInstanceRangeLocked");
510        }
511
512        if (Log.isLoggable(TAG, Log.VERBOSE)) {
513            Log.v(TAG, "Expanding events between " + begin + " and " + end);
514        }
515
516        Cursor entries = getEntries(begin, end);
517        try {
518            performInstanceExpansion(begin, end, localTimezone, entries);
519        } finally {
520            if (entries != null) {
521                entries.close();
522            }
523        }
524        if (CalendarProvider2.PROFILE) {
525            Debug.stopMethodTracing();
526        }
527    }
528
529    /**
530     * Get all entries affecting the given window.
531     *
532     * @param begin Window start (ms).
533     * @param end Window end (ms).
534     * @return Cursor for the entries; caller must close it.
535     */
536    private Cursor getEntries(long begin, long end) {
537        SQLiteQueryBuilder qb = new SQLiteQueryBuilder();
538        qb.setTables(CalendarDatabaseHelper.Views.EVENTS);
539        qb.setProjectionMap(CalendarProvider2.sEventsProjectionMap);
540
541        String beginString = String.valueOf(begin);
542        String endString = String.valueOf(end);
543
544        // grab recurrence exceptions that fall outside our expansion window but
545        // modify
546        // recurrences that do fall within our window. we won't insert these
547        // into the output
548        // set of instances, but instead will just add them to our cancellations
549        // list, so we
550        // can cancel the correct recurrence expansion instances.
551        // we don't have originalInstanceDuration or end time. for now, assume
552        // the original
553        // instance lasts no longer than 1 week.
554        // also filter with syncable state (we dont want the entries from a non
555        // syncable account)
556        // TODO: compute the originalInstanceEndTime or get this from the
557        // server.
558        qb.appendWhere(SQL_WHERE_GET_EVENTS_ENTRIES);
559        String selectionArgs[] = new String[] {
560                endString, beginString, endString, String.valueOf(begin - MAX_ASSUMED_DURATION)
561        };
562        Cursor c = qb.query(mDb, EXPAND_COLUMNS, null /* selection */, selectionArgs,
563                null /* groupBy */, null /* having */, null /* sortOrder */);
564        if (Log.isLoggable(TAG, Log.VERBOSE)) {
565            Log.v(TAG, "Instance expansion:  got " + c.getCount() + " entries");
566        }
567        return c;
568    }
569
570    /**
571     * Updates the instances table when an event is added or updated.
572     *
573     * @param values The new values of the event.
574     * @param rowId The database row id of the event.
575     * @param newEvent true if the event is new.
576     * @param db The database
577     */
578    public void updateInstancesLocked(ContentValues values, long rowId, boolean newEvent,
579            SQLiteDatabase db) {
580
581        // If there are no expanded Instances, then return.
582        MetaData.Fields fields = mMetaData.getFieldsLocked();
583        if (fields.maxInstance == 0) {
584            return;
585        }
586
587        Long dtstartMillis = values.getAsLong(Events.DTSTART);
588        if (dtstartMillis == null) {
589            if (newEvent) {
590                // must be present for a new event.
591                throw new RuntimeException("DTSTART missing.");
592            }
593            if (Log.isLoggable(TAG, Log.VERBOSE)) {
594                Log.v(TAG, "Missing DTSTART.  No need to update instance.");
595            }
596            return;
597        }
598
599        Long lastDateMillis = values.getAsLong(Events.LAST_DATE);
600        Long originalInstanceTime = values.getAsLong(Events.ORIGINAL_INSTANCE_TIME);
601
602        if (!newEvent) {
603            // Want to do this for regular event, recurrence, or exception.
604            // For recurrence or exception, more deletion may happen below if we
605            // do an instance expansion. This deletion will suffice if the
606            // exception
607            // is moved outside the window, for instance.
608            db.delete(Tables.INSTANCES, Instances.EVENT_ID + "=?", new String[] {
609                String.valueOf(rowId)
610            });
611        }
612
613        String rrule = values.getAsString(Events.RRULE);
614        String rdate = values.getAsString(Events.RDATE);
615        String originalEvent = values.getAsString(Events.ORIGINAL_EVENT);
616        if (CalendarProvider2.isRecurrenceEvent(rrule, rdate, originalEvent)) {
617            // The recurrence or exception needs to be (re-)expanded if:
618            // a) Exception or recurrence that falls inside window
619            boolean insideWindow = dtstartMillis <= fields.maxInstance
620                    && (lastDateMillis == null || lastDateMillis >= fields.minInstance);
621            // b) Exception that affects instance inside window
622            // These conditions match the query in getEntries
623            // See getEntries comment for explanation of subtracting 1 week.
624            boolean affectsWindow = originalInstanceTime != null
625                    && originalInstanceTime <= fields.maxInstance
626                    && originalInstanceTime >= fields.minInstance - MAX_ASSUMED_DURATION;
627            if (insideWindow || affectsWindow) {
628                updateRecurrenceInstancesLocked(values, rowId, db);
629            }
630            // TODO: an exception creation or update could be optimized by
631            // updating just the affected instances, instead of regenerating
632            // the recurrence.
633            return;
634        }
635
636        Long dtendMillis = values.getAsLong(Events.DTEND);
637        if (dtendMillis == null) {
638            dtendMillis = dtstartMillis;
639        }
640
641        // if the event is in the expanded range, insert
642        // into the instances table.
643        // TODO: deal with durations. currently, durations are only used in
644        // recurrences.
645
646        if (dtstartMillis <= fields.maxInstance && dtendMillis >= fields.minInstance) {
647            ContentValues instanceValues = new ContentValues();
648            instanceValues.put(Instances.EVENT_ID, rowId);
649            instanceValues.put(Instances.BEGIN, dtstartMillis);
650            instanceValues.put(Instances.END, dtendMillis);
651
652            boolean allDay = false;
653            Integer allDayInteger = values.getAsInteger(Events.ALL_DAY);
654            if (allDayInteger != null) {
655                allDay = allDayInteger != 0;
656            }
657
658            // Update the timezone-dependent fields.
659            Time local = new Time();
660            if (allDay) {
661                local.timezone = Time.TIMEZONE_UTC;
662            } else {
663                local.timezone = fields.timezone;
664            }
665
666            CalendarInstancesHelper.computeTimezoneDependentFields(dtstartMillis, dtendMillis,
667                    local, instanceValues);
668            mDbHelper.instancesInsert(instanceValues);
669        }
670    }
671
672    /**
673     * Do incremental Instances update of a recurrence or recurrence exception.
674     * This method does performInstanceExpansion on just the modified
675     * recurrence, to avoid the overhead of recomputing the entire instance
676     * table.
677     *
678     * @param values The new values of the event.
679     * @param rowId The database row id of the event.
680     * @param db The database
681     */
682    private void updateRecurrenceInstancesLocked(ContentValues values, long rowId,
683            SQLiteDatabase db) {
684        MetaData.Fields fields = mMetaData.getFieldsLocked();
685        String instancesTimezone = mCalendarCache.readTimezoneInstances();
686        String originalEvent = values.getAsString(Events.ORIGINAL_EVENT);
687        String recurrenceSyncId;
688        if (originalEvent != null) {
689            recurrenceSyncId = originalEvent;
690        } else {
691            // Get the recurrence's sync id from the database
692            recurrenceSyncId = DatabaseUtils.stringForQuery(db, SQL_SELECT_EVENTS_SYNC_ID,
693                    new String[] {
694                        String.valueOf(rowId)
695                    });
696        }
697        // recurrenceSyncId is the _sync_id of the underlying recurrence
698        // If the recurrence hasn't gone to the server, it will be null.
699
700        // Need to clear out old instances
701        if (recurrenceSyncId == null) {
702            // Creating updating a recurrence that hasn't gone to the server.
703            // Need to delete based on row id
704            String where = SQL_WHERE_ID_FROM_INSTANCES_NOT_SYNCED;
705            db.delete(Tables.INSTANCES, where, new String[] {
706                "" + rowId
707            });
708        } else {
709            // Creating or modifying a recurrence or exception.
710            // Delete instances for recurrence (_sync_id = recurrenceSyncId)
711            // and all exceptions (originalEvent = recurrenceSyncId)
712            String where = SQL_WHERE_ID_FROM_INSTANCES_SYNCED;
713            db.delete(Tables.INSTANCES, where, new String[] {
714                    recurrenceSyncId, recurrenceSyncId
715            });
716        }
717
718        // Now do instance expansion
719        Cursor entries = getRelevantRecurrenceEntries(recurrenceSyncId, rowId);
720        try {
721            performInstanceExpansion(fields.minInstance, fields.maxInstance,
722                    instancesTimezone, entries);
723        } finally {
724            if (entries != null) {
725                entries.close();
726            }
727        }
728    }
729
730    /**
731     * Determines the recurrence entries associated with a particular
732     * recurrence. This set is the base recurrence and any exception. Normally
733     * the entries are indicated by the sync id of the base recurrence (which is
734     * the originalEvent in the exceptions). However, a complication is that a
735     * recurrence may not yet have a sync id. In that case, the recurrence is
736     * specified by the rowId.
737     *
738     * @param recurrenceSyncId The sync id of the base recurrence, or null.
739     * @param rowId The row id of the base recurrence.
740     * @return the relevant entries.
741     */
742    private Cursor getRelevantRecurrenceEntries(String recurrenceSyncId, long rowId) {
743        SQLiteQueryBuilder qb = new SQLiteQueryBuilder();
744
745        qb.setTables(CalendarDatabaseHelper.Views.EVENTS);
746        qb.setProjectionMap(CalendarProvider2.sEventsProjectionMap);
747        String selectionArgs[];
748        if (recurrenceSyncId == null) {
749            String where = CalendarProvider2.SQL_WHERE_ID;
750            qb.appendWhere(where);
751            selectionArgs = new String[] {
752                String.valueOf(rowId)
753            };
754        } else {
755            String where = Events._SYNC_ID + "=? OR " + Events.ORIGINAL_EVENT + "=?";
756            qb.appendWhere(where);
757            selectionArgs = new String[] {
758                    recurrenceSyncId, recurrenceSyncId
759            };
760        }
761        if (Log.isLoggable(TAG, Log.VERBOSE)) {
762            Log.v(TAG, "Retrieving events to expand: " + qb.toString());
763        }
764
765        return qb.query(mDb, EXPAND_COLUMNS, null /* selection */, selectionArgs,
766                null /* groupBy */, null /* having */, null /* sortOrder */);
767    }
768
769    /**
770     * Generates a unique key from the syncId and calendarId. The purpose of
771     * this is to prevent collisions if two different calendars use the same
772     * sync id. This can happen if a Google calendar is accessed by two
773     * different accounts, or with Exchange, where ids are not unique between
774     * calendars.
775     *
776     * @param syncId Id for the event
777     * @param calendarId Id for the calendar
778     * @return key
779     */
780    static String getSyncIdKey(String syncId, long calendarId) {
781        return calendarId + ":" + syncId;
782    }
783
784    /**
785     * Computes the timezone-dependent fields of an instance of an event and
786     * updates the "values" map to contain those fields.
787     *
788     * @param begin the start time of the instance (in UTC milliseconds)
789     * @param end the end time of the instance (in UTC milliseconds)
790     * @param local a Time object with the timezone set to the local timezone
791     * @param values a map that will contain the timezone-dependent fields
792     */
793    static void computeTimezoneDependentFields(long begin, long end,
794            Time local, ContentValues values) {
795        local.set(begin);
796        int startDay = Time.getJulianDay(begin, local.gmtoff);
797        int startMinute = local.hour * 60 + local.minute;
798
799        local.set(end);
800        int endDay = Time.getJulianDay(end, local.gmtoff);
801        int endMinute = local.hour * 60 + local.minute;
802
803        // Special case for midnight, which has endMinute == 0.  Change
804        // that to +24 hours on the previous day to make everything simpler.
805        // Exception: if start and end minute are both 0 on the same day,
806        // then leave endMinute alone.
807        if (endMinute == 0 && endDay > startDay) {
808            endMinute = 24 * 60;
809            endDay -= 1;
810        }
811
812        values.put(Instances.START_DAY, startDay);
813        values.put(Instances.END_DAY, endDay);
814        values.put(Instances.START_MINUTE, startMinute);
815        values.put(Instances.END_MINUTE, endMinute);
816    }
817
818}
819