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> { 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 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 */ 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 */ 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 */ 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 */ 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 */ 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 */ 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 */ 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 */ 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