1 package com.android.exchange.service; 2 3 import android.content.ContentResolver; 4 import android.content.ContentUris; 5 import android.content.ContentValues; 6 import android.content.Context; 7 import android.content.Entity; 8 import android.content.EntityIterator; 9 import android.content.SyncResult; 10 import android.database.Cursor; 11 import android.database.DatabaseUtils; 12 import android.net.Uri; 13 import android.os.Bundle; 14 import android.provider.CalendarContract; 15 import android.provider.CalendarContract.Attendees; 16 import android.provider.CalendarContract.Calendars; 17 import android.provider.CalendarContract.Events; 18 import android.provider.CalendarContract.EventsEntity; 19 import android.provider.CalendarContract.ExtendedProperties; 20 import android.provider.CalendarContract.Reminders; 21 import android.text.TextUtils; 22 import android.text.format.DateUtils; 23 24 import com.android.calendarcommon2.DateException; 25 import com.android.calendarcommon2.Duration; 26 import com.android.emailcommon.TrafficFlags; 27 import com.android.emailcommon.provider.Account; 28 import com.android.emailcommon.provider.EmailContent.Message; 29 import com.android.emailcommon.provider.Mailbox; 30 import com.android.emailcommon.utility.Utility; 31 import com.android.exchange.Eas; 32 import com.android.exchange.R; 33 import com.android.exchange.adapter.AbstractSyncParser; 34 import com.android.exchange.adapter.CalendarSyncParser; 35 import com.android.exchange.adapter.Serializer; 36 import com.android.exchange.adapter.Tags; 37 import com.android.exchange.utility.CalendarUtilities; 38 import com.android.mail.utils.LogUtils; 39 40 import java.io.IOException; 41 import java.io.InputStream; 42 import java.util.ArrayList; 43 import java.util.StringTokenizer; 44 import java.util.TimeZone; 45 import java.util.UUID; 46 47 /** 48 * Performs an Exchange Sync for a Calendar collection. 49 */ 50 public class EasCalendarSyncHandler extends EasSyncHandler { 51 private static final String TAG = Eas.LOG_TAG; 52 53 // TODO: Some constants are copied from CalendarSyncAdapter and are still used by the parser. 54 // These values need to stay in sync; when the parser is cleaned up, be sure to unify them. 55 56 /** Projection for getting a calendar id. */ 57 private static final String[] CALENDAR_ID_PROJECTION = { Calendars._ID }; 58 private static final int CALENDAR_ID_COLUMN = 0; 59 60 /** Content selection for getting a calendar id for an account. */ 61 private static final String CALENDAR_SELECTION_ACCOUNT_AND_SYNC_ID = 62 Calendars.ACCOUNT_NAME + "=? AND " + 63 Calendars.ACCOUNT_TYPE + "=? AND " + 64 Calendars._SYNC_ID + "=?"; 65 66 /** Content selection for getting a calendar id for an account. */ 67 private static final String CALENDAR_SELECTION_ACCOUNT_AND_NO_SYNC = 68 Calendars.ACCOUNT_NAME + "=? AND " + 69 Calendars.ACCOUNT_TYPE + "=? AND " + 70 Calendars._SYNC_ID + " IS NULL"; 71 72 /** The column used to track the timezone of the event. */ 73 private static final String EVENT_SAVED_TIMEZONE_COLUMN = Events.SYNC_DATA1; 74 75 /** Used to keep track of exception vs. parent event dirtiness. */ 76 private static final String EVENT_SYNC_MARK = Events.SYNC_DATA8; 77 78 /** The column used to track the Event version sequence number. */ 79 private static final String EVENT_SYNC_VERSION = Events.SYNC_DATA4; 80 81 /** Projection for getting info about changed events. */ 82 private static final String[] ORIGINAL_EVENT_PROJECTION = { Events.ORIGINAL_ID, Events._ID }; 83 private static final int ORIGINAL_EVENT_ORIGINAL_ID_COLUMN = 0; 84 private static final int ORIGINAL_EVENT_ID_COLUMN = 1; 85 86 /** Content selection for dirty calendar events. */ 87 private static final String DIRTY_EXCEPTION_IN_CALENDAR = Events.DIRTY + "=1 AND " + 88 Events.ORIGINAL_ID + " NOTNULL AND " + Events.CALENDAR_ID + "=?"; 89 90 /** Where clause for updating dirty events. */ 91 private static final String EVENT_ID_AND_CALENDAR_ID = Events._ID + "=? AND " + 92 Events.ORIGINAL_SYNC_ID + " ISNULL AND " + Events.CALENDAR_ID + "=?"; 93 94 /** Content selection for dirty or marked top level events. */ 95 private static final String DIRTY_OR_MARKED_TOP_LEVEL_IN_CALENDAR = "(" + Events.DIRTY + 96 "=1 OR " + EVENT_SYNC_MARK + "= 1) AND " + Events.ORIGINAL_ID + " ISNULL AND " + 97 Events.CALENDAR_ID + "=?"; 98 99 /** Content selection for getting events when handling exceptions. */ 100 private static final String ORIGINAL_EVENT_AND_CALENDAR = Events.ORIGINAL_SYNC_ID + "=? AND " + 101 Events.CALENDAR_ID + "=?"; 102 103 private static final String CATEGORY_TOKENIZER_DELIMITER = "\\"; 104 private static final String ATTENDEE_TOKENIZER_DELIMITER = CATEGORY_TOKENIZER_DELIMITER; 105 106 /** Used to indicate that upsyncs aren't allowed (we catch this in sendLocalChanges) */ 107 private static final String EXTENDED_PROPERTY_UPSYNC_PROHIBITED = "upsyncProhibited"; 108 109 private static final String EXTENDED_PROPERTY_USER_ATTENDEE_STATUS = "userAttendeeStatus"; 110 private static final String EXTENDED_PROPERTY_ATTENDEES = "attendees"; 111 private static final String EXTENDED_PROPERTY_CATEGORIES = "categories"; 112 113 private final android.accounts.Account mAccountManagerAccount; 114 private final long mCalendarId; 115 116 // The following lists are populated as part of upsync, and handled during cleanup. 117 /** Ids of events that were deleted in this upsync. */ 118 private final ArrayList<Long> mDeletedIdList = new ArrayList<Long>(); 119 /** Ids of events that were changed in this upsync. */ 120 private final ArrayList<Long> mUploadedIdList = new ArrayList<Long>(); 121 /** Emails that need to be sent due to this upsync. */ 122 private final ArrayList<Message> mOutgoingMailList = new ArrayList<Message>(); 123 124 public EasCalendarSyncHandler(final Context context, final ContentResolver contentResolver, 125 final android.accounts.Account accountManagerAccount, final Account account, 126 final Mailbox mailbox, final Bundle syncExtras, final SyncResult syncResult) { 127 super(context, contentResolver, account, mailbox, syncExtras, syncResult); 128 mAccountManagerAccount = accountManagerAccount; 129 final Cursor c = mContentResolver.query(Calendars.CONTENT_URI, CALENDAR_ID_PROJECTION, 130 CALENDAR_SELECTION_ACCOUNT_AND_SYNC_ID, 131 new String[] { 132 mAccount.mEmailAddress, 133 Eas.EXCHANGE_ACCOUNT_MANAGER_TYPE, 134 mailbox.mServerId, 135 }, null); 136 if (c == null) { 137 mCalendarId = -1; 138 } else { 139 try { 140 if (c.moveToFirst()) { 141 mCalendarId = c.getLong(CALENDAR_ID_COLUMN); 142 } else { 143 long id = -1; 144 // Check if we have a calendar for this account with no server Id. If so, it was 145 // synced with an older version of the sync adapter before serverId's were 146 // supported. 147 final Cursor c1 = mContentResolver.query(Calendars.CONTENT_URI, 148 CALENDAR_ID_PROJECTION, 149 CALENDAR_SELECTION_ACCOUNT_AND_NO_SYNC, 150 new String[] { 151 mAccount.mEmailAddress, 152 Eas.EXCHANGE_ACCOUNT_MANAGER_TYPE, 153 }, null); 154 if (c1 != null) { 155 try { 156 if (c1.moveToFirst()) { 157 id = c1.getLong(CALENDAR_ID_COLUMN); 158 final ContentValues values = new ContentValues(); 159 values.put(Calendars._SYNC_ID, mMailbox.mServerId); 160 mContentResolver.update( 161 ContentUris.withAppendedId( 162 asSyncAdapter(Calendars.CONTENT_URI), id), 163 values, 164 null, /* where */ 165 null /* selectionArgs */); 166 } 167 } finally { 168 c1.close(); 169 } 170 } 171 172 if (id >= 0) { 173 mCalendarId = id; 174 } else { 175 mCalendarId = CalendarUtilities.createCalendar(mContext, mContentResolver, 176 mAccount, mMailbox); 177 } 178 } 179 } finally { 180 c.close(); 181 } 182 } 183 } 184 185 @Override 186 protected int getTrafficFlag() { 187 return TrafficFlags.DATA_CALENDAR; 188 } 189 190 /** 191 * Adds params to a {@link Uri} to indicate that the caller is a sync adapter, and to add the 192 * account info. 193 * @param uri The {@link Uri} to which to add params. 194 * @return The augmented {@link Uri}. 195 */ 196 private static Uri asSyncAdapter(final Uri uri, final String emailAddress) { 197 return uri.buildUpon().appendQueryParameter(CalendarContract.CALLER_IS_SYNCADAPTER, "true") 198 .appendQueryParameter(Calendars.ACCOUNT_NAME, emailAddress) 199 .appendQueryParameter(Calendars.ACCOUNT_TYPE, Eas.EXCHANGE_ACCOUNT_MANAGER_TYPE) 200 .build(); 201 } 202 203 /** 204 * Convenience wrapper to {@link #asSyncAdapter(android.net.Uri, String)}. 205 */ 206 private Uri asSyncAdapter(final Uri uri) { 207 return asSyncAdapter(uri, mAccount.mEmailAddress); 208 } 209 210 @Override 211 protected String getFolderClassName() { 212 return "Calendar"; 213 } 214 215 216 @Override 217 protected AbstractSyncParser getParser(final InputStream is) throws IOException { 218 return new CalendarSyncParser(mContext, mContentResolver, is, 219 mMailbox, mAccount, mAccountManagerAccount, mCalendarId); 220 } 221 222 @Override 223 protected void setInitialSyncOptions(final Serializer s) throws IOException { 224 // Nothing to do for Calendar. 225 } 226 227 @Override 228 protected void setNonInitialSyncOptions(final Serializer s, int numWindows) throws IOException { 229 final int windowSize = numWindows * PIM_WINDOW_SIZE_CALENDAR; 230 if (windowSize > MAX_WINDOW_SIZE + PIM_WINDOW_SIZE_CALENDAR) { 231 throw new IOException("Max window size reached and still no data"); 232 } 233 setPimSyncOptions(s, Eas.FILTER_2_WEEKS, 234 windowSize < MAX_WINDOW_SIZE ? windowSize : MAX_WINDOW_SIZE); 235 } 236 237 /** 238 * Find all dirty events for our calendar and mark their parents. Also delete any dirty events 239 * that have no parents. 240 * @param calendarIdString {@link #mCalendarId}, as a String. 241 * @param calendarIdArgument calendarIdString, in a String array. 242 */ 243 private void markParentsOfDirtyEvents(final String calendarIdString, 244 final String[] calendarIdArgument) { 245 // We've got to handle exceptions as part of the parent when changes occur, so we need 246 // to find new/changed exceptions and mark the parent dirty 247 final ArrayList<Long> orphanedExceptions = new ArrayList<Long>(); 248 final Cursor c = mContentResolver.query(Events.CONTENT_URI, 249 ORIGINAL_EVENT_PROJECTION, DIRTY_EXCEPTION_IN_CALENDAR, calendarIdArgument, null); 250 if (c != null) { 251 try { 252 final ContentValues cv = new ContentValues(1); 253 // We use _sync_mark here to distinguish dirty parents from parents with dirty 254 // exceptions 255 cv.put(EVENT_SYNC_MARK, "1"); 256 while (c.moveToNext()) { 257 // Mark the parents of dirty exceptions 258 final long parentId = c.getLong(ORIGINAL_EVENT_ORIGINAL_ID_COLUMN); 259 final int cnt = mContentResolver.update(asSyncAdapter(Events.CONTENT_URI), cv, 260 EVENT_ID_AND_CALENDAR_ID, 261 new String[] { Long.toString(parentId), calendarIdString }); 262 // Keep track of any orphaned exceptions 263 if (cnt == 0) { 264 orphanedExceptions.add(c.getLong(ORIGINAL_EVENT_ID_COLUMN)); 265 } 266 } 267 } finally { 268 c.close(); 269 } 270 } 271 272 // Delete any orphaned exceptions 273 for (final long orphan : orphanedExceptions) { 274 LogUtils.d(TAG, "Deleted orphaned exception: %d", orphan); 275 mContentResolver.delete(asSyncAdapter( 276 ContentUris.withAppendedId(Events.CONTENT_URI, orphan)), null, null); 277 } 278 } 279 280 /** 281 * Get the version number of the current event, incrementing it if it's already there. 282 * @param entityValues The {@link ContentValues} for this event. 283 * @return The new version number for this event (i.e. 0 if it's a new event, or the old version 284 * number + 1). 285 */ 286 private static String getEntityVersion(final ContentValues entityValues) { 287 final String version = entityValues.getAsString(EVENT_SYNC_VERSION); 288 // This should never be null, but catch this error anyway 289 // Version should be "0" when we create the event, so use that 290 if (version != null) { 291 // Increment and save 292 try { 293 return Integer.toString((Integer.parseInt(version) + 1)); 294 } catch (final NumberFormatException e) { 295 // Handle the case in which someone writes a non-integer here; 296 // shouldn't happen, but we don't want to kill the sync for his 297 } 298 } 299 return "0"; 300 } 301 302 /** 303 * Convenience method for sending an email to the organizer declining the meeting. 304 * @param entity The {@link Entity} for this event. 305 * @param clientId The client id for this event. 306 */ 307 private void sendDeclinedEmail(final Entity entity, final String clientId) { 308 final Message msg = 309 CalendarUtilities.createMessageForEntity(mContext, entity, 310 Message.FLAG_OUTGOING_MEETING_DECLINE, clientId, mAccount); 311 if (msg != null) { 312 LogUtils.d(TAG, "Queueing declined response to %s", msg.mTo); 313 mOutgoingMailList.add(msg); 314 } 315 } 316 317 /** 318 * Get an integer value from a {@link ContentValues}, or 0 if the value isn't there. 319 * @param cv The {@link ContentValues} to find the value in. 320 * @param column The name of the column in cv to get. 321 * @return The appropriate value as an integer, or 0 if it's not there. 322 */ 323 private static int getInt(final ContentValues cv, final String column) { 324 final Integer i = cv.getAsInteger(column); 325 if (i == null) return 0; 326 return i; 327 } 328 329 /** 330 * Convert {@link Events} visibility values to EAS visibility values. 331 * @param visibility The {@link Events} visibility value. 332 * @return The corresponding EAS visibility value. 333 */ 334 private static String decodeVisibility(final int visibility) { 335 final int easVisibility; 336 switch(visibility) { 337 case Events.ACCESS_DEFAULT: 338 easVisibility = 0; 339 break; 340 case Events.ACCESS_PUBLIC: 341 easVisibility = 1; 342 break; 343 case Events.ACCESS_PRIVATE: 344 easVisibility = 2; 345 break; 346 case Events.ACCESS_CONFIDENTIAL: 347 easVisibility = 3; 348 break; 349 default: 350 easVisibility = 0; 351 break; 352 } 353 return Integer.toString(easVisibility); 354 } 355 356 /** 357 * Write an event to the {@link Serializer} for this upsync. 358 * @param entity The {@link Entity} for this event. 359 * @param clientId The client id for this event. 360 * @param s The {@link Serializer} for this Sync request. 361 * @throws IOException 362 * TODO: This can probably be refactored/cleaned up more. 363 */ 364 private void sendEvent(final Entity entity, final String clientId, final Serializer s) 365 throws IOException { 366 // Serialize for EAS here 367 // Set uid with the client id we created 368 // 1) Serialize the top-level event 369 // 2) Serialize attendees and reminders from subvalues 370 // 3) Look for exceptions and serialize with the top-level event 371 final ContentValues entityValues = entity.getEntityValues(); 372 final boolean isException = (clientId == null); 373 boolean hasAttendees = false; 374 final boolean isChange = entityValues.containsKey(Events._SYNC_ID); 375 final boolean allDay = 376 CalendarUtilities.getIntegerValueAsBoolean(entityValues, Events.ALL_DAY); 377 final TimeZone localTimeZone = TimeZone.getDefault(); 378 379 // NOTE: Exchange 2003 (EAS 2.5) seems to require the "exception deleted" and "exception 380 // start time" data before other data in exceptions. Failure to do so results in a 381 // status 6 error during sync 382 if (isException) { 383 // Send exception deleted flag if necessary 384 final Integer deleted = entityValues.getAsInteger(Events.DELETED); 385 final boolean isDeleted = deleted != null && deleted == 1; 386 final Integer eventStatus = entityValues.getAsInteger(Events.STATUS); 387 final boolean isCanceled = 388 eventStatus != null && eventStatus.equals(Events.STATUS_CANCELED); 389 if (isDeleted || isCanceled) { 390 s.data(Tags.CALENDAR_EXCEPTION_IS_DELETED, "1"); 391 // If we're deleted, the UI will continue to show this exception until we mark 392 // it canceled, so we'll do that here... 393 if (isDeleted && !isCanceled) { 394 final long eventId = entityValues.getAsLong(Events._ID); 395 final ContentValues cv = new ContentValues(1); 396 cv.put(Events.STATUS, Events.STATUS_CANCELED); 397 mContentResolver.update( 398 asSyncAdapter(ContentUris.withAppendedId(Events.CONTENT_URI, eventId)), 399 cv, null, null); 400 } 401 } else { 402 s.data(Tags.CALENDAR_EXCEPTION_IS_DELETED, "0"); 403 } 404 405 // TODO Add reminders to exceptions (allow them to be specified!) 406 Long originalTime = entityValues.getAsLong(Events.ORIGINAL_INSTANCE_TIME); 407 if (originalTime != null) { 408 final boolean originalAllDay = 409 CalendarUtilities.getIntegerValueAsBoolean(entityValues, 410 Events.ORIGINAL_ALL_DAY); 411 if (originalAllDay) { 412 // For all day events, we need our local all-day time 413 originalTime = 414 CalendarUtilities.getLocalAllDayCalendarTime(originalTime, localTimeZone); 415 } 416 s.data(Tags.CALENDAR_EXCEPTION_START_TIME, 417 CalendarUtilities.millisToEasDateTime(originalTime)); 418 } else { 419 // Illegal; what should we do? 420 } 421 } 422 423 if (!isException) { 424 // A time zone is required in all EAS events; we'll use the default if none is set 425 // Exchange 2003 seems to require this first... :-) 426 String timeZoneName = entityValues.getAsString( 427 allDay ? EVENT_SAVED_TIMEZONE_COLUMN : Events.EVENT_TIMEZONE); 428 if (timeZoneName == null) { 429 timeZoneName = localTimeZone.getID(); 430 } 431 s.data(Tags.CALENDAR_TIME_ZONE, 432 CalendarUtilities.timeZoneToTziString(TimeZone.getTimeZone(timeZoneName))); 433 } 434 435 s.data(Tags.CALENDAR_ALL_DAY_EVENT, allDay ? "1" : "0"); 436 437 // DTSTART is always supplied 438 long startTime = entityValues.getAsLong(Events.DTSTART); 439 // Determine endTime; it's either provided as DTEND or we calculate using DURATION 440 // If no DURATION is provided, we default to one hour 441 long endTime; 442 if (entityValues.containsKey(Events.DTEND)) { 443 endTime = entityValues.getAsLong(Events.DTEND); 444 } else { 445 long durationMillis = DateUtils.HOUR_IN_MILLIS; 446 if (entityValues.containsKey(Events.DURATION)) { 447 final Duration duration = new Duration(); 448 try { 449 duration.parse(entityValues.getAsString(Events.DURATION)); 450 durationMillis = duration.getMillis(); 451 } catch (DateException e) { 452 // Can't do much about this; use the default (1 hour) 453 } 454 } 455 endTime = startTime + durationMillis; 456 } 457 if (allDay) { 458 startTime = CalendarUtilities.getLocalAllDayCalendarTime(startTime, localTimeZone); 459 endTime = CalendarUtilities.getLocalAllDayCalendarTime(endTime, localTimeZone); 460 } 461 s.data(Tags.CALENDAR_START_TIME, CalendarUtilities.millisToEasDateTime(startTime)); 462 s.data(Tags.CALENDAR_END_TIME, CalendarUtilities.millisToEasDateTime(endTime)); 463 464 s.data(Tags.CALENDAR_DTSTAMP, 465 CalendarUtilities.millisToEasDateTime(System.currentTimeMillis())); 466 467 String loc = entityValues.getAsString(Events.EVENT_LOCATION); 468 if (!TextUtils.isEmpty(loc)) { 469 if (getProtocolVersion() < Eas.SUPPORTED_PROTOCOL_EX2007_DOUBLE) { 470 // EAS 2.5 doesn't like bare line feeds 471 loc = Utility.replaceBareLfWithCrlf(loc); 472 } 473 s.data(Tags.CALENDAR_LOCATION, loc); 474 } 475 s.writeStringValue(entityValues, Events.TITLE, Tags.CALENDAR_SUBJECT); 476 477 if (getProtocolVersion() >= Eas.SUPPORTED_PROTOCOL_EX2007_DOUBLE) { 478 s.start(Tags.BASE_BODY); 479 s.data(Tags.BASE_TYPE, "1"); 480 s.writeStringValue(entityValues, Events.DESCRIPTION, Tags.BASE_DATA); 481 s.end(); 482 } else { 483 // EAS 2.5 doesn't like bare line feeds 484 s.writeStringValue(entityValues, Events.DESCRIPTION, Tags.CALENDAR_BODY); 485 } 486 487 if (!isException) { 488 // For Exchange 2003, only upsync if the event is new 489 if ((getProtocolVersion() >= Eas.SUPPORTED_PROTOCOL_EX2007_DOUBLE) || !isChange) { 490 s.writeStringValue(entityValues, Events.ORGANIZER, Tags.CALENDAR_ORGANIZER_EMAIL); 491 } 492 493 final String rrule = entityValues.getAsString(Events.RRULE); 494 if (rrule != null) { 495 CalendarUtilities.recurrenceFromRrule(rrule, startTime, localTimeZone, s); 496 } 497 498 // Handle associated data EXCEPT for attendees, which have to be grouped 499 final ArrayList<Entity.NamedContentValues> subValues = entity.getSubValues(); 500 // The earliest of the reminders for this Event; we can only send one reminder... 501 int earliestReminder = -1; 502 for (final Entity.NamedContentValues ncv: subValues) { 503 final Uri ncvUri = ncv.uri; 504 final ContentValues ncvValues = ncv.values; 505 if (ncvUri.equals(ExtendedProperties.CONTENT_URI)) { 506 final String propertyName = ncvValues.getAsString(ExtendedProperties.NAME); 507 final String propertyValue = ncvValues.getAsString(ExtendedProperties.VALUE); 508 if (TextUtils.isEmpty(propertyValue)) { 509 continue; 510 } 511 if (propertyName.equals(EXTENDED_PROPERTY_CATEGORIES)) { 512 // Send all the categories back to the server 513 // We've saved them as a String of delimited tokens 514 final StringTokenizer st = 515 new StringTokenizer(propertyValue, CATEGORY_TOKENIZER_DELIMITER); 516 if (st.countTokens() > 0) { 517 s.start(Tags.CALENDAR_CATEGORIES); 518 while (st.hasMoreTokens()) { 519 s.data(Tags.CALENDAR_CATEGORY, st.nextToken()); 520 } 521 s.end(); 522 } 523 } 524 } else if (ncvUri.equals(Reminders.CONTENT_URI)) { 525 Integer mins = ncvValues.getAsInteger(Reminders.MINUTES); 526 if (mins != null) { 527 // -1 means "default", which for Exchange, is 30 528 if (mins < 0) { 529 mins = 30; 530 } 531 // Save this away if it's the earliest reminder (greatest minutes) 532 if (mins > earliestReminder) { 533 earliestReminder = mins; 534 } 535 } 536 } 537 } 538 539 // If we have a reminder, send it to the server 540 if (earliestReminder >= 0) { 541 s.data(Tags.CALENDAR_REMINDER_MINS_BEFORE, Integer.toString(earliestReminder)); 542 } 543 544 // We've got to send a UID, unless this is an exception. If the event is new, we've 545 // generated one; if not, we should have gotten one from extended properties. 546 if (clientId != null) { 547 s.data(Tags.CALENDAR_UID, clientId); 548 } 549 550 // Handle attendee data here; keep track of organizer and stream it afterward 551 String organizerName = null; 552 String organizerEmail = null; 553 for (final Entity.NamedContentValues ncv: subValues) { 554 final Uri ncvUri = ncv.uri; 555 final ContentValues ncvValues = ncv.values; 556 if (ncvUri.equals(Attendees.CONTENT_URI)) { 557 final Integer relationship = 558 ncvValues.getAsInteger(Attendees.ATTENDEE_RELATIONSHIP); 559 // If there's no relationship, we can't create this for EAS 560 // Similarly, we need an attendee email for each invitee 561 if (relationship != null && ncvValues.containsKey(Attendees.ATTENDEE_EMAIL)) { 562 // Organizer isn't among attendees in EAS 563 if (relationship == Attendees.RELATIONSHIP_ORGANIZER) { 564 organizerName = ncvValues.getAsString(Attendees.ATTENDEE_NAME); 565 organizerEmail = ncvValues.getAsString(Attendees.ATTENDEE_EMAIL); 566 continue; 567 } 568 if (!hasAttendees) { 569 s.start(Tags.CALENDAR_ATTENDEES); 570 hasAttendees = true; 571 } 572 s.start(Tags.CALENDAR_ATTENDEE); 573 final String attendeeEmail = 574 ncvValues.getAsString(Attendees.ATTENDEE_EMAIL); 575 String attendeeName = ncvValues.getAsString(Attendees.ATTENDEE_NAME); 576 if (attendeeName == null) { 577 attendeeName = attendeeEmail; 578 } 579 s.data(Tags.CALENDAR_ATTENDEE_NAME, attendeeName); 580 s.data(Tags.CALENDAR_ATTENDEE_EMAIL, attendeeEmail); 581 if (getProtocolVersion() >= Eas.SUPPORTED_PROTOCOL_EX2007_DOUBLE) { 582 s.data(Tags.CALENDAR_ATTENDEE_TYPE, "1"); // Required 583 } 584 s.end(); // Attendee 585 } 586 } 587 } 588 if (hasAttendees) { 589 s.end(); // Attendees 590 } 591 592 // Get busy status from availability 593 final int availability = entityValues.getAsInteger(Events.AVAILABILITY); 594 final int busyStatus = CalendarUtilities.busyStatusFromAvailability(availability); 595 s.data(Tags.CALENDAR_BUSY_STATUS, Integer.toString(busyStatus)); 596 597 // Meeting status, 0 = appointment, 1 = meeting, 3 = attendee 598 // In JB, organizer won't be an attendee 599 if (organizerEmail == null && entityValues.containsKey(Events.ORGANIZER)) { 600 organizerEmail = entityValues.getAsString(Events.ORGANIZER); 601 } 602 if (mAccount.mEmailAddress.equalsIgnoreCase(organizerEmail)) { 603 s.data(Tags.CALENDAR_MEETING_STATUS, hasAttendees ? "1" : "0"); 604 } else { 605 s.data(Tags.CALENDAR_MEETING_STATUS, "3"); 606 } 607 608 // For Exchange 2003, only upsync if the event is new 609 if (((getProtocolVersion() >= Eas.SUPPORTED_PROTOCOL_EX2007_DOUBLE) || !isChange) && 610 organizerName != null) { 611 s.data(Tags.CALENDAR_ORGANIZER_NAME, organizerName); 612 } 613 614 // NOTE: Sensitivity must NOT be sent to the server for exceptions in Exchange 2003 615 // The result will be a status 6 failure during sync 616 final Integer visibility = entityValues.getAsInteger(Events.ACCESS_LEVEL); 617 if (visibility != null) { 618 s.data(Tags.CALENDAR_SENSITIVITY, decodeVisibility(visibility)); 619 } else { 620 // Default to private if not set 621 s.data(Tags.CALENDAR_SENSITIVITY, "1"); 622 } 623 } 624 } 625 626 /** 627 * Handle exceptions to an event's recurrance pattern. 628 * @param s The {@link Serializer} for this upsync. 629 * @param entity The {@link Entity} for this event. 630 * @param entityValues The {@link ContentValues} for entity. 631 * @param serverId The server side id for this event. 632 * @param clientId The client side id for this event. 633 * @param calendarIdString The calendar id, as a {@link String}. 634 * @param selfOrganizer Whether the user is the organizer of this event. 635 * @throws IOException 636 */ 637 private void handleExceptionsToRecurrenceRules(final Serializer s, final Entity entity, 638 final ContentValues entityValues, final String serverId, final String clientId, 639 final String calendarIdString, final boolean selfOrganizer) throws IOException { 640 final EntityIterator exIterator = EventsEntity.newEntityIterator(mContentResolver.query( 641 asSyncAdapter(Events.CONTENT_URI), null, ORIGINAL_EVENT_AND_CALENDAR, 642 new String[] { serverId, calendarIdString }, null), mContentResolver); 643 boolean exFirst = true; 644 while (exIterator.hasNext()) { 645 final Entity exEntity = exIterator.next(); 646 if (exFirst) { 647 s.start(Tags.CALENDAR_EXCEPTIONS); 648 exFirst = false; 649 } 650 s.start(Tags.CALENDAR_EXCEPTION); 651 sendEvent(exEntity, null, s); 652 final ContentValues exValues = exEntity.getEntityValues(); 653 if (getInt(exValues, Events.DIRTY) == 1) { 654 // This is a new/updated exception, so we've got to notify our 655 // attendees about it 656 final long exEventId = exValues.getAsLong(Events._ID); 657 658 // Copy subvalues into the exception; otherwise, we won't see the 659 // attendees when preparing the message 660 for (final Entity.NamedContentValues ncv: entity.getSubValues()) { 661 exEntity.addSubValue(ncv.uri, ncv.values); 662 } 663 664 final int flag; 665 if ((getInt(exValues, Events.DELETED) == 1) || 666 (getInt(exValues, Events.STATUS) == Events.STATUS_CANCELED)) { 667 flag = Message.FLAG_OUTGOING_MEETING_CANCEL; 668 if (!selfOrganizer) { 669 // Send a cancellation notice to the organizer 670 // Since CalendarProvider2 sets the organizer of exceptions 671 // to the user, we have to reset it first to the original 672 // organizer 673 exValues.put(Events.ORGANIZER, entityValues.getAsString(Events.ORGANIZER)); 674 sendDeclinedEmail(exEntity, clientId); 675 } 676 } else { 677 flag = Message.FLAG_OUTGOING_MEETING_INVITE; 678 } 679 // Add the eventId of the exception to the uploaded id list, so that 680 // the dirty/mark bits are cleared 681 mUploadedIdList.add(exEventId); 682 683 // Copy version so the ics attachment shows the proper sequence # 684 exValues.put(EVENT_SYNC_VERSION, 685 entityValues.getAsString(EVENT_SYNC_VERSION)); 686 // Copy location so that it's included in the outgoing email 687 if (entityValues.containsKey(Events.EVENT_LOCATION)) { 688 exValues.put(Events.EVENT_LOCATION, 689 entityValues.getAsString(Events.EVENT_LOCATION)); 690 } 691 692 if (selfOrganizer) { 693 final Message msg = CalendarUtilities.createMessageForEntity(mContext, exEntity, 694 flag, clientId, mAccount); 695 if (msg != null) { 696 LogUtils.d(TAG, "Queueing exception update to %s", msg.mTo); 697 mOutgoingMailList.add(msg); 698 } 699 } 700 } 701 s.end(); // EXCEPTION 702 } 703 if (!exFirst) { 704 s.end(); // EXCEPTIONS 705 } 706 } 707 708 /** 709 * Update the event properties with the attendee list, and send mail as appropriate. 710 * @param entity The {@link Entity} for this event. 711 * @param entityValues The {@link ContentValues} for entity. 712 * @param selfOrganizer Whether the user is the organizer of this event. 713 * @param eventId The id for this event. 714 * @param clientId The client side id for this event. 715 */ 716 private void updateAttendeesAndSendMail(final Entity entity, final ContentValues entityValues, 717 final boolean selfOrganizer, final long eventId, final String clientId) { 718 // Go through the extended properties of this Event and pull out our tokenized 719 // attendees list and the user attendee status; we will need them later 720 String attendeeString = null; 721 long attendeeStringId = -1; 722 String userAttendeeStatus = null; 723 long userAttendeeStatusId = -1; 724 for (final Entity.NamedContentValues ncv: entity.getSubValues()) { 725 if (ncv.uri.equals(ExtendedProperties.CONTENT_URI)) { 726 final ContentValues ncvValues = ncv.values; 727 final String propertyName = ncvValues.getAsString(ExtendedProperties.NAME); 728 if (propertyName.equals(EXTENDED_PROPERTY_ATTENDEES)) { 729 attendeeString = ncvValues.getAsString(ExtendedProperties.VALUE); 730 attendeeStringId = ncvValues.getAsLong(ExtendedProperties._ID); 731 } else if (propertyName.equals(EXTENDED_PROPERTY_USER_ATTENDEE_STATUS)) { 732 userAttendeeStatus = ncvValues.getAsString(ExtendedProperties.VALUE); 733 userAttendeeStatusId = ncvValues.getAsLong(ExtendedProperties._ID); 734 } 735 } 736 } 737 738 // Send the meeting invite if there are attendees and we're the organizer AND 739 // if the Event itself is dirty (we might be syncing only because an exception 740 // is dirty, in which case we DON'T send email about the Event) 741 if (selfOrganizer && (getInt(entityValues, Events.DIRTY) == 1)) { 742 final Message msg = 743 CalendarUtilities.createMessageForEventId(mContext, eventId, 744 Message.FLAG_OUTGOING_MEETING_INVITE, clientId, mAccount); 745 if (msg != null) { 746 LogUtils.d(TAG, "Queueing invitation to %s", msg.mTo); 747 mOutgoingMailList.add(msg); 748 } 749 // Make a list out of our tokenized attendees, if we have any 750 final ArrayList<String> originalAttendeeList = new ArrayList<String>(); 751 if (attendeeString != null) { 752 final StringTokenizer st = 753 new StringTokenizer(attendeeString, ATTENDEE_TOKENIZER_DELIMITER); 754 while (st.hasMoreTokens()) { 755 originalAttendeeList.add(st.nextToken()); 756 } 757 } 758 final StringBuilder newTokenizedAttendees = new StringBuilder(); 759 // See if any attendees have been dropped and while we're at it, build 760 // an updated String with tokenized attendee addresses 761 for (final Entity.NamedContentValues ncv: entity.getSubValues()) { 762 if (ncv.uri.equals(Attendees.CONTENT_URI)) { 763 final String attendeeEmail = ncv.values.getAsString(Attendees.ATTENDEE_EMAIL); 764 // Remove all found attendees 765 originalAttendeeList.remove(attendeeEmail); 766 newTokenizedAttendees.append(attendeeEmail); 767 newTokenizedAttendees.append(ATTENDEE_TOKENIZER_DELIMITER); 768 } 769 } 770 // Update extended properties with the new attendee list, if we have one 771 // Otherwise, create one (this would be the case for Events created on 772 // device or "legacy" events (before this code was added) 773 final ContentValues cv = new ContentValues(); 774 cv.put(ExtendedProperties.VALUE, newTokenizedAttendees.toString()); 775 if (attendeeString != null) { 776 mContentResolver.update(asSyncAdapter(ContentUris.withAppendedId( 777 ExtendedProperties.CONTENT_URI, attendeeStringId)), cv, null, null); 778 } else { 779 // If there wasn't an "attendees" property, insert one 780 cv.put(ExtendedProperties.NAME, EXTENDED_PROPERTY_ATTENDEES); 781 cv.put(ExtendedProperties.EVENT_ID, eventId); 782 mContentResolver.insert(asSyncAdapter(ExtendedProperties.CONTENT_URI), cv); 783 } 784 // Whoever is left has been removed from the attendee list; send them 785 // a cancellation 786 for (final String removedAttendee: originalAttendeeList) { 787 // Send a cancellation message to each of them 788 final Message cancelMsg = CalendarUtilities.createMessageForEventId(mContext, 789 eventId, Message.FLAG_OUTGOING_MEETING_CANCEL, clientId, mAccount, 790 removedAttendee); 791 if (cancelMsg != null) { 792 // Just send it to the removed attendee 793 LogUtils.d(TAG, "Queueing cancellation to removed attendee %s", cancelMsg.mTo); 794 mOutgoingMailList.add(cancelMsg); 795 } 796 } 797 } else if (!selfOrganizer) { 798 // If we're not the organizer, see if we've changed our attendee status 799 // Our last synced attendee status is in ExtendedProperties, and we've 800 // retrieved it above as userAttendeeStatus 801 final int currentStatus = entityValues.getAsInteger(Events.SELF_ATTENDEE_STATUS); 802 int syncStatus = Attendees.ATTENDEE_STATUS_NONE; 803 if (userAttendeeStatus != null) { 804 try { 805 syncStatus = Integer.parseInt(userAttendeeStatus); 806 } catch (NumberFormatException e) { 807 // Just in case somebody else mucked with this and it's not Integer 808 } 809 } 810 if ((currentStatus != syncStatus) && 811 (currentStatus != Attendees.ATTENDEE_STATUS_NONE)) { 812 // If so, send a meeting reply 813 final int messageFlag; 814 switch (currentStatus) { 815 case Attendees.ATTENDEE_STATUS_ACCEPTED: 816 messageFlag = Message.FLAG_OUTGOING_MEETING_ACCEPT; 817 break; 818 case Attendees.ATTENDEE_STATUS_DECLINED: 819 messageFlag = Message.FLAG_OUTGOING_MEETING_DECLINE; 820 break; 821 case Attendees.ATTENDEE_STATUS_TENTATIVE: 822 messageFlag = Message.FLAG_OUTGOING_MEETING_TENTATIVE; 823 break; 824 default: 825 messageFlag = 0; 826 break; 827 } 828 // Make sure we have a valid status (messageFlag should never be zero) 829 if (messageFlag != 0 && userAttendeeStatusId >= 0) { 830 // Save away the new status 831 final ContentValues cv = new ContentValues(1); 832 cv.put(ExtendedProperties.VALUE, Integer.toString(currentStatus)); 833 mContentResolver.update(asSyncAdapter(ContentUris.withAppendedId( 834 ExtendedProperties.CONTENT_URI, userAttendeeStatusId)), 835 cv, null, null); 836 // Send mail to the organizer advising of the new status 837 final Message msg = CalendarUtilities.createMessageForEventId(mContext, eventId, 838 messageFlag, clientId, mAccount); 839 if (msg != null) { 840 LogUtils.d(TAG, "Queueing invitation reply to %s", msg.mTo); 841 mOutgoingMailList.add(msg); 842 } 843 } 844 } 845 } 846 } 847 848 /** 849 * Process a single event, adding to the {@link Serializer} as necessary. 850 * @param s The {@link Serializer} for this Sync request. 851 * @param entity The {@link Entity} for this event. 852 * @param calendarIdString The calendar's id, as a {@link String}. 853 * @param first Whether this would be the first event added to s. 854 * @return Whether this function added anything to s. 855 * @throws IOException 856 */ 857 private boolean handleEntity(final Serializer s, final Entity entity, 858 final String calendarIdString, final boolean first) throws IOException { 859 // For each of these entities, create the change commands 860 final ContentValues entityValues = entity.getEntityValues(); 861 // We first need to check whether we can upsync this event; our test for this 862 // is currently the value of EXTENDED_PROPERTY_ATTENDEES_REDACTED 863 // If this is set to "1", we can't upsync the event 864 for (final Entity.NamedContentValues ncv: entity.getSubValues()) { 865 if (ncv.uri.equals(ExtendedProperties.CONTENT_URI)) { 866 final ContentValues ncvValues = ncv.values; 867 if (ncvValues.getAsString(ExtendedProperties.NAME).equals( 868 EXTENDED_PROPERTY_UPSYNC_PROHIBITED)) { 869 if ("1".equals(ncvValues.getAsString(ExtendedProperties.VALUE))) { 870 // Make sure we mark this to clear the dirty flag 871 mUploadedIdList.add(entityValues.getAsLong(Events._ID)); 872 return false; 873 } 874 } 875 } 876 } 877 878 // EAS 2.5 needs: BusyStatus DtStamp EndTime Sensitivity StartTime TimeZone UID 879 // We can generate all but what we're testing for below 880 final String organizerEmail = entityValues.getAsString(Events.ORGANIZER); 881 if (organizerEmail == null || !entityValues.containsKey(Events.DTSTART) || 882 (!entityValues.containsKey(Events.DURATION) 883 && !entityValues.containsKey(Events.DTEND))) { 884 return false; 885 } 886 887 if (first) { 888 s.start(Tags.SYNC_COMMANDS); 889 LogUtils.d(TAG, "Sending Calendar changes to the server"); 890 } 891 892 final boolean selfOrganizer = organizerEmail.equalsIgnoreCase(mAccount.mEmailAddress); 893 // Find our uid in the entity; otherwise create one 894 String clientId = entityValues.getAsString(Events.SYNC_DATA2); 895 if (clientId == null) { 896 clientId = UUID.randomUUID().toString(); 897 } 898 final String serverId = entityValues.getAsString(Events._SYNC_ID); 899 final long eventId = entityValues.getAsLong(Events._ID); 900 if (serverId == null) { 901 // This is a new event; create a clientId 902 LogUtils.d(TAG, "Creating new event with clientId: %s", clientId); 903 s.start(Tags.SYNC_ADD).data(Tags.SYNC_CLIENT_ID, clientId); 904 // And save it in the Event as the local id 905 final ContentValues cv = new ContentValues(2); 906 cv.put(Events.SYNC_DATA2, clientId); 907 cv.put(EVENT_SYNC_VERSION, "0"); 908 mContentResolver.update( 909 asSyncAdapter(ContentUris.withAppendedId(Events.CONTENT_URI, eventId)), 910 cv, null, null); 911 } else if (entityValues.getAsInteger(Events.DELETED) == 1) { 912 LogUtils.d(TAG, "Deleting event with serverId: %s", serverId); 913 s.start(Tags.SYNC_DELETE).data(Tags.SYNC_SERVER_ID, serverId).end(); 914 mDeletedIdList.add(eventId); 915 if (selfOrganizer) { 916 final Message msg = CalendarUtilities.createMessageForEventId(mContext, 917 eventId, Message.FLAG_OUTGOING_MEETING_CANCEL, null, mAccount); 918 if (msg != null) { 919 LogUtils.d(TAG, "Queueing cancellation to %s", msg.mTo); 920 mOutgoingMailList.add(msg); 921 } 922 } else { 923 sendDeclinedEmail(entity, clientId); 924 } 925 // For deletions, we don't need to add application data, so just bail here. 926 return true; 927 } else { 928 LogUtils.d(TAG, "Upsync change to event with serverId: %s", serverId); 929 s.start(Tags.SYNC_CHANGE).data(Tags.SYNC_SERVER_ID, serverId); 930 // Save to the ContentResolver. 931 final String version = getEntityVersion(entityValues); 932 final ContentValues cv = new ContentValues(1); 933 cv.put(EVENT_SYNC_VERSION, version); 934 mContentResolver.update( 935 asSyncAdapter( ContentUris.withAppendedId(Events.CONTENT_URI, eventId)), 936 cv, null, null); 937 // Also save in entityValues so that we send it this time around 938 entityValues.put(EVENT_SYNC_VERSION, version); 939 } 940 s.start(Tags.SYNC_APPLICATION_DATA); 941 sendEvent(entity, clientId, s); 942 943 // Now, the hard part; find exceptions for this event 944 if (serverId != null) { 945 handleExceptionsToRecurrenceRules(s, entity, entityValues, serverId, clientId, 946 calendarIdString, selfOrganizer); 947 } 948 949 s.end().end(); // ApplicationData & Add/Change 950 mUploadedIdList.add(eventId); 951 updateAttendeesAndSendMail(entity, entityValues, selfOrganizer, eventId, clientId); 952 return true; 953 } 954 955 @Override 956 protected void setUpsyncCommands(final Serializer s) throws IOException { 957 final String calendarIdString = Long.toString(mCalendarId); 958 final String[] calendarIdArgument = { calendarIdString }; 959 960 markParentsOfDirtyEvents(calendarIdString, calendarIdArgument); 961 962 // Now go through dirty/marked top-level events and send them back to the server 963 final EntityIterator eventIterator = EventsEntity.newEntityIterator( 964 mContentResolver.query(asSyncAdapter(Events.CONTENT_URI), null, 965 DIRTY_OR_MARKED_TOP_LEVEL_IN_CALENDAR, calendarIdArgument, null), mContentResolver); 966 967 try { 968 boolean first = true; 969 while (eventIterator.hasNext()) { 970 final boolean addedCommand = 971 handleEntity(s, eventIterator.next(), calendarIdString, first); 972 if (addedCommand) { 973 first = false; 974 } 975 } 976 if (!first) { 977 s.end(); // Commands 978 } 979 } finally { 980 eventIterator.close(); 981 } 982 } 983 984 @Override 985 protected void cleanup(final int syncResult) { 986 if (syncResult != SYNC_RESULT_FAILED) { 987 // Clear dirty and mark flags for updates sent to server 988 if (!mUploadedIdList.isEmpty()) { 989 final ContentValues cv = new ContentValues(2); 990 cv.put(Events.DIRTY, 0); 991 cv.put(EVENT_SYNC_MARK, "0"); 992 for (final long eventId : mUploadedIdList) { 993 mContentResolver.update(asSyncAdapter(ContentUris.withAppendedId( 994 Events.CONTENT_URI, eventId)), cv, null, null); 995 } 996 } 997 // Delete events marked for deletion 998 if (!mDeletedIdList.isEmpty()) { 999 for (final long eventId : mDeletedIdList) { 1000 mContentResolver.delete(asSyncAdapter(ContentUris.withAppendedId( 1001 Events.CONTENT_URI, eventId)), null, null); 1002 } 1003 } 1004 // Send all messages that were created during this sync. 1005 for (final Message msg : mOutgoingMailList) { 1006 sendMessage(mAccount, msg); 1007 } 1008 } 1009 // Clear our lists for the next Sync request, if necessary. 1010 if (syncResult != SYNC_RESULT_MORE_AVAILABLE) { 1011 mDeletedIdList.clear(); 1012 mUploadedIdList.clear(); 1013 mOutgoingMailList.clear(); 1014 } 1015 } 1016 1017 /** 1018 * Delete an account from the Calendar provider. 1019 * @param context Our {@link Context} 1020 * @param emailAddress The email address of the account we wish to delete 1021 */ 1022 public static void wipeAccountFromContentProvider(final Context context, 1023 final String emailAddress) { 1024 context.getContentResolver().delete(asSyncAdapter(Calendars.CONTENT_URI, emailAddress), 1025 Calendars.ACCOUNT_NAME + "=" + DatabaseUtils.sqlEscapeString(emailAddress) 1026 + " AND " + Calendars.ACCOUNT_TYPE + "="+ DatabaseUtils.sqlEscapeString( 1027 context.getString(R.string.account_manager_type_exchange)), null); 1028 } 1029 } 1030