1 /* 2 * Copyright (C) 2008-2009 Marc Blank 3 * Licensed to The Android Open Source Project. 4 * 5 * Licensed under the Apache License, Version 2.0 (the "License"); 6 * you may not use this file except in compliance with the License. 7 * You may obtain a copy of the License at 8 * 9 * http://www.apache.org/licenses/LICENSE-2.0 10 * 11 * Unless required by applicable law or agreed to in writing, software 12 * distributed under the License is distributed on an "AS IS" BASIS, 13 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 * See the License for the specific language governing permissions and 15 * limitations under the License. 16 */ 17 18 package com.android.exchange.adapter; 19 20 import android.content.ContentProviderClient; 21 import android.content.ContentProviderOperation; 22 import android.content.ContentProviderResult; 23 import android.content.ContentResolver; 24 import android.content.ContentUris; 25 import android.content.ContentValues; 26 import android.content.Entity; 27 import android.content.Entity.NamedContentValues; 28 import android.content.EntityIterator; 29 import android.content.OperationApplicationException; 30 import android.database.Cursor; 31 import android.database.DatabaseUtils; 32 import android.net.Uri; 33 import android.os.RemoteException; 34 import android.provider.CalendarContract; 35 import android.provider.CalendarContract.Attendees; 36 import android.provider.CalendarContract.Calendars; 37 import android.provider.CalendarContract.Events; 38 import android.provider.CalendarContract.EventsEntity; 39 import android.provider.CalendarContract.ExtendedProperties; 40 import android.provider.CalendarContract.Reminders; 41 import android.provider.CalendarContract.SyncState; 42 import android.provider.ContactsContract.RawContacts; 43 import android.provider.SyncStateContract; 44 import android.text.TextUtils; 45 import android.util.Log; 46 47 import com.android.emailcommon.AccountManagerTypes; 48 import com.android.emailcommon.provider.EmailContent; 49 import com.android.emailcommon.provider.EmailContent.Message; 50 import com.android.emailcommon.utility.Utility; 51 import com.android.exchange.CommandStatusException; 52 import com.android.exchange.Eas; 53 import com.android.exchange.EasOutboxService; 54 import com.android.exchange.EasSyncService; 55 import com.android.exchange.ExchangeService; 56 import com.android.exchange.utility.CalendarUtilities; 57 import com.android.exchange.utility.Duration; 58 59 import java.io.IOException; 60 import java.io.InputStream; 61 import java.text.ParseException; 62 import java.util.ArrayList; 63 import java.util.GregorianCalendar; 64 import java.util.Map.Entry; 65 import java.util.StringTokenizer; 66 import java.util.TimeZone; 67 import java.util.UUID; 68 69 /** 70 * Sync adapter class for EAS calendars 71 * 72 */ 73 public class CalendarSyncAdapter extends AbstractSyncAdapter { 74 75 private static final String TAG = "EasCalendarSyncAdapter"; 76 77 private static final String EVENT_SAVED_TIMEZONE_COLUMN = Events.SYNC_DATA1; 78 /** 79 * Used to keep track of exception vs parent event dirtiness. 80 */ 81 private static final String EVENT_SYNC_MARK = Events.SYNC_DATA8; 82 private static final String EVENT_SYNC_VERSION = Events.SYNC_DATA4; 83 // Since exceptions will have the same _SYNC_ID as the original event we have to check that 84 // there's no original event when finding an item by _SYNC_ID 85 private static final String SERVER_ID_AND_CALENDAR_ID = Events._SYNC_ID + "=? AND " + 86 Events.ORIGINAL_SYNC_ID + " ISNULL AND " + Events.CALENDAR_ID + "=?"; 87 private static final String EVENT_ID_AND_CALENDAR_ID = Events._ID + "=? AND " + 88 Events.ORIGINAL_SYNC_ID + " ISNULL AND " + Events.CALENDAR_ID + "=?"; 89 private static final String DIRTY_OR_MARKED_TOP_LEVEL_IN_CALENDAR = "(" + Events.DIRTY 90 + "=1 OR " + EVENT_SYNC_MARK + "= 1) AND " + 91 Events.ORIGINAL_ID + " ISNULL AND " + Events.CALENDAR_ID + "=?"; 92 private static final String DIRTY_EXCEPTION_IN_CALENDAR = 93 Events.DIRTY + "=1 AND " + Events.ORIGINAL_ID + " NOTNULL AND " + 94 Events.CALENDAR_ID + "=?"; 95 private static final String CLIENT_ID_SELECTION = Events.SYNC_DATA2 + "=?"; 96 private static final String ORIGINAL_EVENT_AND_CALENDAR = 97 Events.ORIGINAL_SYNC_ID + "=? AND " + Events.CALENDAR_ID + "=?"; 98 private static final String ATTENDEES_EXCEPT_ORGANIZER = Attendees.EVENT_ID + "=? AND " + 99 Attendees.ATTENDEE_RELATIONSHIP + "!=" + Attendees.RELATIONSHIP_ORGANIZER; 100 private static final String[] ID_PROJECTION = new String[] {Events._ID}; 101 private static final String[] ORIGINAL_EVENT_PROJECTION = 102 new String[] {Events.ORIGINAL_ID, Events._ID}; 103 private static final String EVENT_ID_AND_NAME = 104 ExtendedProperties.EVENT_ID + "=? AND " + ExtendedProperties.NAME + "=?"; 105 106 // Note that we use LIKE below for its case insensitivity 107 private static final String EVENT_AND_EMAIL = 108 Attendees.EVENT_ID + "=? AND "+ Attendees.ATTENDEE_EMAIL + " LIKE ?"; 109 private static final int ATTENDEE_STATUS_COLUMN_STATUS = 0; 110 private static final String[] ATTENDEE_STATUS_PROJECTION = 111 new String[] {Attendees.ATTENDEE_STATUS}; 112 113 public static final String CALENDAR_SELECTION = 114 Calendars.ACCOUNT_NAME + "=? AND " + Calendars.ACCOUNT_TYPE + "=?"; 115 private static final int CALENDAR_SELECTION_ID = 0; 116 117 private static final String[] EXTENDED_PROPERTY_PROJECTION = 118 new String[] {ExtendedProperties._ID}; 119 private static final int EXTENDED_PROPERTY_ID = 0; 120 121 private static final String CATEGORY_TOKENIZER_DELIMITER = "\\"; 122 private static final String ATTENDEE_TOKENIZER_DELIMITER = CATEGORY_TOKENIZER_DELIMITER; 123 124 private static final String EXTENDED_PROPERTY_USER_ATTENDEE_STATUS = "userAttendeeStatus"; 125 private static final String EXTENDED_PROPERTY_ATTENDEES = "attendees"; 126 private static final String EXTENDED_PROPERTY_DTSTAMP = "dtstamp"; 127 private static final String EXTENDED_PROPERTY_MEETING_STATUS = "meeting_status"; 128 private static final String EXTENDED_PROPERTY_CATEGORIES = "categories"; 129 // Used to indicate that we removed the attendee list because it was too large 130 private static final String EXTENDED_PROPERTY_ATTENDEES_REDACTED = "attendeesRedacted"; 131 // Used to indicate that upsyncs aren't allowed (we catch this in sendLocalChanges) 132 private static final String EXTENDED_PROPERTY_UPSYNC_PROHIBITED = "upsyncProhibited"; 133 134 private static final ContentProviderOperation PLACEHOLDER_OPERATION = 135 ContentProviderOperation.newInsert(Uri.EMPTY).build(); 136 137 private static final Object sSyncKeyLock = new Object(); 138 139 private static final TimeZone UTC_TIMEZONE = TimeZone.getTimeZone("UTC"); 140 private final TimeZone mLocalTimeZone = TimeZone.getDefault(); 141 142 143 // Maximum number of allowed attendees; above this number, we mark the Event with the 144 // attendeesRedacted extended property and don't allow the event to be upsynced to the server 145 private static final int MAX_SYNCED_ATTENDEES = 50; 146 // We set the organizer to this when the user is the organizer and we've redacted the 147 // attendee list. By making the meeting organizer OTHER than the user, we cause the UI to 148 // prevent edits to this event (except local changes like reminder). 149 private static final String BOGUS_ORGANIZER_EMAIL = "upload_disallowed (at) uploadisdisallowed.aaa"; 150 // Maximum number of CPO's before we start redacting attendees in exceptions 151 // The number 500 has been determined empirically; 1500 CPOs appears to be the limit before 152 // binder failures occur, but we need room at any point for additional events/exceptions so 153 // we set our limit at 1/3 of the apparent maximum for extra safety 154 // TODO Find a better solution to this workaround 155 private static final int MAX_OPS_BEFORE_EXCEPTION_ATTENDEE_REDACTION = 500; 156 157 private long mCalendarId = -1; 158 private String mCalendarIdString; 159 private String[] mCalendarIdArgument; 160 /*package*/ String mEmailAddress; 161 162 private ArrayList<Long> mDeletedIdList = new ArrayList<Long>(); 163 private ArrayList<Long> mUploadedIdList = new ArrayList<Long>(); 164 private ArrayList<Long> mSendCancelIdList = new ArrayList<Long>(); 165 private ArrayList<Message> mOutgoingMailList = new ArrayList<Message>(); 166 167 public CalendarSyncAdapter(EasSyncService service) { 168 super(service); 169 mEmailAddress = mAccount.mEmailAddress; 170 Cursor c = mService.mContentResolver.query(Calendars.CONTENT_URI, 171 new String[] {Calendars._ID}, CALENDAR_SELECTION, 172 new String[] {mEmailAddress, Eas.EXCHANGE_ACCOUNT_MANAGER_TYPE}, null); 173 if (c == null) return; 174 try { 175 if (c.moveToFirst()) { 176 mCalendarId = c.getLong(CALENDAR_SELECTION_ID); 177 } else { 178 mCalendarId = CalendarUtilities.createCalendar(mService, mAccount, mMailbox); 179 } 180 mCalendarIdString = Long.toString(mCalendarId); 181 mCalendarIdArgument = new String[] {mCalendarIdString}; 182 } finally { 183 c.close(); 184 } 185 } 186 187 @Override 188 public String getCollectionName() { 189 return "Calendar"; 190 } 191 192 @Override 193 public void cleanup() { 194 } 195 196 @Override 197 public void wipe() { 198 // Delete the calendar associated with this account 199 // CalendarProvider2 does NOT handle selection arguments in deletions 200 mContentResolver.delete( 201 asSyncAdapter(Calendars.CONTENT_URI, mEmailAddress, 202 Eas.EXCHANGE_ACCOUNT_MANAGER_TYPE), 203 Calendars.ACCOUNT_NAME + "=" + DatabaseUtils.sqlEscapeString(mEmailAddress) 204 + " AND " + Calendars.ACCOUNT_TYPE + "=" 205 + DatabaseUtils.sqlEscapeString(AccountManagerTypes.TYPE_EXCHANGE), null); 206 // Invalidate our calendar observers 207 ExchangeService.unregisterCalendarObservers(); 208 } 209 210 @Override 211 public void sendSyncOptions(Double protocolVersion, Serializer s) throws IOException { 212 setPimSyncOptions(protocolVersion, Eas.FILTER_2_WEEKS, s); 213 } 214 215 @Override 216 public boolean isSyncable() { 217 return ContentResolver.getSyncAutomatically(mAccountManagerAccount, 218 CalendarContract.AUTHORITY); 219 } 220 221 @Override 222 public boolean parse(InputStream is) throws IOException, CommandStatusException { 223 EasCalendarSyncParser p = new EasCalendarSyncParser(is, this); 224 return p.parse(); 225 } 226 227 public static Uri asSyncAdapter(Uri uri, String account, String accountType) { 228 return uri.buildUpon().appendQueryParameter(CalendarContract.CALLER_IS_SYNCADAPTER, "true") 229 .appendQueryParameter(Calendars.ACCOUNT_NAME, account) 230 .appendQueryParameter(Calendars.ACCOUNT_TYPE, accountType).build(); 231 } 232 233 /** 234 * Generate the uri for the data row associated with this NamedContentValues object 235 * @param ncv the NamedContentValues object 236 * @return a uri that can be used to refer to this row 237 */ 238 public Uri dataUriFromNamedContentValues(NamedContentValues ncv) { 239 long id = ncv.values.getAsLong(RawContacts._ID); 240 Uri dataUri = ContentUris.withAppendedId(ncv.uri, id); 241 return dataUri; 242 } 243 244 /** 245 * We get our SyncKey from CalendarProvider. If there's not one, we set it to "0" (the reset 246 * state) and save that away. 247 */ 248 @Override 249 public String getSyncKey() throws IOException { 250 synchronized (sSyncKeyLock) { 251 ContentProviderClient client = mService.mContentResolver 252 .acquireContentProviderClient(CalendarContract.CONTENT_URI); 253 try { 254 byte[] data = SyncStateContract.Helpers.get( 255 client, 256 asSyncAdapter(SyncState.CONTENT_URI, mEmailAddress, 257 Eas.EXCHANGE_ACCOUNT_MANAGER_TYPE), mAccountManagerAccount); 258 if (data == null || data.length == 0) { 259 // Initialize the SyncKey 260 setSyncKey("0", false); 261 return "0"; 262 } else { 263 String syncKey = new String(data); 264 userLog("SyncKey retrieved as ", syncKey, " from CalendarProvider"); 265 return syncKey; 266 } 267 } catch (RemoteException e) { 268 throw new IOException("Can't get SyncKey from CalendarProvider"); 269 } 270 } 271 } 272 273 /** 274 * We only need to set this when we're forced to make the SyncKey "0" (a reset). In all other 275 * cases, the SyncKey is set within Calendar 276 */ 277 @Override 278 public void setSyncKey(String syncKey, boolean inCommands) throws IOException { 279 synchronized (sSyncKeyLock) { 280 if ("0".equals(syncKey) || !inCommands) { 281 ContentProviderClient client = mService.mContentResolver 282 .acquireContentProviderClient(CalendarContract.CONTENT_URI); 283 try { 284 SyncStateContract.Helpers.set( 285 client, 286 asSyncAdapter(SyncState.CONTENT_URI, mEmailAddress, 287 Eas.EXCHANGE_ACCOUNT_MANAGER_TYPE), mAccountManagerAccount, 288 syncKey.getBytes()); 289 userLog("SyncKey set to ", syncKey, " in CalendarProvider"); 290 } catch (RemoteException e) { 291 throw new IOException("Can't set SyncKey in CalendarProvider"); 292 } 293 } 294 mMailbox.mSyncKey = syncKey; 295 } 296 } 297 298 public class EasCalendarSyncParser extends AbstractSyncParser { 299 300 String[] mBindArgument = new String[1]; 301 Uri mAccountUri; 302 CalendarOperations mOps = new CalendarOperations(); 303 304 public EasCalendarSyncParser(InputStream in, CalendarSyncAdapter adapter) 305 throws IOException { 306 super(in, adapter); 307 setLoggingTag("CalendarParser"); 308 mAccountUri = Events.CONTENT_URI; 309 } 310 311 private void addOrganizerToAttendees(CalendarOperations ops, long eventId, 312 String organizerName, String organizerEmail) { 313 // Handle the organizer (who IS an attendee on device, but NOT in EAS) 314 if (organizerName != null || organizerEmail != null) { 315 ContentValues attendeeCv = new ContentValues(); 316 if (organizerName != null) { 317 attendeeCv.put(Attendees.ATTENDEE_NAME, organizerName); 318 } 319 if (organizerEmail != null) { 320 attendeeCv.put(Attendees.ATTENDEE_EMAIL, organizerEmail); 321 } 322 attendeeCv.put(Attendees.ATTENDEE_RELATIONSHIP, Attendees.RELATIONSHIP_ORGANIZER); 323 attendeeCv.put(Attendees.ATTENDEE_TYPE, Attendees.TYPE_REQUIRED); 324 attendeeCv.put(Attendees.ATTENDEE_STATUS, Attendees.ATTENDEE_STATUS_ACCEPTED); 325 if (eventId < 0) { 326 ops.newAttendee(attendeeCv); 327 } else { 328 ops.updatedAttendee(attendeeCv, eventId); 329 } 330 } 331 } 332 333 /** 334 * Set DTSTART, DTEND, DURATION and EVENT_TIMEZONE as appropriate for the given Event 335 * The follow rules are enforced by CalendarProvider2: 336 * Events that aren't exceptions MUST have either 1) a DTEND or 2) a DURATION 337 * Recurring events (i.e. events with RRULE) must have a DURATION 338 * All-day recurring events MUST have a DURATION that is in the form P<n>D 339 * Other events MAY have a DURATION in any valid form (we use P<n>M) 340 * All-day events MUST have hour, minute, and second = 0; in addition, they must have 341 * the EVENT_TIMEZONE set to UTC 342 * Also, exceptions to all-day events need to have an ORIGINAL_INSTANCE_TIME that has 343 * hour, minute, and second = 0 and be set in UTC 344 * @param cv the ContentValues for the Event 345 * @param startTime the start time for the Event 346 * @param endTime the end time for the Event 347 * @param allDayEvent whether this is an all day event (1) or not (0) 348 */ 349 /*package*/ void setTimeRelatedValues(ContentValues cv, long startTime, long endTime, 350 int allDayEvent) { 351 // If there's no startTime, the event will be found to be invalid, so return 352 if (startTime < 0) return; 353 // EAS events can arrive without an end time, but CalendarProvider requires them 354 // so we'll default to 30 minutes; this will be superceded if this is an all-day event 355 if (endTime < 0) endTime = startTime + (30*MINUTES); 356 357 // If this is an all-day event, set hour, minute, and second to zero, and use UTC 358 if (allDayEvent != 0) { 359 startTime = CalendarUtilities.getUtcAllDayCalendarTime(startTime, mLocalTimeZone); 360 endTime = CalendarUtilities.getUtcAllDayCalendarTime(endTime, mLocalTimeZone); 361 String originalTimeZone = cv.getAsString(Events.EVENT_TIMEZONE); 362 cv.put(EVENT_SAVED_TIMEZONE_COLUMN, originalTimeZone); 363 cv.put(Events.EVENT_TIMEZONE, UTC_TIMEZONE.getID()); 364 } 365 366 // If this is an exception, and the original was an all-day event, make sure the 367 // original instance time has hour, minute, and second set to zero, and is in UTC 368 if (cv.containsKey(Events.ORIGINAL_INSTANCE_TIME) && 369 cv.containsKey(Events.ORIGINAL_ALL_DAY)) { 370 Integer ade = cv.getAsInteger(Events.ORIGINAL_ALL_DAY); 371 if (ade != null && ade != 0) { 372 long exceptionTime = cv.getAsLong(Events.ORIGINAL_INSTANCE_TIME); 373 GregorianCalendar cal = new GregorianCalendar(UTC_TIMEZONE); 374 cal.setTimeInMillis(exceptionTime); 375 cal.set(GregorianCalendar.HOUR_OF_DAY, 0); 376 cal.set(GregorianCalendar.MINUTE, 0); 377 cal.set(GregorianCalendar.SECOND, 0); 378 cv.put(Events.ORIGINAL_INSTANCE_TIME, cal.getTimeInMillis()); 379 } 380 } 381 382 // Always set DTSTART 383 cv.put(Events.DTSTART, startTime); 384 // For recurring events, set DURATION. Use P<n>D format for all day events 385 if (cv.containsKey(Events.RRULE)) { 386 if (allDayEvent != 0) { 387 cv.put(Events.DURATION, "P" + ((endTime - startTime) / DAYS) + "D"); 388 } 389 else { 390 cv.put(Events.DURATION, "P" + ((endTime - startTime) / MINUTES) + "M"); 391 } 392 // For other events, set DTEND and LAST_DATE 393 } else { 394 cv.put(Events.DTEND, endTime); 395 cv.put(Events.LAST_DATE, endTime); 396 } 397 } 398 399 public void addEvent(CalendarOperations ops, String serverId, boolean update) 400 throws IOException { 401 ContentValues cv = new ContentValues(); 402 cv.put(Events.CALENDAR_ID, mCalendarId); 403 cv.put(Events._SYNC_ID, serverId); 404 cv.put(Events.HAS_ATTENDEE_DATA, 1); 405 cv.put(Events.SYNC_DATA2, "0"); 406 407 int allDayEvent = 0; 408 String organizerName = null; 409 String organizerEmail = null; 410 int eventOffset = -1; 411 int deleteOffset = -1; 412 int busyStatus = CalendarUtilities.BUSY_STATUS_TENTATIVE; 413 int responseType = CalendarUtilities.RESPONSE_TYPE_NONE; 414 415 boolean firstTag = true; 416 long eventId = -1; 417 long startTime = -1; 418 long endTime = -1; 419 TimeZone timeZone = null; 420 421 // Keep track of the attendees; exceptions will need them 422 ArrayList<ContentValues> attendeeValues = new ArrayList<ContentValues>(); 423 int reminderMins = -1; 424 String dtStamp = null; 425 boolean organizerAdded = false; 426 427 while (nextTag(Tags.SYNC_APPLICATION_DATA) != END) { 428 if (update && firstTag) { 429 // Find the event that's being updated 430 Cursor c = getServerIdCursor(serverId); 431 long id = -1; 432 try { 433 if (c != null && c.moveToFirst()) { 434 id = c.getLong(0); 435 } 436 } finally { 437 if (c != null) c.close(); 438 } 439 if (id > 0) { 440 // DTSTAMP can come first, and we simply need to track it 441 if (tag == Tags.CALENDAR_DTSTAMP) { 442 dtStamp = getValue(); 443 continue; 444 } else if (tag == Tags.CALENDAR_ATTENDEES) { 445 // This is an attendees-only update; just 446 // delete/re-add attendees 447 mBindArgument[0] = Long.toString(id); 448 ops.add(ContentProviderOperation 449 .newDelete( 450 asSyncAdapter(Attendees.CONTENT_URI, mEmailAddress, 451 Eas.EXCHANGE_ACCOUNT_MANAGER_TYPE)) 452 .withSelection(ATTENDEES_EXCEPT_ORGANIZER, mBindArgument) 453 .build()); 454 eventId = id; 455 } else { 456 // Otherwise, delete the original event and recreate it 457 userLog("Changing (delete/add) event ", serverId); 458 deleteOffset = ops.newDelete(id, serverId); 459 // Add a placeholder event so that associated tables can reference 460 // this as a back reference. We add the event at the end of the method 461 eventOffset = ops.newEvent(PLACEHOLDER_OPERATION); 462 } 463 } else { 464 // The changed item isn't found. We'll treat this as a new item 465 eventOffset = ops.newEvent(PLACEHOLDER_OPERATION); 466 userLog(TAG, "Changed item not found; treating as new."); 467 } 468 } else if (firstTag) { 469 // Add a placeholder event so that associated tables can reference 470 // this as a back reference. We add the event at the end of the method 471 eventOffset = ops.newEvent(PLACEHOLDER_OPERATION); 472 } 473 firstTag = false; 474 switch (tag) { 475 case Tags.CALENDAR_ALL_DAY_EVENT: 476 allDayEvent = getValueInt(); 477 if (allDayEvent != 0 && timeZone != null) { 478 // If the event doesn't start at midnight local time, we won't consider 479 // this an all-day event in the local time zone (this is what OWA does) 480 GregorianCalendar cal = new GregorianCalendar(mLocalTimeZone); 481 cal.setTimeInMillis(startTime); 482 userLog("All-day event arrived in: " + timeZone.getID()); 483 if (cal.get(GregorianCalendar.HOUR_OF_DAY) != 0 || 484 cal.get(GregorianCalendar.MINUTE) != 0) { 485 allDayEvent = 0; 486 userLog("Not an all-day event locally: " + mLocalTimeZone.getID()); 487 } 488 } 489 cv.put(Events.ALL_DAY, allDayEvent); 490 break; 491 case Tags.CALENDAR_ATTACHMENTS: 492 attachmentsParser(); 493 break; 494 case Tags.CALENDAR_ATTENDEES: 495 // If eventId >= 0, this is an update; otherwise, a new Event 496 attendeeValues = attendeesParser(ops, eventId); 497 break; 498 case Tags.BASE_BODY: 499 cv.put(Events.DESCRIPTION, bodyParser()); 500 break; 501 case Tags.CALENDAR_BODY: 502 cv.put(Events.DESCRIPTION, getValue()); 503 break; 504 case Tags.CALENDAR_TIME_ZONE: 505 timeZone = CalendarUtilities.tziStringToTimeZone(getValue()); 506 if (timeZone == null) { 507 timeZone = mLocalTimeZone; 508 } 509 cv.put(Events.EVENT_TIMEZONE, timeZone.getID()); 510 break; 511 case Tags.CALENDAR_START_TIME: 512 startTime = Utility.parseDateTimeToMillis(getValue()); 513 break; 514 case Tags.CALENDAR_END_TIME: 515 endTime = Utility.parseDateTimeToMillis(getValue()); 516 break; 517 case Tags.CALENDAR_EXCEPTIONS: 518 // For exceptions to show the organizer, the organizer must be added before 519 // we call exceptionsParser 520 addOrganizerToAttendees(ops, eventId, organizerName, organizerEmail); 521 organizerAdded = true; 522 exceptionsParser(ops, cv, attendeeValues, reminderMins, busyStatus, 523 startTime, endTime); 524 break; 525 case Tags.CALENDAR_LOCATION: 526 cv.put(Events.EVENT_LOCATION, getValue()); 527 break; 528 case Tags.CALENDAR_RECURRENCE: 529 String rrule = recurrenceParser(); 530 if (rrule != null) { 531 cv.put(Events.RRULE, rrule); 532 } 533 break; 534 case Tags.CALENDAR_ORGANIZER_EMAIL: 535 organizerEmail = getValue(); 536 cv.put(Events.ORGANIZER, organizerEmail); 537 break; 538 case Tags.CALENDAR_SUBJECT: 539 cv.put(Events.TITLE, getValue()); 540 break; 541 case Tags.CALENDAR_SENSITIVITY: 542 cv.put(Events.ACCESS_LEVEL, encodeVisibility(getValueInt())); 543 break; 544 case Tags.CALENDAR_ORGANIZER_NAME: 545 organizerName = getValue(); 546 break; 547 case Tags.CALENDAR_REMINDER_MINS_BEFORE: 548 reminderMins = getValueInt(); 549 ops.newReminder(reminderMins); 550 cv.put(Events.HAS_ALARM, 1); 551 break; 552 // The following are fields we should save (for changes), though they don't 553 // relate to data used by CalendarProvider at this point 554 case Tags.CALENDAR_UID: 555 cv.put(Events.SYNC_DATA2, getValue()); 556 break; 557 case Tags.CALENDAR_DTSTAMP: 558 dtStamp = getValue(); 559 break; 560 case Tags.CALENDAR_MEETING_STATUS: 561 ops.newExtendedProperty(EXTENDED_PROPERTY_MEETING_STATUS, getValue()); 562 break; 563 case Tags.CALENDAR_BUSY_STATUS: 564 // We'll set the user's status in the Attendees table below 565 // Don't set selfAttendeeStatus or CalendarProvider will create a duplicate 566 // attendee! 567 busyStatus = getValueInt(); 568 break; 569 case Tags.CALENDAR_RESPONSE_TYPE: 570 // EAS 14+ uses this for the user's response status; we'll use this instead 571 // of busy status, if it appears 572 responseType = getValueInt(); 573 break; 574 case Tags.CALENDAR_CATEGORIES: 575 String categories = categoriesParser(ops); 576 if (categories.length() > 0) { 577 ops.newExtendedProperty(EXTENDED_PROPERTY_CATEGORIES, categories); 578 } 579 break; 580 default: 581 skipTag(); 582 } 583 } 584 585 // Enforce CalendarProvider required properties 586 setTimeRelatedValues(cv, startTime, endTime, allDayEvent); 587 588 // If we haven't added the organizer to attendees, do it now 589 if (!organizerAdded) { 590 addOrganizerToAttendees(ops, eventId, organizerName, organizerEmail); 591 } 592 593 // Note that organizerEmail can be null with a DTSTAMP only change from the server 594 boolean selfOrganizer = (mEmailAddress.equals(organizerEmail)); 595 596 // Store email addresses of attendees (in a tokenizable string) in ExtendedProperties 597 // If the user is an attendee, set the attendee status using busyStatus (note that the 598 // busyStatus is inherited from the parent unless it's specified in the exception) 599 // Add the insert/update operation for each attendee (based on whether it's add/change) 600 int numAttendees = attendeeValues.size(); 601 if (numAttendees > MAX_SYNCED_ATTENDEES) { 602 // Indicate that we've redacted attendees. If we're the organizer, disable edit 603 // by setting organizerEmail to a bogus value and by setting the upsync prohibited 604 // extended properly. 605 // Note that we don't set ANY attendees if we're in this branch; however, the 606 // organizer has already been included above, and WILL show up (which is good) 607 if (eventId < 0) { 608 ops.newExtendedProperty(EXTENDED_PROPERTY_ATTENDEES_REDACTED, "1"); 609 if (selfOrganizer) { 610 ops.newExtendedProperty(EXTENDED_PROPERTY_UPSYNC_PROHIBITED, "1"); 611 } 612 } else { 613 ops.updatedExtendedProperty(EXTENDED_PROPERTY_ATTENDEES_REDACTED, "1", eventId); 614 if (selfOrganizer) { 615 ops.updatedExtendedProperty(EXTENDED_PROPERTY_UPSYNC_PROHIBITED, "1", 616 eventId); 617 } 618 } 619 if (selfOrganizer) { 620 organizerEmail = BOGUS_ORGANIZER_EMAIL; 621 cv.put(Events.ORGANIZER, organizerEmail); 622 } 623 // Tell UI that we don't have any attendees 624 cv.put(Events.HAS_ATTENDEE_DATA, "0"); 625 mService.userLog("Maximum number of attendees exceeded; redacting"); 626 } else if (numAttendees > 0) { 627 StringBuilder sb = new StringBuilder(); 628 for (ContentValues attendee: attendeeValues) { 629 String attendeeEmail = attendee.getAsString(Attendees.ATTENDEE_EMAIL); 630 sb.append(attendeeEmail); 631 sb.append(ATTENDEE_TOKENIZER_DELIMITER); 632 if (mEmailAddress.equalsIgnoreCase(attendeeEmail)) { 633 int attendeeStatus; 634 // We'll use the response type (EAS 14), if we've got one; otherwise, we'll 635 // try to infer it from busy status 636 if (responseType != CalendarUtilities.RESPONSE_TYPE_NONE) { 637 attendeeStatus = 638 CalendarUtilities.attendeeStatusFromResponseType(responseType); 639 } else if (!update) { 640 // For new events in EAS < 14, we have no idea what the busy status 641 // means, so we show "none", allowing the user to select an option. 642 attendeeStatus = Attendees.ATTENDEE_STATUS_NONE; 643 } else { 644 // For updated events, we'll try to infer the attendee status from the 645 // busy status 646 attendeeStatus = 647 CalendarUtilities.attendeeStatusFromBusyStatus(busyStatus); 648 } 649 attendee.put(Attendees.ATTENDEE_STATUS, attendeeStatus); 650 // If we're an attendee, save away our initial attendee status in the 651 // event's ExtendedProperties (we look for differences between this and 652 // the user's current attendee status to determine whether an email needs 653 // to be sent to the organizer) 654 // organizerEmail will be null in the case that this is an attendees-only 655 // change from the server 656 if (organizerEmail == null || 657 !organizerEmail.equalsIgnoreCase(attendeeEmail)) { 658 if (eventId < 0) { 659 ops.newExtendedProperty(EXTENDED_PROPERTY_USER_ATTENDEE_STATUS, 660 Integer.toString(attendeeStatus)); 661 } else { 662 ops.updatedExtendedProperty(EXTENDED_PROPERTY_USER_ATTENDEE_STATUS, 663 Integer.toString(attendeeStatus), eventId); 664 665 } 666 } 667 } 668 if (eventId < 0) { 669 ops.newAttendee(attendee); 670 } else { 671 ops.updatedAttendee(attendee, eventId); 672 } 673 } 674 if (eventId < 0) { 675 ops.newExtendedProperty(EXTENDED_PROPERTY_ATTENDEES, sb.toString()); 676 ops.newExtendedProperty(EXTENDED_PROPERTY_ATTENDEES_REDACTED, "0"); 677 ops.newExtendedProperty(EXTENDED_PROPERTY_UPSYNC_PROHIBITED, "0"); 678 } else { 679 ops.updatedExtendedProperty(EXTENDED_PROPERTY_ATTENDEES, sb.toString(), 680 eventId); 681 ops.updatedExtendedProperty(EXTENDED_PROPERTY_ATTENDEES_REDACTED, "0", eventId); 682 ops.updatedExtendedProperty(EXTENDED_PROPERTY_UPSYNC_PROHIBITED, "0", eventId); 683 } 684 } 685 686 // Put the real event in the proper place in the ops ArrayList 687 if (eventOffset >= 0) { 688 // Store away the DTSTAMP here 689 if (dtStamp != null) { 690 ops.newExtendedProperty(EXTENDED_PROPERTY_DTSTAMP, dtStamp); 691 } 692 693 if (isValidEventValues(cv)) { 694 ops.set(eventOffset, 695 ContentProviderOperation 696 .newInsert( 697 asSyncAdapter(Events.CONTENT_URI, mEmailAddress, 698 Eas.EXCHANGE_ACCOUNT_MANAGER_TYPE)) 699 .withValues(cv).build()); 700 } else { 701 // If we can't add this event (it's invalid), remove all of the inserts 702 // we've built for it 703 int cnt = ops.mCount - eventOffset; 704 userLog(TAG, "Removing " + cnt + " inserts from mOps"); 705 for (int i = 0; i < cnt; i++) { 706 ops.remove(eventOffset); 707 } 708 ops.mCount = eventOffset; 709 // If this is a change, we need to also remove the deletion that comes 710 // before the addition 711 if (deleteOffset >= 0) { 712 // Remove the deletion 713 ops.remove(deleteOffset); 714 // And the deletion of exceptions 715 ops.remove(deleteOffset); 716 userLog(TAG, "Removing deletion ops from mOps"); 717 ops.mCount = deleteOffset; 718 } 719 } 720 } 721 } 722 723 private void logEventColumns(ContentValues cv, String reason) { 724 if (Eas.USER_LOG) { 725 StringBuilder sb = 726 new StringBuilder("Event invalid, " + reason + ", skipping: Columns = "); 727 for (Entry<String, Object> entry: cv.valueSet()) { 728 sb.append(entry.getKey()); 729 sb.append('/'); 730 } 731 userLog(TAG, sb.toString()); 732 } 733 } 734 735 /*package*/ boolean isValidEventValues(ContentValues cv) { 736 boolean isException = cv.containsKey(Events.ORIGINAL_INSTANCE_TIME); 737 // All events require DTSTART 738 if (!cv.containsKey(Events.DTSTART)) { 739 logEventColumns(cv, "DTSTART missing"); 740 return false; 741 // If we're a top-level event, we must have _SYNC_DATA (uid) 742 } else if (!isException && !cv.containsKey(Events.SYNC_DATA2)) { 743 logEventColumns(cv, "_SYNC_DATA missing"); 744 return false; 745 // We must also have DTEND or DURATION if we're not an exception 746 } else if (!isException && !cv.containsKey(Events.DTEND) && 747 !cv.containsKey(Events.DURATION)) { 748 logEventColumns(cv, "DTEND/DURATION missing"); 749 return false; 750 // Exceptions require DTEND 751 } else if (isException && !cv.containsKey(Events.DTEND)) { 752 logEventColumns(cv, "Exception missing DTEND"); 753 return false; 754 // If this is a recurrence, we need a DURATION (in days if an all-day event) 755 } else if (cv.containsKey(Events.RRULE)) { 756 String duration = cv.getAsString(Events.DURATION); 757 if (duration == null) return false; 758 if (cv.containsKey(Events.ALL_DAY)) { 759 Integer ade = cv.getAsInteger(Events.ALL_DAY); 760 if (ade != null && ade != 0 && !duration.endsWith("D")) { 761 return false; 762 } 763 } 764 } 765 return true; 766 } 767 768 public String recurrenceParser() throws IOException { 769 // Turn this information into an RRULE 770 int type = -1; 771 int occurrences = -1; 772 int interval = -1; 773 int dow = -1; 774 int dom = -1; 775 int wom = -1; 776 int moy = -1; 777 String until = null; 778 779 while (nextTag(Tags.CALENDAR_RECURRENCE) != END) { 780 switch (tag) { 781 case Tags.CALENDAR_RECURRENCE_TYPE: 782 type = getValueInt(); 783 break; 784 case Tags.CALENDAR_RECURRENCE_INTERVAL: 785 interval = getValueInt(); 786 break; 787 case Tags.CALENDAR_RECURRENCE_OCCURRENCES: 788 occurrences = getValueInt(); 789 break; 790 case Tags.CALENDAR_RECURRENCE_DAYOFWEEK: 791 dow = getValueInt(); 792 break; 793 case Tags.CALENDAR_RECURRENCE_DAYOFMONTH: 794 dom = getValueInt(); 795 break; 796 case Tags.CALENDAR_RECURRENCE_WEEKOFMONTH: 797 wom = getValueInt(); 798 break; 799 case Tags.CALENDAR_RECURRENCE_MONTHOFYEAR: 800 moy = getValueInt(); 801 break; 802 case Tags.CALENDAR_RECURRENCE_UNTIL: 803 until = getValue(); 804 break; 805 default: 806 skipTag(); 807 } 808 } 809 810 return CalendarUtilities.rruleFromRecurrence(type, occurrences, interval, 811 dow, dom, wom, moy, until); 812 } 813 814 private void exceptionParser(CalendarOperations ops, ContentValues parentCv, 815 ArrayList<ContentValues> attendeeValues, int reminderMins, int busyStatus, 816 long startTime, long endTime) throws IOException { 817 ContentValues cv = new ContentValues(); 818 cv.put(Events.CALENDAR_ID, mCalendarId); 819 820 // It appears that these values have to be copied from the parent if they are to appear 821 // Note that they can be overridden below 822 cv.put(Events.ORGANIZER, parentCv.getAsString(Events.ORGANIZER)); 823 cv.put(Events.TITLE, parentCv.getAsString(Events.TITLE)); 824 cv.put(Events.DESCRIPTION, parentCv.getAsString(Events.DESCRIPTION)); 825 cv.put(Events.ORIGINAL_ALL_DAY, parentCv.getAsInteger(Events.ALL_DAY)); 826 cv.put(Events.EVENT_LOCATION, parentCv.getAsString(Events.EVENT_LOCATION)); 827 cv.put(Events.ACCESS_LEVEL, parentCv.getAsString(Events.ACCESS_LEVEL)); 828 cv.put(Events.EVENT_TIMEZONE, parentCv.getAsString(Events.EVENT_TIMEZONE)); 829 // Exceptions should always have this set to zero, since EAS has no concept of 830 // separate attendee lists for exceptions; if we fail to do this, then the UI will 831 // allow the user to change attendee data, and this change would never get reflected 832 // on the server. 833 cv.put(Events.HAS_ATTENDEE_DATA, 0); 834 835 int allDayEvent = 0; 836 837 // This column is the key that links the exception to the serverId 838 cv.put(Events.ORIGINAL_SYNC_ID, parentCv.getAsString(Events._SYNC_ID)); 839 840 String exceptionStartTime = "_noStartTime"; 841 while (nextTag(Tags.SYNC_APPLICATION_DATA) != END) { 842 switch (tag) { 843 case Tags.CALENDAR_ATTACHMENTS: 844 attachmentsParser(); 845 break; 846 case Tags.CALENDAR_EXCEPTION_START_TIME: 847 exceptionStartTime = getValue(); 848 cv.put(Events.ORIGINAL_INSTANCE_TIME, 849 Utility.parseDateTimeToMillis(exceptionStartTime)); 850 break; 851 case Tags.CALENDAR_EXCEPTION_IS_DELETED: 852 if (getValueInt() == 1) { 853 cv.put(Events.STATUS, Events.STATUS_CANCELED); 854 } 855 break; 856 case Tags.CALENDAR_ALL_DAY_EVENT: 857 allDayEvent = getValueInt(); 858 cv.put(Events.ALL_DAY, allDayEvent); 859 break; 860 case Tags.BASE_BODY: 861 cv.put(Events.DESCRIPTION, bodyParser()); 862 break; 863 case Tags.CALENDAR_BODY: 864 cv.put(Events.DESCRIPTION, getValue()); 865 break; 866 case Tags.CALENDAR_START_TIME: 867 startTime = Utility.parseDateTimeToMillis(getValue()); 868 break; 869 case Tags.CALENDAR_END_TIME: 870 endTime = Utility.parseDateTimeToMillis(getValue()); 871 break; 872 case Tags.CALENDAR_LOCATION: 873 cv.put(Events.EVENT_LOCATION, getValue()); 874 break; 875 case Tags.CALENDAR_RECURRENCE: 876 String rrule = recurrenceParser(); 877 if (rrule != null) { 878 cv.put(Events.RRULE, rrule); 879 } 880 break; 881 case Tags.CALENDAR_SUBJECT: 882 cv.put(Events.TITLE, getValue()); 883 break; 884 case Tags.CALENDAR_SENSITIVITY: 885 cv.put(Events.ACCESS_LEVEL, encodeVisibility(getValueInt())); 886 break; 887 case Tags.CALENDAR_BUSY_STATUS: 888 busyStatus = getValueInt(); 889 // Don't set selfAttendeeStatus or CalendarProvider will create a duplicate 890 // attendee! 891 break; 892 // TODO How to handle these items that are linked to event id! 893 // case Tags.CALENDAR_DTSTAMP: 894 // ops.newExtendedProperty("dtstamp", getValue()); 895 // break; 896 // case Tags.CALENDAR_REMINDER_MINS_BEFORE: 897 // ops.newReminder(getValueInt()); 898 // break; 899 default: 900 skipTag(); 901 } 902 } 903 904 // We need a _sync_id, but it can't be the parent's id, so we generate one 905 cv.put(Events._SYNC_ID, parentCv.getAsString(Events._SYNC_ID) + '_' + 906 exceptionStartTime); 907 908 // Enforce CalendarProvider required properties 909 setTimeRelatedValues(cv, startTime, endTime, allDayEvent); 910 911 // Don't insert an invalid exception event 912 if (!isValidEventValues(cv)) return; 913 914 // Add the exception insert 915 int exceptionStart = ops.mCount; 916 ops.newException(cv); 917 // Also add the attendees, because they need to be copied over from the parent event 918 boolean attendeesRedacted = false; 919 if (attendeeValues != null) { 920 for (ContentValues attValues: attendeeValues) { 921 // If this is the user, use his busy status for attendee status 922 String attendeeEmail = attValues.getAsString(Attendees.ATTENDEE_EMAIL); 923 // Note that the exception at which we surpass the redaction limit might have 924 // any number of attendees shown; since this is an edge case and a workaround, 925 // it seems to be an acceptable implementation 926 if (mEmailAddress.equalsIgnoreCase(attendeeEmail)) { 927 attValues.put(Attendees.ATTENDEE_STATUS, 928 CalendarUtilities.attendeeStatusFromBusyStatus(busyStatus)); 929 ops.newAttendee(attValues, exceptionStart); 930 } else if (ops.size() < MAX_OPS_BEFORE_EXCEPTION_ATTENDEE_REDACTION) { 931 ops.newAttendee(attValues, exceptionStart); 932 } else { 933 attendeesRedacted = true; 934 } 935 } 936 } 937 // And add the parent's reminder value 938 if (reminderMins > 0) { 939 ops.newReminder(reminderMins, exceptionStart); 940 } 941 if (attendeesRedacted) { 942 mService.userLog("Attendees redacted in this exception"); 943 } 944 } 945 946 private int encodeVisibility(int easVisibility) { 947 int visibility = 0; 948 switch(easVisibility) { 949 case 0: 950 visibility = Events.ACCESS_DEFAULT; 951 break; 952 case 1: 953 visibility = Events.ACCESS_PUBLIC; 954 break; 955 case 2: 956 visibility = Events.ACCESS_PRIVATE; 957 break; 958 case 3: 959 visibility = Events.ACCESS_CONFIDENTIAL; 960 break; 961 } 962 return visibility; 963 } 964 965 private void exceptionsParser(CalendarOperations ops, ContentValues cv, 966 ArrayList<ContentValues> attendeeValues, int reminderMins, int busyStatus, 967 long startTime, long endTime) throws IOException { 968 while (nextTag(Tags.CALENDAR_EXCEPTIONS) != END) { 969 switch (tag) { 970 case Tags.CALENDAR_EXCEPTION: 971 exceptionParser(ops, cv, attendeeValues, reminderMins, busyStatus, 972 startTime, endTime); 973 break; 974 default: 975 skipTag(); 976 } 977 } 978 } 979 980 private String categoriesParser(CalendarOperations ops) throws IOException { 981 StringBuilder categories = new StringBuilder(); 982 while (nextTag(Tags.CALENDAR_CATEGORIES) != END) { 983 switch (tag) { 984 case Tags.CALENDAR_CATEGORY: 985 // TODO Handle categories (there's no similar concept for gdata AFAIK) 986 // We need to save them and spit them back when we update the event 987 categories.append(getValue()); 988 categories.append(CATEGORY_TOKENIZER_DELIMITER); 989 break; 990 default: 991 skipTag(); 992 } 993 } 994 return categories.toString(); 995 } 996 997 /** 998 * For now, we ignore (but still have to parse) event attachments; these are new in EAS 14 999 */ 1000 private void attachmentsParser() throws IOException { 1001 while (nextTag(Tags.CALENDAR_ATTACHMENTS) != END) { 1002 switch (tag) { 1003 case Tags.CALENDAR_ATTACHMENT: 1004 skipParser(Tags.CALENDAR_ATTACHMENT); 1005 break; 1006 default: 1007 skipTag(); 1008 } 1009 } 1010 } 1011 1012 private ArrayList<ContentValues> attendeesParser(CalendarOperations ops, long eventId) 1013 throws IOException { 1014 int attendeeCount = 0; 1015 ArrayList<ContentValues> attendeeValues = new ArrayList<ContentValues>(); 1016 while (nextTag(Tags.CALENDAR_ATTENDEES) != END) { 1017 switch (tag) { 1018 case Tags.CALENDAR_ATTENDEE: 1019 ContentValues cv = attendeeParser(ops, eventId); 1020 // If we're going to redact these attendees anyway, let's avoid unnecessary 1021 // memory pressure, and not keep them around 1022 // We still need to parse them all, however 1023 attendeeCount++; 1024 // Allow one more than MAX_ATTENDEES, so that the check for "too many" will 1025 // succeed in addEvent 1026 if (attendeeCount <= (MAX_SYNCED_ATTENDEES+1)) { 1027 attendeeValues.add(cv); 1028 } 1029 break; 1030 default: 1031 skipTag(); 1032 } 1033 } 1034 return attendeeValues; 1035 } 1036 1037 private ContentValues attendeeParser(CalendarOperations ops, long eventId) 1038 throws IOException { 1039 ContentValues cv = new ContentValues(); 1040 while (nextTag(Tags.CALENDAR_ATTENDEE) != END) { 1041 switch (tag) { 1042 case Tags.CALENDAR_ATTENDEE_EMAIL: 1043 cv.put(Attendees.ATTENDEE_EMAIL, getValue()); 1044 break; 1045 case Tags.CALENDAR_ATTENDEE_NAME: 1046 cv.put(Attendees.ATTENDEE_NAME, getValue()); 1047 break; 1048 case Tags.CALENDAR_ATTENDEE_STATUS: 1049 int status = getValueInt(); 1050 cv.put(Attendees.ATTENDEE_STATUS, 1051 (status == 2) ? Attendees.ATTENDEE_STATUS_TENTATIVE : 1052 (status == 3) ? Attendees.ATTENDEE_STATUS_ACCEPTED : 1053 (status == 4) ? Attendees.ATTENDEE_STATUS_DECLINED : 1054 (status == 5) ? Attendees.ATTENDEE_STATUS_INVITED : 1055 Attendees.ATTENDEE_STATUS_NONE); 1056 break; 1057 case Tags.CALENDAR_ATTENDEE_TYPE: 1058 int type = Attendees.TYPE_NONE; 1059 // EAS types: 1 = req'd, 2 = opt, 3 = resource 1060 switch (getValueInt()) { 1061 case 1: 1062 type = Attendees.TYPE_REQUIRED; 1063 break; 1064 case 2: 1065 type = Attendees.TYPE_OPTIONAL; 1066 break; 1067 } 1068 cv.put(Attendees.ATTENDEE_TYPE, type); 1069 break; 1070 default: 1071 skipTag(); 1072 } 1073 } 1074 cv.put(Attendees.ATTENDEE_RELATIONSHIP, Attendees.RELATIONSHIP_ATTENDEE); 1075 return cv; 1076 } 1077 1078 private String bodyParser() throws IOException { 1079 String body = null; 1080 while (nextTag(Tags.BASE_BODY) != END) { 1081 switch (tag) { 1082 case Tags.BASE_DATA: 1083 body = getValue(); 1084 break; 1085 default: 1086 skipTag(); 1087 } 1088 } 1089 1090 // Handle null data without error 1091 if (body == null) return ""; 1092 // Remove \r's from any body text 1093 return body.replace("\r\n", "\n"); 1094 } 1095 1096 public void addParser(CalendarOperations ops) throws IOException { 1097 String serverId = null; 1098 while (nextTag(Tags.SYNC_ADD) != END) { 1099 switch (tag) { 1100 case Tags.SYNC_SERVER_ID: // same as 1101 serverId = getValue(); 1102 break; 1103 case Tags.SYNC_APPLICATION_DATA: 1104 addEvent(ops, serverId, false); 1105 break; 1106 default: 1107 skipTag(); 1108 } 1109 } 1110 } 1111 1112 private Cursor getServerIdCursor(String serverId) { 1113 return mContentResolver.query(mAccountUri, ID_PROJECTION, SERVER_ID_AND_CALENDAR_ID, 1114 new String[] {serverId, mCalendarIdString}, null); 1115 } 1116 1117 private Cursor getClientIdCursor(String clientId) { 1118 mBindArgument[0] = clientId; 1119 return mContentResolver.query(mAccountUri, ID_PROJECTION, CLIENT_ID_SELECTION, 1120 mBindArgument, null); 1121 } 1122 1123 public void deleteParser(CalendarOperations ops) throws IOException { 1124 while (nextTag(Tags.SYNC_DELETE) != END) { 1125 switch (tag) { 1126 case Tags.SYNC_SERVER_ID: 1127 String serverId = getValue(); 1128 // Find the event with the given serverId 1129 Cursor c = getServerIdCursor(serverId); 1130 try { 1131 if (c.moveToFirst()) { 1132 userLog("Deleting ", serverId); 1133 ops.delete(c.getLong(0), serverId); 1134 } 1135 } finally { 1136 c.close(); 1137 } 1138 break; 1139 default: 1140 skipTag(); 1141 } 1142 } 1143 } 1144 1145 /** 1146 * A change is handled as a delete (including all exceptions) and an add 1147 * This isn't as efficient as attempting to traverse the original and all of its exceptions, 1148 * but changes happen infrequently and this code is both simpler and easier to maintain 1149 * @param ops the array of pending ContactProviderOperations. 1150 * @throws IOException 1151 */ 1152 public void changeParser(CalendarOperations ops) throws IOException { 1153 String serverId = null; 1154 while (nextTag(Tags.SYNC_CHANGE) != END) { 1155 switch (tag) { 1156 case Tags.SYNC_SERVER_ID: 1157 serverId = getValue(); 1158 break; 1159 case Tags.SYNC_APPLICATION_DATA: 1160 userLog("Changing " + serverId); 1161 addEvent(ops, serverId, true); 1162 break; 1163 default: 1164 skipTag(); 1165 } 1166 } 1167 } 1168 1169 @Override 1170 public void commandsParser() throws IOException { 1171 while (nextTag(Tags.SYNC_COMMANDS) != END) { 1172 if (tag == Tags.SYNC_ADD) { 1173 addParser(mOps); 1174 incrementChangeCount(); 1175 } else if (tag == Tags.SYNC_DELETE) { 1176 deleteParser(mOps); 1177 incrementChangeCount(); 1178 } else if (tag == Tags.SYNC_CHANGE) { 1179 changeParser(mOps); 1180 incrementChangeCount(); 1181 } else 1182 skipTag(); 1183 } 1184 } 1185 1186 @Override 1187 public void commit() throws IOException { 1188 userLog("Calendar SyncKey saved as: ", mMailbox.mSyncKey); 1189 // Save the syncKey here, using the Helper provider by Calendar provider 1190 mOps.add(SyncStateContract.Helpers.newSetOperation( 1191 asSyncAdapter(SyncState.CONTENT_URI, mEmailAddress, 1192 Eas.EXCHANGE_ACCOUNT_MANAGER_TYPE), 1193 mAccountManagerAccount, 1194 mMailbox.mSyncKey.getBytes())); 1195 1196 // We need to send cancellations now, because the Event won't exist after the commit 1197 for (long eventId: mSendCancelIdList) { 1198 EmailContent.Message msg; 1199 try { 1200 msg = CalendarUtilities.createMessageForEventId(mContext, eventId, 1201 EmailContent.Message.FLAG_OUTGOING_MEETING_CANCEL, null, 1202 mAccount); 1203 } catch (RemoteException e) { 1204 // Nothing to do here; the Event may no longer exist 1205 continue; 1206 } 1207 if (msg != null) { 1208 EasOutboxService.sendMessage(mContext, mAccount.mId, msg); 1209 } 1210 } 1211 1212 // Execute these all at once... 1213 mOps.execute(); 1214 1215 if (mOps.mResults != null) { 1216 // Clear dirty and mark flags for updates sent to server 1217 if (!mUploadedIdList.isEmpty()) { 1218 ContentValues cv = new ContentValues(); 1219 cv.put(Events.DIRTY, 0); 1220 cv.put(EVENT_SYNC_MARK, "0"); 1221 for (long eventId : mUploadedIdList) { 1222 mContentResolver.update( 1223 asSyncAdapter( 1224 ContentUris.withAppendedId(Events.CONTENT_URI, eventId), 1225 mEmailAddress, Eas.EXCHANGE_ACCOUNT_MANAGER_TYPE), cv, 1226 null, null); 1227 } 1228 } 1229 // Delete events marked for deletion 1230 if (!mDeletedIdList.isEmpty()) { 1231 for (long eventId : mDeletedIdList) { 1232 mContentResolver.delete( 1233 asSyncAdapter( 1234 ContentUris.withAppendedId(Events.CONTENT_URI, eventId), 1235 mEmailAddress, Eas.EXCHANGE_ACCOUNT_MANAGER_TYPE), null, 1236 null); 1237 } 1238 } 1239 // Send any queued up email (invitations replies, etc.) 1240 for (Message msg: mOutgoingMailList) { 1241 EasOutboxService.sendMessage(mContext, mAccount.mId, msg); 1242 } 1243 } 1244 } 1245 1246 public void addResponsesParser() throws IOException { 1247 String serverId = null; 1248 String clientId = null; 1249 int status = -1; 1250 ContentValues cv = new ContentValues(); 1251 while (nextTag(Tags.SYNC_ADD) != END) { 1252 switch (tag) { 1253 case Tags.SYNC_SERVER_ID: 1254 serverId = getValue(); 1255 break; 1256 case Tags.SYNC_CLIENT_ID: 1257 clientId = getValue(); 1258 break; 1259 case Tags.SYNC_STATUS: 1260 status = getValueInt(); 1261 if (status != 1) { 1262 userLog("Attempt to add event failed with status: " + status); 1263 } 1264 break; 1265 default: 1266 skipTag(); 1267 } 1268 } 1269 1270 if (clientId == null) return; 1271 if (serverId == null) { 1272 // TODO Reconsider how to handle this 1273 serverId = "FAIL:" + status; 1274 } 1275 1276 Cursor c = getClientIdCursor(clientId); 1277 try { 1278 if (c.moveToFirst()) { 1279 cv.put(Events._SYNC_ID, serverId); 1280 cv.put(Events.SYNC_DATA2, clientId); 1281 long id = c.getLong(0); 1282 // Write the serverId into the Event 1283 mOps.add(ContentProviderOperation 1284 .newUpdate( 1285 asSyncAdapter( 1286 ContentUris.withAppendedId(Events.CONTENT_URI, id), 1287 mEmailAddress, Eas.EXCHANGE_ACCOUNT_MANAGER_TYPE)) 1288 .withValues(cv).build()); 1289 userLog("New event " + clientId + " was given serverId: " + serverId); 1290 } 1291 } finally { 1292 c.close(); 1293 } 1294 } 1295 1296 public void changeResponsesParser() throws IOException { 1297 String serverId = null; 1298 String status = null; 1299 while (nextTag(Tags.SYNC_CHANGE) != END) { 1300 switch (tag) { 1301 case Tags.SYNC_SERVER_ID: 1302 serverId = getValue(); 1303 break; 1304 case Tags.SYNC_STATUS: 1305 status = getValue(); 1306 break; 1307 default: 1308 skipTag(); 1309 } 1310 } 1311 if (serverId != null && status != null) { 1312 userLog("Changed event " + serverId + " failed with status: " + status); 1313 } 1314 } 1315 1316 1317 @Override 1318 public void responsesParser() throws IOException { 1319 // Handle server responses here (for Add and Change) 1320 while (nextTag(Tags.SYNC_RESPONSES) != END) { 1321 if (tag == Tags.SYNC_ADD) { 1322 addResponsesParser(); 1323 } else if (tag == Tags.SYNC_CHANGE) { 1324 changeResponsesParser(); 1325 } else 1326 skipTag(); 1327 } 1328 } 1329 } 1330 1331 protected class CalendarOperations extends ArrayList<ContentProviderOperation> { 1332 private static final long serialVersionUID = 1L; 1333 public int mCount = 0; 1334 private ContentProviderResult[] mResults = null; 1335 private int mEventStart = 0; 1336 1337 @Override 1338 public boolean add(ContentProviderOperation op) { 1339 super.add(op); 1340 mCount++; 1341 return true; 1342 } 1343 1344 public int newEvent(ContentProviderOperation op) { 1345 mEventStart = mCount; 1346 add(op); 1347 return mEventStart; 1348 } 1349 1350 public int newDelete(long id, String serverId) { 1351 int offset = mCount; 1352 delete(id, serverId); 1353 return offset; 1354 } 1355 1356 public void newAttendee(ContentValues cv) { 1357 newAttendee(cv, mEventStart); 1358 } 1359 1360 public void newAttendee(ContentValues cv, int eventStart) { 1361 add(ContentProviderOperation 1362 .newInsert(asSyncAdapter(Attendees.CONTENT_URI, mEmailAddress, 1363 Eas.EXCHANGE_ACCOUNT_MANAGER_TYPE)).withValues(cv) 1364 .withValueBackReference(Attendees.EVENT_ID, eventStart).build()); 1365 } 1366 1367 public void updatedAttendee(ContentValues cv, long id) { 1368 cv.put(Attendees.EVENT_ID, id); 1369 add(ContentProviderOperation.newInsert(asSyncAdapter(Attendees.CONTENT_URI, 1370 mEmailAddress, Eas.EXCHANGE_ACCOUNT_MANAGER_TYPE)).withValues(cv).build()); 1371 } 1372 1373 public void newException(ContentValues cv) { 1374 add(ContentProviderOperation.newInsert( 1375 asSyncAdapter(Events.CONTENT_URI, mEmailAddress, 1376 Eas.EXCHANGE_ACCOUNT_MANAGER_TYPE)).withValues(cv).build()); 1377 } 1378 1379 public void newExtendedProperty(String name, String value) { 1380 add(ContentProviderOperation 1381 .newInsert(asSyncAdapter(ExtendedProperties.CONTENT_URI, mEmailAddress, 1382 Eas.EXCHANGE_ACCOUNT_MANAGER_TYPE)) 1383 .withValue(ExtendedProperties.NAME, name) 1384 .withValue(ExtendedProperties.VALUE, value) 1385 .withValueBackReference(ExtendedProperties.EVENT_ID, mEventStart).build()); 1386 } 1387 1388 public void updatedExtendedProperty(String name, String value, long id) { 1389 // Find an existing ExtendedProperties row for this event and property name 1390 Cursor c = mService.mContentResolver.query(ExtendedProperties.CONTENT_URI, 1391 EXTENDED_PROPERTY_PROJECTION, EVENT_ID_AND_NAME, 1392 new String[] {Long.toString(id), name}, null); 1393 long extendedPropertyId = -1; 1394 // If there is one, capture its _id 1395 if (c != null) { 1396 try { 1397 if (c.moveToFirst()) { 1398 extendedPropertyId = c.getLong(EXTENDED_PROPERTY_ID); 1399 } 1400 } finally { 1401 c.close(); 1402 } 1403 } 1404 // Either do an update or an insert, depending on whether one 1405 // already exists 1406 if (extendedPropertyId >= 0) { 1407 add(ContentProviderOperation 1408 .newUpdate( 1409 ContentUris.withAppendedId( 1410 asSyncAdapter(ExtendedProperties.CONTENT_URI, 1411 mEmailAddress, Eas.EXCHANGE_ACCOUNT_MANAGER_TYPE), 1412 extendedPropertyId)) 1413 .withValue(ExtendedProperties.VALUE, value).build()); 1414 } else { 1415 newExtendedProperty(name, value); 1416 } 1417 } 1418 1419 public void newReminder(int mins, int eventStart) { 1420 add(ContentProviderOperation 1421 .newInsert(asSyncAdapter(Reminders.CONTENT_URI, mEmailAddress, 1422 Eas.EXCHANGE_ACCOUNT_MANAGER_TYPE)) 1423 .withValue(Reminders.MINUTES, mins) 1424 .withValue(Reminders.METHOD, Reminders.METHOD_ALERT) 1425 .withValueBackReference(ExtendedProperties.EVENT_ID, eventStart).build()); 1426 } 1427 1428 public void newReminder(int mins) { 1429 newReminder(mins, mEventStart); 1430 } 1431 1432 public void delete(long id, String syncId) { 1433 add(ContentProviderOperation.newDelete( 1434 asSyncAdapter(ContentUris.withAppendedId(Events.CONTENT_URI, id), 1435 mEmailAddress, Eas.EXCHANGE_ACCOUNT_MANAGER_TYPE)).build()); 1436 // Delete the exceptions for this Event (CalendarProvider doesn't do 1437 // this) 1438 add(ContentProviderOperation 1439 .newDelete(asSyncAdapter(Events.CONTENT_URI, mEmailAddress, 1440 Eas.EXCHANGE_ACCOUNT_MANAGER_TYPE)) 1441 .withSelection(Events.ORIGINAL_SYNC_ID + "=?", new String[] {syncId}).build()); 1442 } 1443 1444 public void execute() { 1445 synchronized (mService.getSynchronizer()) { 1446 if (!mService.isStopped()) { 1447 try { 1448 if (!isEmpty()) { 1449 mService.userLog("Executing ", size(), " CPO's"); 1450 mResults = mContext.getContentResolver().applyBatch( 1451 CalendarContract.AUTHORITY, this); 1452 } 1453 } catch (RemoteException e) { 1454 // There is nothing sensible to be done here 1455 Log.e(TAG, "problem inserting event during server update", e); 1456 } catch (OperationApplicationException e) { 1457 // There is nothing sensible to be done here 1458 Log.e(TAG, "problem inserting event during server update", e); 1459 } 1460 } 1461 } 1462 } 1463 } 1464 1465 private String decodeVisibility(int visibility) { 1466 int easVisibility = 0; 1467 switch(visibility) { 1468 case Events.ACCESS_DEFAULT: 1469 easVisibility = 0; 1470 break; 1471 case Events.ACCESS_PUBLIC: 1472 easVisibility = 1; 1473 break; 1474 case Events.ACCESS_PRIVATE: 1475 easVisibility = 2; 1476 break; 1477 case Events.ACCESS_CONFIDENTIAL: 1478 easVisibility = 3; 1479 break; 1480 } 1481 return Integer.toString(easVisibility); 1482 } 1483 1484 private int getInt(ContentValues cv, String column) { 1485 Integer i = cv.getAsInteger(column); 1486 if (i == null) return 0; 1487 return i; 1488 } 1489 1490 private void sendEvent(Entity entity, String clientId, Serializer s) 1491 throws IOException { 1492 // Serialize for EAS here 1493 // Set uid with the client id we created 1494 // 1) Serialize the top-level event 1495 // 2) Serialize attendees and reminders from subvalues 1496 // 3) Look for exceptions and serialize with the top-level event 1497 ContentValues entityValues = entity.getEntityValues(); 1498 final boolean isException = (clientId == null); 1499 boolean hasAttendees = false; 1500 final boolean isChange = entityValues.containsKey(Events._SYNC_ID); 1501 final Double version = mService.mProtocolVersionDouble; 1502 final boolean allDay = 1503 CalendarUtilities.getIntegerValueAsBoolean(entityValues, Events.ALL_DAY); 1504 1505 // NOTE: Exchange 2003 (EAS 2.5) seems to require the "exception deleted" and "exception 1506 // start time" data before other data in exceptions. Failure to do so results in a 1507 // status 6 error during sync 1508 if (isException) { 1509 // Send exception deleted flag if necessary 1510 Integer deleted = entityValues.getAsInteger(Events.DELETED); 1511 boolean isDeleted = deleted != null && deleted == 1; 1512 Integer eventStatus = entityValues.getAsInteger(Events.STATUS); 1513 boolean isCanceled = eventStatus != null && eventStatus.equals(Events.STATUS_CANCELED); 1514 if (isDeleted || isCanceled) { 1515 s.data(Tags.CALENDAR_EXCEPTION_IS_DELETED, "1"); 1516 // If we're deleted, the UI will continue to show this exception until we mark 1517 // it canceled, so we'll do that here... 1518 if (isDeleted && !isCanceled) { 1519 final long eventId = entityValues.getAsLong(Events._ID); 1520 ContentValues cv = new ContentValues(); 1521 cv.put(Events.STATUS, Events.STATUS_CANCELED); 1522 mService.mContentResolver.update( 1523 asSyncAdapter(ContentUris.withAppendedId(Events.CONTENT_URI, eventId), 1524 mEmailAddress, Eas.EXCHANGE_ACCOUNT_MANAGER_TYPE), cv, null, 1525 null); 1526 } 1527 } else { 1528 s.data(Tags.CALENDAR_EXCEPTION_IS_DELETED, "0"); 1529 } 1530 1531 // TODO Add reminders to exceptions (allow them to be specified!) 1532 Long originalTime = entityValues.getAsLong(Events.ORIGINAL_INSTANCE_TIME); 1533 if (originalTime != null) { 1534 final boolean originalAllDay = 1535 CalendarUtilities.getIntegerValueAsBoolean(entityValues, 1536 Events.ORIGINAL_ALL_DAY); 1537 if (originalAllDay) { 1538 // For all day events, we need our local all-day time 1539 originalTime = 1540 CalendarUtilities.getLocalAllDayCalendarTime(originalTime, mLocalTimeZone); 1541 } 1542 s.data(Tags.CALENDAR_EXCEPTION_START_TIME, 1543 CalendarUtilities.millisToEasDateTime(originalTime)); 1544 } else { 1545 // Illegal; what should we do? 1546 } 1547 } 1548 1549 // Get the event's time zone 1550 String timeZoneName = 1551 entityValues.getAsString(allDay ? EVENT_SAVED_TIMEZONE_COLUMN : Events.EVENT_TIMEZONE); 1552 if (timeZoneName == null) { 1553 timeZoneName = mLocalTimeZone.getID(); 1554 } 1555 TimeZone eventTimeZone = TimeZone.getTimeZone(timeZoneName); 1556 1557 if (!isException) { 1558 // A time zone is required in all EAS events; we'll use the default if none is set 1559 // Exchange 2003 seems to require this first... :-) 1560 String timeZone = CalendarUtilities.timeZoneToTziString(eventTimeZone); 1561 s.data(Tags.CALENDAR_TIME_ZONE, timeZone); 1562 } 1563 1564 s.data(Tags.CALENDAR_ALL_DAY_EVENT, allDay ? "1" : "0"); 1565 1566 // DTSTART is always supplied 1567 long startTime = entityValues.getAsLong(Events.DTSTART); 1568 // Determine endTime; it's either provided as DTEND or we calculate using DURATION 1569 // If no DURATION is provided, we default to one hour 1570 long endTime; 1571 if (entityValues.containsKey(Events.DTEND)) { 1572 endTime = entityValues.getAsLong(Events.DTEND); 1573 } else { 1574 long durationMillis = HOURS; 1575 if (entityValues.containsKey(Events.DURATION)) { 1576 Duration duration = new Duration(); 1577 try { 1578 duration.parse(entityValues.getAsString(Events.DURATION)); 1579 durationMillis = duration.getMillis(); 1580 } catch (ParseException e) { 1581 // Can't do much about this; use the default (1 hour) 1582 } 1583 } 1584 endTime = startTime + durationMillis; 1585 } 1586 if (allDay) { 1587 TimeZone tz = mLocalTimeZone; 1588 startTime = CalendarUtilities.getLocalAllDayCalendarTime(startTime, tz); 1589 endTime = CalendarUtilities.getLocalAllDayCalendarTime(endTime, tz); 1590 } 1591 s.data(Tags.CALENDAR_START_TIME, CalendarUtilities.millisToEasDateTime(startTime)); 1592 s.data(Tags.CALENDAR_END_TIME, CalendarUtilities.millisToEasDateTime(endTime)); 1593 1594 s.data(Tags.CALENDAR_DTSTAMP, 1595 CalendarUtilities.millisToEasDateTime(System.currentTimeMillis())); 1596 1597 String loc = entityValues.getAsString(Events.EVENT_LOCATION); 1598 if (!TextUtils.isEmpty(loc)) { 1599 if (version < Eas.SUPPORTED_PROTOCOL_EX2007_DOUBLE) { 1600 // EAS 2.5 doesn't like bare line feeds 1601 loc = Utility.replaceBareLfWithCrlf(loc); 1602 } 1603 s.data(Tags.CALENDAR_LOCATION, loc); 1604 } 1605 s.writeStringValue(entityValues, Events.TITLE, Tags.CALENDAR_SUBJECT); 1606 1607 if (version >= Eas.SUPPORTED_PROTOCOL_EX2007_DOUBLE) { 1608 s.start(Tags.BASE_BODY); 1609 s.data(Tags.BASE_TYPE, "1"); 1610 s.writeStringValue(entityValues, Events.DESCRIPTION, Tags.BASE_DATA); 1611 s.end(); 1612 } else { 1613 // EAS 2.5 doesn't like bare line feeds 1614 s.writeStringValue(entityValues, Events.DESCRIPTION, Tags.CALENDAR_BODY); 1615 } 1616 1617 if (!isException) { 1618 // For Exchange 2003, only upsync if the event is new 1619 if ((version >= Eas.SUPPORTED_PROTOCOL_EX2007_DOUBLE) || !isChange) { 1620 s.writeStringValue(entityValues, Events.ORGANIZER, Tags.CALENDAR_ORGANIZER_EMAIL); 1621 } 1622 1623 String rrule = entityValues.getAsString(Events.RRULE); 1624 if (rrule != null) { 1625 CalendarUtilities.recurrenceFromRrule(rrule, startTime, s); 1626 } 1627 1628 // Handle associated data EXCEPT for attendees, which have to be grouped 1629 ArrayList<NamedContentValues> subValues = entity.getSubValues(); 1630 // The earliest of the reminders for this Event; we can only send one reminder... 1631 int earliestReminder = -1; 1632 for (NamedContentValues ncv: subValues) { 1633 Uri ncvUri = ncv.uri; 1634 ContentValues ncvValues = ncv.values; 1635 if (ncvUri.equals(ExtendedProperties.CONTENT_URI)) { 1636 String propertyName = 1637 ncvValues.getAsString(ExtendedProperties.NAME); 1638 String propertyValue = 1639 ncvValues.getAsString(ExtendedProperties.VALUE); 1640 if (TextUtils.isEmpty(propertyValue)) { 1641 continue; 1642 } 1643 if (propertyName.equals(EXTENDED_PROPERTY_CATEGORIES)) { 1644 // Send all the categories back to the server 1645 // We've saved them as a String of delimited tokens 1646 StringTokenizer st = 1647 new StringTokenizer(propertyValue, CATEGORY_TOKENIZER_DELIMITER); 1648 if (st.countTokens() > 0) { 1649 s.start(Tags.CALENDAR_CATEGORIES); 1650 while (st.hasMoreTokens()) { 1651 String category = st.nextToken(); 1652 s.data(Tags.CALENDAR_CATEGORY, category); 1653 } 1654 s.end(); 1655 } 1656 } 1657 } else if (ncvUri.equals(Reminders.CONTENT_URI)) { 1658 Integer mins = ncvValues.getAsInteger(Reminders.MINUTES); 1659 if (mins != null) { 1660 // -1 means "default", which for Exchange, is 30 1661 if (mins < 0) { 1662 mins = 30; 1663 } 1664 // Save this away if it's the earliest reminder (greatest minutes) 1665 if (mins > earliestReminder) { 1666 earliestReminder = mins; 1667 } 1668 } 1669 } 1670 } 1671 1672 // If we have a reminder, send it to the server 1673 if (earliestReminder >= 0) { 1674 s.data(Tags.CALENDAR_REMINDER_MINS_BEFORE, Integer.toString(earliestReminder)); 1675 } 1676 1677 // We've got to send a UID, unless this is an exception. If the event is new, we've 1678 // generated one; if not, we should have gotten one from extended properties. 1679 if (clientId != null) { 1680 s.data(Tags.CALENDAR_UID, clientId); 1681 } 1682 1683 // Handle attendee data here; keep track of organizer and stream it afterward 1684 String organizerName = null; 1685 String organizerEmail = null; 1686 for (NamedContentValues ncv: subValues) { 1687 Uri ncvUri = ncv.uri; 1688 ContentValues ncvValues = ncv.values; 1689 if (ncvUri.equals(Attendees.CONTENT_URI)) { 1690 Integer relationship = ncvValues.getAsInteger(Attendees.ATTENDEE_RELATIONSHIP); 1691 // If there's no relationship, we can't create this for EAS 1692 // Similarly, we need an attendee email for each invitee 1693 if (relationship != null && ncvValues.containsKey(Attendees.ATTENDEE_EMAIL)) { 1694 // Organizer isn't among attendees in EAS 1695 if (relationship == Attendees.RELATIONSHIP_ORGANIZER) { 1696 organizerName = ncvValues.getAsString(Attendees.ATTENDEE_NAME); 1697 organizerEmail = ncvValues.getAsString(Attendees.ATTENDEE_EMAIL); 1698 continue; 1699 } 1700 if (!hasAttendees) { 1701 s.start(Tags.CALENDAR_ATTENDEES); 1702 hasAttendees = true; 1703 } 1704 s.start(Tags.CALENDAR_ATTENDEE); 1705 String attendeeEmail = ncvValues.getAsString(Attendees.ATTENDEE_EMAIL); 1706 String attendeeName = ncvValues.getAsString(Attendees.ATTENDEE_NAME); 1707 if (attendeeName == null) { 1708 attendeeName = attendeeEmail; 1709 } 1710 s.data(Tags.CALENDAR_ATTENDEE_NAME, attendeeName); 1711 s.data(Tags.CALENDAR_ATTENDEE_EMAIL, attendeeEmail); 1712 if (version >= Eas.SUPPORTED_PROTOCOL_EX2007_DOUBLE) { 1713 s.data(Tags.CALENDAR_ATTENDEE_TYPE, "1"); // Required 1714 } 1715 s.end(); // Attendee 1716 } 1717 } 1718 } 1719 if (hasAttendees) { 1720 s.end(); // Attendees 1721 } 1722 1723 // Get busy status from Attendees table 1724 long eventId = entityValues.getAsLong(Events._ID); 1725 int busyStatus = CalendarUtilities.BUSY_STATUS_TENTATIVE; 1726 Cursor c = mService.mContentResolver.query( 1727 asSyncAdapter(Attendees.CONTENT_URI, mEmailAddress, 1728 Eas.EXCHANGE_ACCOUNT_MANAGER_TYPE), 1729 ATTENDEE_STATUS_PROJECTION, EVENT_AND_EMAIL, 1730 new String[] {Long.toString(eventId), mEmailAddress}, null); 1731 if (c != null) { 1732 try { 1733 if (c.moveToFirst()) { 1734 busyStatus = CalendarUtilities.busyStatusFromAttendeeStatus( 1735 c.getInt(ATTENDEE_STATUS_COLUMN_STATUS)); 1736 } 1737 } finally { 1738 c.close(); 1739 } 1740 } 1741 s.data(Tags.CALENDAR_BUSY_STATUS, Integer.toString(busyStatus)); 1742 1743 // Meeting status, 0 = appointment, 1 = meeting, 3 = attendee 1744 if (mEmailAddress.equalsIgnoreCase(organizerEmail)) { 1745 s.data(Tags.CALENDAR_MEETING_STATUS, hasAttendees ? "1" : "0"); 1746 } else { 1747 s.data(Tags.CALENDAR_MEETING_STATUS, "3"); 1748 } 1749 1750 // For Exchange 2003, only upsync if the event is new 1751 if (((version >= Eas.SUPPORTED_PROTOCOL_EX2007_DOUBLE) || !isChange) && 1752 organizerName != null) { 1753 s.data(Tags.CALENDAR_ORGANIZER_NAME, organizerName); 1754 } 1755 1756 // NOTE: Sensitivity must NOT be sent to the server for exceptions in Exchange 2003 1757 // The result will be a status 6 failure during sync 1758 Integer visibility = entityValues.getAsInteger(Events.ACCESS_LEVEL); 1759 if (visibility != null) { 1760 s.data(Tags.CALENDAR_SENSITIVITY, decodeVisibility(visibility)); 1761 } else { 1762 // Default to private if not set 1763 s.data(Tags.CALENDAR_SENSITIVITY, "1"); 1764 } 1765 } 1766 } 1767 1768 /** 1769 * Convenience method for sending an email to the organizer declining the meeting 1770 * @param entity 1771 * @param clientId 1772 */ 1773 private void sendDeclinedEmail(Entity entity, String clientId) { 1774 Message msg = 1775 CalendarUtilities.createMessageForEntity(mContext, entity, 1776 Message.FLAG_OUTGOING_MEETING_DECLINE, clientId, mAccount); 1777 if (msg != null) { 1778 userLog("Queueing declined response to " + msg.mTo); 1779 mOutgoingMailList.add(msg); 1780 } 1781 } 1782 1783 @Override 1784 public boolean sendLocalChanges(Serializer s) throws IOException { 1785 ContentResolver cr = mService.mContentResolver; 1786 1787 if (getSyncKey().equals("0")) { 1788 return false; 1789 } 1790 1791 try { 1792 // We've got to handle exceptions as part of the parent when changes occur, so we need 1793 // to find new/changed exceptions and mark the parent dirty 1794 ArrayList<Long> orphanedExceptions = new ArrayList<Long>(); 1795 Cursor c = cr.query(Events.CONTENT_URI, ORIGINAL_EVENT_PROJECTION, 1796 DIRTY_EXCEPTION_IN_CALENDAR, mCalendarIdArgument, null); 1797 try { 1798 ContentValues cv = new ContentValues(); 1799 // We use _sync_mark here to distinguish dirty parents from parents with dirty 1800 // exceptions 1801 cv.put(EVENT_SYNC_MARK, "1"); 1802 while (c.moveToNext()) { 1803 // Mark the parents of dirty exceptions 1804 long parentId = c.getLong(0); 1805 int cnt = cr.update( 1806 asSyncAdapter(Events.CONTENT_URI, mEmailAddress, 1807 Eas.EXCHANGE_ACCOUNT_MANAGER_TYPE), cv, 1808 EVENT_ID_AND_CALENDAR_ID, new String[] { 1809 Long.toString(parentId), mCalendarIdString 1810 }); 1811 // Keep track of any orphaned exceptions 1812 if (cnt == 0) { 1813 orphanedExceptions.add(c.getLong(1)); 1814 } 1815 } 1816 } finally { 1817 c.close(); 1818 } 1819 1820 // Delete any orphaned exceptions 1821 for (long orphan : orphanedExceptions) { 1822 userLog(TAG, "Deleted orphaned exception: " + orphan); 1823 cr.delete( 1824 asSyncAdapter(ContentUris.withAppendedId(Events.CONTENT_URI, orphan), 1825 mEmailAddress, Eas.EXCHANGE_ACCOUNT_MANAGER_TYPE), null, null); 1826 } 1827 orphanedExceptions.clear(); 1828 1829 // Now we can go through dirty/marked top-level events and send them 1830 // back to the server 1831 EntityIterator eventIterator = EventsEntity.newEntityIterator(cr.query( 1832 asSyncAdapter(Events.CONTENT_URI, mEmailAddress, 1833 Eas.EXCHANGE_ACCOUNT_MANAGER_TYPE), null, 1834 DIRTY_OR_MARKED_TOP_LEVEL_IN_CALENDAR, mCalendarIdArgument, null), cr); 1835 ContentValues cidValues = new ContentValues(); 1836 1837 try { 1838 boolean first = true; 1839 while (eventIterator.hasNext()) { 1840 Entity entity = eventIterator.next(); 1841 1842 // For each of these entities, create the change commands 1843 ContentValues entityValues = entity.getEntityValues(); 1844 String serverId = entityValues.getAsString(Events._SYNC_ID); 1845 1846 // We first need to check whether we can upsync this event; our test for this 1847 // is currently the value of EXTENDED_PROPERTY_ATTENDEES_REDACTED 1848 // If this is set to "1", we can't upsync the event 1849 for (NamedContentValues ncv: entity.getSubValues()) { 1850 if (ncv.uri.equals(ExtendedProperties.CONTENT_URI)) { 1851 ContentValues ncvValues = ncv.values; 1852 if (ncvValues.getAsString(ExtendedProperties.NAME).equals( 1853 EXTENDED_PROPERTY_UPSYNC_PROHIBITED)) { 1854 if ("1".equals(ncvValues.getAsString(ExtendedProperties.VALUE))) { 1855 // Make sure we mark this to clear the dirty flag 1856 mUploadedIdList.add(entityValues.getAsLong(Events._ID)); 1857 continue; 1858 } 1859 } 1860 } 1861 } 1862 1863 // Find our uid in the entity; otherwise create one 1864 String clientId = entityValues.getAsString(Events.SYNC_DATA2); 1865 if (clientId == null) { 1866 clientId = UUID.randomUUID().toString(); 1867 } 1868 1869 // EAS 2.5 needs: BusyStatus DtStamp EndTime Sensitivity StartTime TimeZone UID 1870 // We can generate all but what we're testing for below 1871 String organizerEmail = entityValues.getAsString(Events.ORGANIZER); 1872 boolean selfOrganizer = organizerEmail.equalsIgnoreCase(mEmailAddress); 1873 1874 if (!entityValues.containsKey(Events.DTSTART) 1875 || (!entityValues.containsKey(Events.DURATION) && 1876 !entityValues.containsKey(Events.DTEND)) 1877 || organizerEmail == null) { 1878 continue; 1879 } 1880 1881 if (first) { 1882 s.start(Tags.SYNC_COMMANDS); 1883 userLog("Sending Calendar changes to the server"); 1884 first = false; 1885 } 1886 long eventId = entityValues.getAsLong(Events._ID); 1887 if (serverId == null) { 1888 // This is a new event; create a clientId 1889 userLog("Creating new event with clientId: ", clientId); 1890 s.start(Tags.SYNC_ADD).data(Tags.SYNC_CLIENT_ID, clientId); 1891 // And save it in the Event as the local id 1892 cidValues.put(Events.SYNC_DATA2, clientId); 1893 cidValues.put(EVENT_SYNC_VERSION, "0"); 1894 cr.update( 1895 asSyncAdapter( 1896 ContentUris.withAppendedId(Events.CONTENT_URI, eventId), 1897 mEmailAddress, Eas.EXCHANGE_ACCOUNT_MANAGER_TYPE), 1898 cidValues, null, null); 1899 } else { 1900 if (entityValues.getAsInteger(Events.DELETED) == 1) { 1901 userLog("Deleting event with serverId: ", serverId); 1902 s.start(Tags.SYNC_DELETE).data(Tags.SYNC_SERVER_ID, serverId).end(); 1903 mDeletedIdList.add(eventId); 1904 if (selfOrganizer) { 1905 mSendCancelIdList.add(eventId); 1906 } else { 1907 sendDeclinedEmail(entity, clientId); 1908 } 1909 continue; 1910 } 1911 userLog("Upsync change to event with serverId: " + serverId); 1912 // Get the current version 1913 String version = entityValues.getAsString(EVENT_SYNC_VERSION); 1914 // This should never be null, but catch this error anyway 1915 // Version should be "0" when we create the event, so use that 1916 if (version == null) { 1917 version = "0"; 1918 } else { 1919 // Increment and save 1920 try { 1921 version = Integer.toString((Integer.parseInt(version) + 1)); 1922 } catch (Exception e) { 1923 // Handle the case in which someone writes a non-integer here; 1924 // shouldn't happen, but we don't want to kill the sync for his 1925 version = "0"; 1926 } 1927 } 1928 cidValues.put(EVENT_SYNC_VERSION, version); 1929 // Also save in entityValues so that we send it this time around 1930 entityValues.put(EVENT_SYNC_VERSION, version); 1931 cr.update( 1932 asSyncAdapter( 1933 ContentUris.withAppendedId(Events.CONTENT_URI, eventId), 1934 mEmailAddress, Eas.EXCHANGE_ACCOUNT_MANAGER_TYPE), 1935 cidValues, null, null); 1936 s.start(Tags.SYNC_CHANGE).data(Tags.SYNC_SERVER_ID, serverId); 1937 } 1938 s.start(Tags.SYNC_APPLICATION_DATA); 1939 1940 sendEvent(entity, clientId, s); 1941 1942 // Now, the hard part; find exceptions for this event 1943 if (serverId != null) { 1944 EntityIterator exIterator = EventsEntity.newEntityIterator(cr.query( 1945 asSyncAdapter(Events.CONTENT_URI, mEmailAddress, 1946 Eas.EXCHANGE_ACCOUNT_MANAGER_TYPE), null, 1947 ORIGINAL_EVENT_AND_CALENDAR, new String[] { 1948 serverId, mCalendarIdString 1949 }, null), cr); 1950 boolean exFirst = true; 1951 while (exIterator.hasNext()) { 1952 Entity exEntity = exIterator.next(); 1953 if (exFirst) { 1954 s.start(Tags.CALENDAR_EXCEPTIONS); 1955 exFirst = false; 1956 } 1957 s.start(Tags.CALENDAR_EXCEPTION); 1958 sendEvent(exEntity, null, s); 1959 ContentValues exValues = exEntity.getEntityValues(); 1960 if (getInt(exValues, Events.DIRTY) == 1) { 1961 // This is a new/updated exception, so we've got to notify our 1962 // attendees about it 1963 long exEventId = exValues.getAsLong(Events._ID); 1964 int flag; 1965 1966 // Copy subvalues into the exception; otherwise, we won't see the 1967 // attendees when preparing the message 1968 for (NamedContentValues ncv: entity.getSubValues()) { 1969 exEntity.addSubValue(ncv.uri, ncv.values); 1970 } 1971 1972 if ((getInt(exValues, Events.DELETED) == 1) || 1973 (getInt(exValues, Events.STATUS) == 1974 Events.STATUS_CANCELED)) { 1975 flag = Message.FLAG_OUTGOING_MEETING_CANCEL; 1976 if (!selfOrganizer) { 1977 // Send a cancellation notice to the organizer 1978 // Since CalendarProvider2 sets the organizer of exceptions 1979 // to the user, we have to reset it first to the original 1980 // organizer 1981 exValues.put(Events.ORGANIZER, 1982 entityValues.getAsString(Events.ORGANIZER)); 1983 sendDeclinedEmail(exEntity, clientId); 1984 } 1985 } else { 1986 flag = Message.FLAG_OUTGOING_MEETING_INVITE; 1987 } 1988 // Add the eventId of the exception to the uploaded id list, so that 1989 // the dirty/mark bits are cleared 1990 mUploadedIdList.add(exEventId); 1991 1992 // Copy version so the ics attachment shows the proper sequence # 1993 exValues.put(EVENT_SYNC_VERSION, 1994 entityValues.getAsString(EVENT_SYNC_VERSION)); 1995 // Copy location so that it's included in the outgoing email 1996 if (entityValues.containsKey(Events.EVENT_LOCATION)) { 1997 exValues.put(Events.EVENT_LOCATION, 1998 entityValues.getAsString(Events.EVENT_LOCATION)); 1999 } 2000 2001 if (selfOrganizer) { 2002 Message msg = 2003 CalendarUtilities.createMessageForEntity(mContext, 2004 exEntity, flag, clientId, mAccount); 2005 if (msg != null) { 2006 userLog("Queueing exception update to " + msg.mTo); 2007 mOutgoingMailList.add(msg); 2008 } 2009 } 2010 } 2011 s.end(); // EXCEPTION 2012 } 2013 if (!exFirst) { 2014 s.end(); // EXCEPTIONS 2015 } 2016 } 2017 2018 s.end().end(); // ApplicationData & Change 2019 mUploadedIdList.add(eventId); 2020 2021 // Go through the extended properties of this Event and pull out our tokenized 2022 // attendees list and the user attendee status; we will need them later 2023 String attendeeString = null; 2024 long attendeeStringId = -1; 2025 String userAttendeeStatus = null; 2026 long userAttendeeStatusId = -1; 2027 for (NamedContentValues ncv: entity.getSubValues()) { 2028 if (ncv.uri.equals(ExtendedProperties.CONTENT_URI)) { 2029 ContentValues ncvValues = ncv.values; 2030 String propertyName = 2031 ncvValues.getAsString(ExtendedProperties.NAME); 2032 if (propertyName.equals(EXTENDED_PROPERTY_ATTENDEES)) { 2033 attendeeString = 2034 ncvValues.getAsString(ExtendedProperties.VALUE); 2035 attendeeStringId = 2036 ncvValues.getAsLong(ExtendedProperties._ID); 2037 } else if (propertyName.equals( 2038 EXTENDED_PROPERTY_USER_ATTENDEE_STATUS)) { 2039 userAttendeeStatus = 2040 ncvValues.getAsString(ExtendedProperties.VALUE); 2041 userAttendeeStatusId = 2042 ncvValues.getAsLong(ExtendedProperties._ID); 2043 } 2044 } 2045 } 2046 2047 // Send the meeting invite if there are attendees and we're the organizer AND 2048 // if the Event itself is dirty (we might be syncing only because an exception 2049 // is dirty, in which case we DON'T send email about the Event) 2050 if (selfOrganizer && 2051 (getInt(entityValues, Events.DIRTY) == 1)) { 2052 EmailContent.Message msg = 2053 CalendarUtilities.createMessageForEventId(mContext, eventId, 2054 EmailContent.Message.FLAG_OUTGOING_MEETING_INVITE, clientId, 2055 mAccount); 2056 if (msg != null) { 2057 userLog("Queueing invitation to ", msg.mTo); 2058 mOutgoingMailList.add(msg); 2059 } 2060 // Make a list out of our tokenized attendees, if we have any 2061 ArrayList<String> originalAttendeeList = new ArrayList<String>(); 2062 if (attendeeString != null) { 2063 StringTokenizer st = 2064 new StringTokenizer(attendeeString, ATTENDEE_TOKENIZER_DELIMITER); 2065 while (st.hasMoreTokens()) { 2066 originalAttendeeList.add(st.nextToken()); 2067 } 2068 } 2069 StringBuilder newTokenizedAttendees = new StringBuilder(); 2070 // See if any attendees have been dropped and while we're at it, build 2071 // an updated String with tokenized attendee addresses 2072 for (NamedContentValues ncv: entity.getSubValues()) { 2073 if (ncv.uri.equals(Attendees.CONTENT_URI)) { 2074 String attendeeEmail = 2075 ncv.values.getAsString(Attendees.ATTENDEE_EMAIL); 2076 // Remove all found attendees 2077 originalAttendeeList.remove(attendeeEmail); 2078 newTokenizedAttendees.append(attendeeEmail); 2079 newTokenizedAttendees.append(ATTENDEE_TOKENIZER_DELIMITER); 2080 } 2081 } 2082 // Update extended properties with the new attendee list, if we have one 2083 // Otherwise, create one (this would be the case for Events created on 2084 // device or "legacy" events (before this code was added) 2085 ContentValues cv = new ContentValues(); 2086 cv.put(ExtendedProperties.VALUE, newTokenizedAttendees.toString()); 2087 if (attendeeString != null) { 2088 cr.update(asSyncAdapter(ContentUris.withAppendedId( 2089 ExtendedProperties.CONTENT_URI, attendeeStringId), 2090 mEmailAddress, Eas.EXCHANGE_ACCOUNT_MANAGER_TYPE), 2091 cv, null, null); 2092 } else { 2093 // If there wasn't an "attendees" property, insert one 2094 cv.put(ExtendedProperties.NAME, EXTENDED_PROPERTY_ATTENDEES); 2095 cv.put(ExtendedProperties.EVENT_ID, eventId); 2096 cr.insert(asSyncAdapter(ExtendedProperties.CONTENT_URI, 2097 mEmailAddress, Eas.EXCHANGE_ACCOUNT_MANAGER_TYPE), cv); 2098 } 2099 // Whoever is left has been removed from the attendee list; send them 2100 // a cancellation 2101 for (String removedAttendee: originalAttendeeList) { 2102 // Send a cancellation message to each of them 2103 msg = CalendarUtilities.createMessageForEventId(mContext, eventId, 2104 Message.FLAG_OUTGOING_MEETING_CANCEL, clientId, mAccount, 2105 removedAttendee); 2106 if (msg != null) { 2107 // Just send it to the removed attendee 2108 userLog("Queueing cancellation to removed attendee " + msg.mTo); 2109 mOutgoingMailList.add(msg); 2110 } 2111 } 2112 } else if (!selfOrganizer) { 2113 // If we're not the organizer, see if we've changed our attendee status 2114 // Our last synced attendee status is in ExtendedProperties, and we've 2115 // retrieved it above as userAttendeeStatus 2116 int currentStatus = entityValues.getAsInteger(Events.SELF_ATTENDEE_STATUS); 2117 int syncStatus = Attendees.ATTENDEE_STATUS_NONE; 2118 if (userAttendeeStatus != null) { 2119 try { 2120 syncStatus = Integer.parseInt(userAttendeeStatus); 2121 } catch (NumberFormatException e) { 2122 // Just in case somebody else mucked with this and it's not Integer 2123 } 2124 } 2125 if ((currentStatus != syncStatus) && 2126 (currentStatus != Attendees.ATTENDEE_STATUS_NONE)) { 2127 // If so, send a meeting reply 2128 int messageFlag = 0; 2129 switch (currentStatus) { 2130 case Attendees.ATTENDEE_STATUS_ACCEPTED: 2131 messageFlag = Message.FLAG_OUTGOING_MEETING_ACCEPT; 2132 break; 2133 case Attendees.ATTENDEE_STATUS_DECLINED: 2134 messageFlag = Message.FLAG_OUTGOING_MEETING_DECLINE; 2135 break; 2136 case Attendees.ATTENDEE_STATUS_TENTATIVE: 2137 messageFlag = Message.FLAG_OUTGOING_MEETING_TENTATIVE; 2138 break; 2139 } 2140 // Make sure we have a valid status (messageFlag should never be zero) 2141 if (messageFlag != 0 && userAttendeeStatusId >= 0) { 2142 // Save away the new status 2143 cidValues.clear(); 2144 cidValues.put(ExtendedProperties.VALUE, 2145 Integer.toString(currentStatus)); 2146 cr.update(ContentUris.withAppendedId(ExtendedProperties.CONTENT_URI, 2147 userAttendeeStatusId), cidValues, null, null); 2148 // Send mail to the organizer advising of the new status 2149 EmailContent.Message msg = 2150 CalendarUtilities.createMessageForEventId(mContext, eventId, 2151 messageFlag, clientId, mAccount); 2152 if (msg != null) { 2153 userLog("Queueing invitation reply to " + msg.mTo); 2154 mOutgoingMailList.add(msg); 2155 } 2156 } 2157 } 2158 } 2159 } 2160 if (!first) { 2161 s.end(); // Commands 2162 } 2163 } finally { 2164 eventIterator.close(); 2165 } 2166 } catch (RemoteException e) { 2167 Log.e(TAG, "Could not read dirty events."); 2168 } 2169 2170 return false; 2171 } 2172 } 2173