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