CalendarInstancesHelper.java revision 9ec70fada3d8f7cf56d6b0d0947823ec5bce572c
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.Calendar.Calendars;
29import android.provider.Calendar.Events;
30import android.provider.Calendar.Instances;
31import android.text.TextUtils;
32import android.text.format.Time;
33import android.util.Log;
34import android.util.TimeFormatException;
35
36import java.util.ArrayList;
37import java.util.HashMap;
38import java.util.Set;
39
40public class CalendarInstancesHelper {
41    public static final class EventInstancesMap extends
42            HashMap<String, CalendarInstancesHelper.InstancesList> {
43        public void add(String syncIdKey, ContentValues values) {
44            CalendarInstancesHelper.InstancesList instances = get(syncIdKey);
45            if (instances == null) {
46                instances = new CalendarInstancesHelper.InstancesList();
47                put(syncIdKey, instances);
48            }
49            instances.add(values);
50        }
51    }
52
53    public static final class InstancesList extends ArrayList<ContentValues> {
54    }
55
56    private static final String TAG = "CalInstances";
57    private CalendarDatabaseHelper mDbHelper;
58    private SQLiteDatabase mDb;
59    private MetaData mMetaData;
60    private CalendarCache mCalendarCache;
61
62    private static final String SQL_WHERE_GET_EVENTS_ENTRIES =
63            "((" + Events.DTSTART + " <= ? AND "
64                    + "(" + Events.LAST_DATE + " IS NULL OR " + Events.LAST_DATE + " >= ?)) OR "
65            + "(" + Events.ORIGINAL_INSTANCE_TIME + " IS NOT NULL AND "
66                    + Events.ORIGINAL_INSTANCE_TIME
67                    + " <= ? AND " + Events.ORIGINAL_INSTANCE_TIME + " >= ?)) AND "
68            + "(" + Calendars.SYNC_EVENTS + " != ?) AND "
69            + "(" + Events.LAST_SYNCED + " = ?)";
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            Instances._ID + " IN " +
78            "(SELECT " + Tables.INSTANCES + "." + Instances._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            Instances._ID + " IN " +
88            "(SELECT " + Tables.INSTANCES + "." + Instances._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_SYNC_ID + "=?)";
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_SYNC_ID,
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_SYNC_ID);
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        // also filter with last_synced=0 so we don't expand events that were
557        // dup'ed for partial updates.
558        // TODO: compute the originalInstanceEndTime or get this from the
559        // server.
560        qb.appendWhere(SQL_WHERE_GET_EVENTS_ENTRIES);
561        String selectionArgs[] = new String[] {
562                endString,
563                beginString,
564                endString,
565                String.valueOf(begin - MAX_ASSUMED_DURATION),
566                "0", // Calendars.SYNC_EVENTS
567                "0", // Events.LAST_SYNCED
568        };
569        Cursor c = qb.query(mDb, EXPAND_COLUMNS, null /* selection */, selectionArgs,
570                null /* groupBy */, null /* having */, null /* sortOrder */);
571        if (Log.isLoggable(TAG, Log.VERBOSE)) {
572            Log.v(TAG, "Instance expansion:  got " + c.getCount() + " entries");
573        }
574        return c;
575    }
576
577    /**
578     * Updates the instances table when an event is added or updated.
579     *
580     * @param values The new values of the event.
581     * @param rowId The database row id of the event.
582     * @param newEvent true if the event is new.
583     * @param db The database
584     */
585    public void updateInstancesLocked(ContentValues values, long rowId, boolean newEvent,
586            SQLiteDatabase db) {
587
588        // If there are no expanded Instances, then return.
589        MetaData.Fields fields = mMetaData.getFieldsLocked();
590        if (fields.maxInstance == 0) {
591            return;
592        }
593
594        Long dtstartMillis = values.getAsLong(Events.DTSTART);
595        if (dtstartMillis == null) {
596            if (newEvent) {
597                // must be present for a new event.
598                throw new RuntimeException("DTSTART missing.");
599            }
600            if (Log.isLoggable(TAG, Log.VERBOSE)) {
601                Log.v(TAG, "Missing DTSTART.  No need to update instance.");
602            }
603            return;
604        }
605
606        Long lastDateMillis = values.getAsLong(Events.LAST_DATE);
607        Long originalInstanceTime = values.getAsLong(Events.ORIGINAL_INSTANCE_TIME);
608
609        if (!newEvent) {
610            // Want to do this for regular event, recurrence, or exception.
611            // For recurrence or exception, more deletion may happen below if we
612            // do an instance expansion. This deletion will suffice if the
613            // exception
614            // is moved outside the window, for instance.
615            db.delete(Tables.INSTANCES, Instances.EVENT_ID + "=?", new String[] {
616                String.valueOf(rowId)
617            });
618        }
619
620        String rrule = values.getAsString(Events.RRULE);
621        String rdate = values.getAsString(Events.RDATE);
622        String originalEvent = values.getAsString(Events.ORIGINAL_SYNC_ID);
623        if (CalendarProvider2.isRecurrenceEvent(rrule, rdate, originalEvent)) {
624            // The recurrence or exception needs to be (re-)expanded if:
625            // a) Exception or recurrence that falls inside window
626            boolean insideWindow = dtstartMillis <= fields.maxInstance
627                    && (lastDateMillis == null || lastDateMillis >= fields.minInstance);
628            // b) Exception that affects instance inside window
629            // These conditions match the query in getEntries
630            // See getEntries comment for explanation of subtracting 1 week.
631            boolean affectsWindow = originalInstanceTime != null
632                    && originalInstanceTime <= fields.maxInstance
633                    && originalInstanceTime >= fields.minInstance - MAX_ASSUMED_DURATION;
634            if (insideWindow || affectsWindow) {
635                updateRecurrenceInstancesLocked(values, rowId, db);
636            }
637            // TODO: an exception creation or update could be optimized by
638            // updating just the affected instances, instead of regenerating
639            // the recurrence.
640            return;
641        }
642
643        Long dtendMillis = values.getAsLong(Events.DTEND);
644        if (dtendMillis == null) {
645            dtendMillis = dtstartMillis;
646        }
647
648        // if the event is in the expanded range, insert
649        // into the instances table.
650        // TODO: deal with durations. currently, durations are only used in
651        // recurrences.
652
653        if (dtstartMillis <= fields.maxInstance && dtendMillis >= fields.minInstance) {
654            ContentValues instanceValues = new ContentValues();
655            instanceValues.put(Instances.EVENT_ID, rowId);
656            instanceValues.put(Instances.BEGIN, dtstartMillis);
657            instanceValues.put(Instances.END, dtendMillis);
658
659            boolean allDay = false;
660            Integer allDayInteger = values.getAsInteger(Events.ALL_DAY);
661            if (allDayInteger != null) {
662                allDay = allDayInteger != 0;
663            }
664
665            // Update the timezone-dependent fields.
666            Time local = new Time();
667            if (allDay) {
668                local.timezone = Time.TIMEZONE_UTC;
669            } else {
670                local.timezone = fields.timezone;
671            }
672
673            CalendarInstancesHelper.computeTimezoneDependentFields(dtstartMillis, dtendMillis,
674                    local, instanceValues);
675            mDbHelper.instancesInsert(instanceValues);
676        }
677    }
678
679    /**
680     * Do incremental Instances update of a recurrence or recurrence exception.
681     * This method does performInstanceExpansion on just the modified
682     * recurrence, to avoid the overhead of recomputing the entire instance
683     * table.
684     *
685     * @param values The new values of the event.
686     * @param rowId The database row id of the event.
687     * @param db The database
688     */
689    private void updateRecurrenceInstancesLocked(ContentValues values, long rowId,
690            SQLiteDatabase db) {
691        MetaData.Fields fields = mMetaData.getFieldsLocked();
692        String instancesTimezone = mCalendarCache.readTimezoneInstances();
693        String originalEvent = values.getAsString(Events.ORIGINAL_SYNC_ID);
694        String recurrenceSyncId;
695        if (originalEvent != null) {
696            recurrenceSyncId = originalEvent;
697        } else {
698            // Get the recurrence's sync id from the database
699            recurrenceSyncId = DatabaseUtils.stringForQuery(db, SQL_SELECT_EVENTS_SYNC_ID,
700                    new String[] {
701                        String.valueOf(rowId)
702                    });
703        }
704        // recurrenceSyncId is the _sync_id of the underlying recurrence
705        // If the recurrence hasn't gone to the server, it will be null.
706
707        // Need to clear out old instances
708        if (recurrenceSyncId == null) {
709            // Creating updating a recurrence that hasn't gone to the server.
710            // Need to delete based on row id
711            String where = SQL_WHERE_ID_FROM_INSTANCES_NOT_SYNCED;
712            db.delete(Tables.INSTANCES, where, new String[] {
713                "" + rowId
714            });
715        } else {
716            // Creating or modifying a recurrence or exception.
717            // Delete instances for recurrence (_sync_id = recurrenceSyncId)
718            // and all exceptions (originalEvent = recurrenceSyncId)
719            String where = SQL_WHERE_ID_FROM_INSTANCES_SYNCED;
720            db.delete(Tables.INSTANCES, where, new String[] {
721                    recurrenceSyncId, recurrenceSyncId
722            });
723        }
724
725        // Now do instance expansion
726        Cursor entries = getRelevantRecurrenceEntries(recurrenceSyncId, rowId);
727        try {
728            performInstanceExpansion(fields.minInstance, fields.maxInstance,
729                    instancesTimezone, entries);
730        } finally {
731            if (entries != null) {
732                entries.close();
733            }
734        }
735    }
736
737    /**
738     * Determines the recurrence entries associated with a particular
739     * recurrence. This set is the base recurrence and any exception. Normally
740     * the entries are indicated by the sync id of the base recurrence (which is
741     * the originalEvent in the exceptions). However, a complication is that a
742     * recurrence may not yet have a sync id. In that case, the recurrence is
743     * specified by the rowId.
744     *
745     * @param recurrenceSyncId The sync id of the base recurrence, or null.
746     * @param rowId The row id of the base recurrence.
747     * @return the relevant entries.
748     */
749    private Cursor getRelevantRecurrenceEntries(String recurrenceSyncId, long rowId) {
750        SQLiteQueryBuilder qb = new SQLiteQueryBuilder();
751
752        qb.setTables(CalendarDatabaseHelper.Views.EVENTS);
753        qb.setProjectionMap(CalendarProvider2.sEventsProjectionMap);
754        String selectionArgs[];
755        if (recurrenceSyncId == null) {
756            String where = CalendarProvider2.SQL_WHERE_ID;
757            qb.appendWhere(where);
758            selectionArgs = new String[] {
759                String.valueOf(rowId)
760            };
761        } else {
762            // don't expand events that were dup'ed for partial updates
763            String where = "(" + Events._SYNC_ID + "=? OR " + Events.ORIGINAL_SYNC_ID + "=?) AND "
764                    + Events.LAST_SYNCED + " = ?";
765            qb.appendWhere(where);
766            selectionArgs = new String[] {
767                    recurrenceSyncId,
768                    recurrenceSyncId,
769                    "0", // Events.LAST_SYNCED
770            };
771        }
772        if (Log.isLoggable(TAG, Log.VERBOSE)) {
773            Log.v(TAG, "Retrieving events to expand: " + qb.toString());
774        }
775
776        return qb.query(mDb, EXPAND_COLUMNS, null /* selection */, selectionArgs,
777                null /* groupBy */, null /* having */, null /* sortOrder */);
778    }
779
780    /**
781     * Generates a unique key from the syncId and calendarId. The purpose of
782     * this is to prevent collisions if two different calendars use the same
783     * sync id. This can happen if a Google calendar is accessed by two
784     * different accounts, or with Exchange, where ids are not unique between
785     * calendars.
786     *
787     * @param syncId Id for the event
788     * @param calendarId Id for the calendar
789     * @return key
790     */
791    static String getSyncIdKey(String syncId, long calendarId) {
792        return calendarId + ":" + syncId;
793    }
794
795    /**
796     * Computes the timezone-dependent fields of an instance of an event and
797     * updates the "values" map to contain those fields.
798     *
799     * @param begin the start time of the instance (in UTC milliseconds)
800     * @param end the end time of the instance (in UTC milliseconds)
801     * @param local a Time object with the timezone set to the local timezone
802     * @param values a map that will contain the timezone-dependent fields
803     */
804    static void computeTimezoneDependentFields(long begin, long end,
805            Time local, ContentValues values) {
806        local.set(begin);
807        int startDay = Time.getJulianDay(begin, local.gmtoff);
808        int startMinute = local.hour * 60 + local.minute;
809
810        local.set(end);
811        int endDay = Time.getJulianDay(end, local.gmtoff);
812        int endMinute = local.hour * 60 + local.minute;
813
814        // Special case for midnight, which has endMinute == 0.  Change
815        // that to +24 hours on the previous day to make everything simpler.
816        // Exception: if start and end minute are both 0 on the same day,
817        // then leave endMinute alone.
818        if (endMinute == 0 && endDay > startDay) {
819            endMinute = 24 * 60;
820            endDay -= 1;
821        }
822
823        values.put(Instances.START_DAY, startDay);
824        values.put(Instances.END_DAY, endDay);
825        values.put(Instances.START_MINUTE, startMinute);
826        values.put(Instances.END_MINUTE, endMinute);
827    }
828
829}
830