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