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.calendarcommon.DateException; 20import com.android.calendarcommon.Duration; 21import com.android.calendarcommon.EventRecurrence; 22import com.android.calendarcommon.RecurrenceProcessor; 23import com.android.calendarcommon.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