/* * Copyright (C) 2008-2009 Marc Blank * Licensed to The Android Open Source Project. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.android.exchange.adapter; import android.content.ContentProviderClient; import android.content.ContentProviderOperation; import android.content.ContentProviderResult; import android.content.ContentResolver; import android.content.ContentUris; import android.content.ContentValues; import android.content.Entity; import android.content.Entity.NamedContentValues; import android.content.EntityIterator; import android.database.Cursor; import android.database.DatabaseUtils; import android.net.Uri; import android.os.RemoteException; import android.provider.CalendarContract; import android.provider.CalendarContract.Attendees; import android.provider.CalendarContract.Calendars; import android.provider.CalendarContract.Events; import android.provider.CalendarContract.EventsEntity; import android.provider.CalendarContract.ExtendedProperties; import android.provider.CalendarContract.Reminders; import android.provider.CalendarContract.SyncState; import android.provider.ContactsContract.RawContacts; import android.provider.SyncStateContract; import android.text.TextUtils; import android.util.Log; import com.android.emailcommon.AccountManagerTypes; import com.android.emailcommon.provider.EmailContent; import com.android.emailcommon.provider.EmailContent.Message; import com.android.emailcommon.utility.Utility; import com.android.exchange.CommandStatusException; import com.android.exchange.Eas; import com.android.exchange.EasOutboxService; import com.android.exchange.EasSyncService; import com.android.exchange.ExchangeService; import com.android.exchange.utility.CalendarUtilities; import com.android.exchange.utility.Duration; import java.io.IOException; import java.io.InputStream; import java.text.ParseException; import java.util.ArrayList; import java.util.GregorianCalendar; import java.util.Map.Entry; import java.util.StringTokenizer; import java.util.TimeZone; import java.util.UUID; /** * Sync adapter class for EAS calendars * */ public class CalendarSyncAdapter extends AbstractSyncAdapter { private static final String TAG = "EasCalendarSyncAdapter"; private static final String EVENT_SAVED_TIMEZONE_COLUMN = Events.SYNC_DATA1; /** * Used to keep track of exception vs parent event dirtiness. */ private static final String EVENT_SYNC_MARK = Events.SYNC_DATA8; private static final String EVENT_SYNC_VERSION = Events.SYNC_DATA4; // Since exceptions will have the same _SYNC_ID as the original event we have to check that // there's no original event when finding an item by _SYNC_ID private static final String SERVER_ID_AND_CALENDAR_ID = Events._SYNC_ID + "=? AND " + Events.ORIGINAL_SYNC_ID + " ISNULL AND " + Events.CALENDAR_ID + "=?"; private static final String EVENT_ID_AND_CALENDAR_ID = Events._ID + "=? AND " + Events.ORIGINAL_SYNC_ID + " ISNULL AND " + Events.CALENDAR_ID + "=?"; private static final String DIRTY_OR_MARKED_TOP_LEVEL_IN_CALENDAR = "(" + Events.DIRTY + "=1 OR " + EVENT_SYNC_MARK + "= 1) AND " + Events.ORIGINAL_ID + " ISNULL AND " + Events.CALENDAR_ID + "=?"; private static final String DIRTY_EXCEPTION_IN_CALENDAR = Events.DIRTY + "=1 AND " + Events.ORIGINAL_ID + " NOTNULL AND " + Events.CALENDAR_ID + "=?"; private static final String CLIENT_ID_SELECTION = Events.SYNC_DATA2 + "=?"; private static final String ORIGINAL_EVENT_AND_CALENDAR = Events.ORIGINAL_SYNC_ID + "=? AND " + Events.CALENDAR_ID + "=?"; private static final String ATTENDEES_EXCEPT_ORGANIZER = Attendees.EVENT_ID + "=? AND " + Attendees.ATTENDEE_RELATIONSHIP + "!=" + Attendees.RELATIONSHIP_ORGANIZER; private static final String[] ID_PROJECTION = new String[] {Events._ID}; private static final String[] ORIGINAL_EVENT_PROJECTION = new String[] {Events.ORIGINAL_ID, Events._ID}; private static final String EVENT_ID_AND_NAME = ExtendedProperties.EVENT_ID + "=? AND " + ExtendedProperties.NAME + "=?"; // Note that we use LIKE below for its case insensitivity private static final String EVENT_AND_EMAIL = Attendees.EVENT_ID + "=? AND "+ Attendees.ATTENDEE_EMAIL + " LIKE ?"; private static final int ATTENDEE_STATUS_COLUMN_STATUS = 0; private static final String[] ATTENDEE_STATUS_PROJECTION = new String[] {Attendees.ATTENDEE_STATUS}; public static final String CALENDAR_SELECTION = Calendars.ACCOUNT_NAME + "=? AND " + Calendars.ACCOUNT_TYPE + "=?"; private static final int CALENDAR_SELECTION_ID = 0; private static final String[] EXTENDED_PROPERTY_PROJECTION = new String[] {ExtendedProperties._ID}; private static final int EXTENDED_PROPERTY_ID = 0; private static final String CATEGORY_TOKENIZER_DELIMITER = "\\"; private static final String ATTENDEE_TOKENIZER_DELIMITER = CATEGORY_TOKENIZER_DELIMITER; private static final String EXTENDED_PROPERTY_USER_ATTENDEE_STATUS = "userAttendeeStatus"; private static final String EXTENDED_PROPERTY_ATTENDEES = "attendees"; private static final String EXTENDED_PROPERTY_DTSTAMP = "dtstamp"; private static final String EXTENDED_PROPERTY_MEETING_STATUS = "meeting_status"; private static final String EXTENDED_PROPERTY_CATEGORIES = "categories"; // Used to indicate that we removed the attendee list because it was too large private static final String EXTENDED_PROPERTY_ATTENDEES_REDACTED = "attendeesRedacted"; // Used to indicate that upsyncs aren't allowed (we catch this in sendLocalChanges) private static final String EXTENDED_PROPERTY_UPSYNC_PROHIBITED = "upsyncProhibited"; private static final Operation PLACEHOLDER_OPERATION = new Operation(ContentProviderOperation.newInsert(Uri.EMPTY)); private static final Object sSyncKeyLock = new Object(); private static final TimeZone UTC_TIMEZONE = TimeZone.getTimeZone("UTC"); private final TimeZone mLocalTimeZone = TimeZone.getDefault(); // Maximum number of allowed attendees; above this number, we mark the Event with the // attendeesRedacted extended property and don't allow the event to be upsynced to the server private static final int MAX_SYNCED_ATTENDEES = 50; // We set the organizer to this when the user is the organizer and we've redacted the // attendee list. By making the meeting organizer OTHER than the user, we cause the UI to // prevent edits to this event (except local changes like reminder). private static final String BOGUS_ORGANIZER_EMAIL = "upload_disallowed@uploadisdisallowed.aaa"; // Maximum number of CPO's before we start redacting attendees in exceptions // The number 500 has been determined empirically; 1500 CPOs appears to be the limit before // binder failures occur, but we need room at any point for additional events/exceptions so // we set our limit at 1/3 of the apparent maximum for extra safety // TODO Find a better solution to this workaround private static final int MAX_OPS_BEFORE_EXCEPTION_ATTENDEE_REDACTION = 500; private long mCalendarId = -1; private String mCalendarIdString; private String[] mCalendarIdArgument; /*package*/ String mEmailAddress; private ArrayList<Long> mDeletedIdList = new ArrayList<Long>(); private ArrayList<Long> mUploadedIdList = new ArrayList<Long>(); private ArrayList<Long> mSendCancelIdList = new ArrayList<Long>(); private ArrayList<Message> mOutgoingMailList = new ArrayList<Message>(); private final Uri mAsSyncAdapterAttendees; private final Uri mAsSyncAdapterEvents; private final Uri mAsSyncAdapterReminders; private final Uri mAsSyncAdapterExtendedProperties; public CalendarSyncAdapter(EasSyncService service) { super(service); mEmailAddress = mAccount.mEmailAddress; String amType = Eas.EXCHANGE_ACCOUNT_MANAGER_TYPE; mAsSyncAdapterAttendees = asSyncAdapter(Attendees.CONTENT_URI, mEmailAddress, amType); mAsSyncAdapterEvents = asSyncAdapter(Events.CONTENT_URI, mEmailAddress, amType); mAsSyncAdapterReminders = asSyncAdapter(Reminders.CONTENT_URI, mEmailAddress, amType); mAsSyncAdapterExtendedProperties = asSyncAdapter(ExtendedProperties.CONTENT_URI, mEmailAddress, amType); Cursor c = mService.mContentResolver.query(Calendars.CONTENT_URI, new String[] {Calendars._ID}, CALENDAR_SELECTION, new String[] {mEmailAddress, Eas.EXCHANGE_ACCOUNT_MANAGER_TYPE}, null); if (c == null) return; try { if (c.moveToFirst()) { mCalendarId = c.getLong(CALENDAR_SELECTION_ID); } else { mCalendarId = CalendarUtilities.createCalendar(mService, mAccount, mMailbox); } mCalendarIdString = Long.toString(mCalendarId); mCalendarIdArgument = new String[] {mCalendarIdString}; } finally { c.close(); } } @Override public String getCollectionName() { return "Calendar"; } @Override public void cleanup() { } @Override public void wipe() { // Delete the calendar associated with this account // CalendarProvider2 does NOT handle selection arguments in deletions mContentResolver.delete( asSyncAdapter(Calendars.CONTENT_URI, mEmailAddress, Eas.EXCHANGE_ACCOUNT_MANAGER_TYPE), Calendars.ACCOUNT_NAME + "=" + DatabaseUtils.sqlEscapeString(mEmailAddress) + " AND " + Calendars.ACCOUNT_TYPE + "=" + DatabaseUtils.sqlEscapeString(AccountManagerTypes.TYPE_EXCHANGE), null); // Invalidate our calendar observers ExchangeService.unregisterCalendarObservers(); } @Override public void sendSyncOptions(Double protocolVersion, Serializer s) throws IOException { setPimSyncOptions(protocolVersion, Eas.FILTER_2_WEEKS, s); } @Override public boolean isSyncable() { return ContentResolver.getSyncAutomatically(mAccountManagerAccount, CalendarContract.AUTHORITY); } @Override public boolean parse(InputStream is) throws IOException, CommandStatusException { EasCalendarSyncParser p = new EasCalendarSyncParser(is, this); return p.parse(); } public static Uri asSyncAdapter(Uri uri, String account, String accountType) { return uri.buildUpon().appendQueryParameter(CalendarContract.CALLER_IS_SYNCADAPTER, "true") .appendQueryParameter(Calendars.ACCOUNT_NAME, account) .appendQueryParameter(Calendars.ACCOUNT_TYPE, accountType).build(); } /** * Generate the uri for the data row associated with this NamedContentValues object * @param ncv the NamedContentValues object * @return a uri that can be used to refer to this row */ public Uri dataUriFromNamedContentValues(NamedContentValues ncv) { long id = ncv.values.getAsLong(RawContacts._ID); Uri dataUri = ContentUris.withAppendedId(ncv.uri, id); return dataUri; } /** * We get our SyncKey from CalendarProvider. If there's not one, we set it to "0" (the reset * state) and save that away. */ @Override public String getSyncKey() throws IOException { synchronized (sSyncKeyLock) { ContentProviderClient client = mService.mContentResolver .acquireContentProviderClient(CalendarContract.CONTENT_URI); try { byte[] data = SyncStateContract.Helpers.get( client, asSyncAdapter(SyncState.CONTENT_URI, mEmailAddress, Eas.EXCHANGE_ACCOUNT_MANAGER_TYPE), mAccountManagerAccount); if (data == null || data.length == 0) { // Initialize the SyncKey setSyncKey("0", false); return "0"; } else { String syncKey = new String(data); userLog("SyncKey retrieved as ", syncKey, " from CalendarProvider"); return syncKey; } } catch (RemoteException e) { throw new IOException("Can't get SyncKey from CalendarProvider"); } } } /** * We only need to set this when we're forced to make the SyncKey "0" (a reset). In all other * cases, the SyncKey is set within Calendar */ @Override public void setSyncKey(String syncKey, boolean inCommands) throws IOException { synchronized (sSyncKeyLock) { if ("0".equals(syncKey) || !inCommands) { ContentProviderClient client = mService.mContentResolver .acquireContentProviderClient(CalendarContract.CONTENT_URI); try { SyncStateContract.Helpers.set( client, asSyncAdapter(SyncState.CONTENT_URI, mEmailAddress, Eas.EXCHANGE_ACCOUNT_MANAGER_TYPE), mAccountManagerAccount, syncKey.getBytes()); userLog("SyncKey set to ", syncKey, " in CalendarProvider"); } catch (RemoteException e) { throw new IOException("Can't set SyncKey in CalendarProvider"); } } mMailbox.mSyncKey = syncKey; } } public class EasCalendarSyncParser extends AbstractSyncParser { String[] mBindArgument = new String[1]; Uri mAccountUri; CalendarOperations mOps = new CalendarOperations(); public EasCalendarSyncParser(InputStream in, CalendarSyncAdapter adapter) throws IOException { super(in, adapter); setLoggingTag("CalendarParser"); mAccountUri = Events.CONTENT_URI; } private void addOrganizerToAttendees(CalendarOperations ops, long eventId, String organizerName, String organizerEmail) { // Handle the organizer (who IS an attendee on device, but NOT in EAS) if (organizerName != null || organizerEmail != null) { ContentValues attendeeCv = new ContentValues(); if (organizerName != null) { attendeeCv.put(Attendees.ATTENDEE_NAME, organizerName); } if (organizerEmail != null) { attendeeCv.put(Attendees.ATTENDEE_EMAIL, organizerEmail); } attendeeCv.put(Attendees.ATTENDEE_RELATIONSHIP, Attendees.RELATIONSHIP_ORGANIZER); attendeeCv.put(Attendees.ATTENDEE_TYPE, Attendees.TYPE_REQUIRED); attendeeCv.put(Attendees.ATTENDEE_STATUS, Attendees.ATTENDEE_STATUS_ACCEPTED); if (eventId < 0) { ops.newAttendee(attendeeCv); } else { ops.updatedAttendee(attendeeCv, eventId); } } } /** * Set DTSTART, DTEND, DURATION and EVENT_TIMEZONE as appropriate for the given Event * The follow rules are enforced by CalendarProvider2: * Events that aren't exceptions MUST have either 1) a DTEND or 2) a DURATION * Recurring events (i.e. events with RRULE) must have a DURATION * All-day recurring events MUST have a DURATION that is in the form P<n>D * Other events MAY have a DURATION in any valid form (we use P<n>M) * All-day events MUST have hour, minute, and second = 0; in addition, they must have * the EVENT_TIMEZONE set to UTC * Also, exceptions to all-day events need to have an ORIGINAL_INSTANCE_TIME that has * hour, minute, and second = 0 and be set in UTC * @param cv the ContentValues for the Event * @param startTime the start time for the Event * @param endTime the end time for the Event * @param allDayEvent whether this is an all day event (1) or not (0) */ /*package*/ void setTimeRelatedValues(ContentValues cv, long startTime, long endTime, int allDayEvent) { // If there's no startTime, the event will be found to be invalid, so return if (startTime < 0) return; // EAS events can arrive without an end time, but CalendarProvider requires them // so we'll default to 30 minutes; this will be superceded if this is an all-day event if (endTime < 0) endTime = startTime + (30*MINUTES); // If this is an all-day event, set hour, minute, and second to zero, and use UTC if (allDayEvent != 0) { startTime = CalendarUtilities.getUtcAllDayCalendarTime(startTime, mLocalTimeZone); endTime = CalendarUtilities.getUtcAllDayCalendarTime(endTime, mLocalTimeZone); String originalTimeZone = cv.getAsString(Events.EVENT_TIMEZONE); cv.put(EVENT_SAVED_TIMEZONE_COLUMN, originalTimeZone); cv.put(Events.EVENT_TIMEZONE, UTC_TIMEZONE.getID()); } // If this is an exception, and the original was an all-day event, make sure the // original instance time has hour, minute, and second set to zero, and is in UTC if (cv.containsKey(Events.ORIGINAL_INSTANCE_TIME) && cv.containsKey(Events.ORIGINAL_ALL_DAY)) { Integer ade = cv.getAsInteger(Events.ORIGINAL_ALL_DAY); if (ade != null && ade != 0) { long exceptionTime = cv.getAsLong(Events.ORIGINAL_INSTANCE_TIME); GregorianCalendar cal = new GregorianCalendar(UTC_TIMEZONE); cal.setTimeInMillis(exceptionTime); cal.set(GregorianCalendar.HOUR_OF_DAY, 0); cal.set(GregorianCalendar.MINUTE, 0); cal.set(GregorianCalendar.SECOND, 0); cv.put(Events.ORIGINAL_INSTANCE_TIME, cal.getTimeInMillis()); } } // Always set DTSTART cv.put(Events.DTSTART, startTime); // For recurring events, set DURATION. Use P<n>D format for all day events if (cv.containsKey(Events.RRULE)) { if (allDayEvent != 0) { cv.put(Events.DURATION, "P" + ((endTime - startTime) / DAYS) + "D"); } else { cv.put(Events.DURATION, "P" + ((endTime - startTime) / MINUTES) + "M"); } // For other events, set DTEND and LAST_DATE } else { cv.put(Events.DTEND, endTime); cv.put(Events.LAST_DATE, endTime); } } public void addEvent(CalendarOperations ops, String serverId, boolean update) throws IOException { ContentValues cv = new ContentValues(); cv.put(Events.CALENDAR_ID, mCalendarId); cv.put(Events._SYNC_ID, serverId); cv.put(Events.HAS_ATTENDEE_DATA, 1); cv.put(Events.SYNC_DATA2, "0"); int allDayEvent = 0; String organizerName = null; String organizerEmail = null; int eventOffset = -1; int deleteOffset = -1; int busyStatus = CalendarUtilities.BUSY_STATUS_TENTATIVE; int responseType = CalendarUtilities.RESPONSE_TYPE_NONE; boolean firstTag = true; long eventId = -1; long startTime = -1; long endTime = -1; TimeZone timeZone = null; // Keep track of the attendees; exceptions will need them ArrayList<ContentValues> attendeeValues = new ArrayList<ContentValues>(); int reminderMins = -1; String dtStamp = null; boolean organizerAdded = false; while (nextTag(Tags.SYNC_APPLICATION_DATA) != END) { if (update && firstTag) { // Find the event that's being updated Cursor c = getServerIdCursor(serverId); long id = -1; try { if (c != null && c.moveToFirst()) { id = c.getLong(0); } } finally { if (c != null) c.close(); } if (id > 0) { // DTSTAMP can come first, and we simply need to track it if (tag == Tags.CALENDAR_DTSTAMP) { dtStamp = getValue(); continue; } else if (tag == Tags.CALENDAR_ATTENDEES) { // This is an attendees-only update; just // delete/re-add attendees mBindArgument[0] = Long.toString(id); ops.add(new Operation(ContentProviderOperation .newDelete(mAsSyncAdapterAttendees) .withSelection(ATTENDEES_EXCEPT_ORGANIZER, mBindArgument))); eventId = id; } else { // Otherwise, delete the original event and recreate it userLog("Changing (delete/add) event ", serverId); deleteOffset = ops.newDelete(id, serverId); // Add a placeholder event so that associated tables can reference // this as a back reference. We add the event at the end of the method eventOffset = ops.newEvent(PLACEHOLDER_OPERATION); } } else { // The changed item isn't found. We'll treat this as a new item eventOffset = ops.newEvent(PLACEHOLDER_OPERATION); userLog(TAG, "Changed item not found; treating as new."); } } else if (firstTag) { // Add a placeholder event so that associated tables can reference // this as a back reference. We add the event at the end of the method eventOffset = ops.newEvent(PLACEHOLDER_OPERATION); } firstTag = false; switch (tag) { case Tags.CALENDAR_ALL_DAY_EVENT: allDayEvent = getValueInt(); if (allDayEvent != 0 && timeZone != null) { // If the event doesn't start at midnight local time, we won't consider // this an all-day event in the local time zone (this is what OWA does) GregorianCalendar cal = new GregorianCalendar(mLocalTimeZone); cal.setTimeInMillis(startTime); userLog("All-day event arrived in: " + timeZone.getID()); if (cal.get(GregorianCalendar.HOUR_OF_DAY) != 0 || cal.get(GregorianCalendar.MINUTE) != 0) { allDayEvent = 0; userLog("Not an all-day event locally: " + mLocalTimeZone.getID()); } } cv.put(Events.ALL_DAY, allDayEvent); break; case Tags.CALENDAR_ATTACHMENTS: attachmentsParser(); break; case Tags.CALENDAR_ATTENDEES: // If eventId >= 0, this is an update; otherwise, a new Event attendeeValues = attendeesParser(ops, eventId); break; case Tags.BASE_BODY: cv.put(Events.DESCRIPTION, bodyParser()); break; case Tags.CALENDAR_BODY: cv.put(Events.DESCRIPTION, getValue()); break; case Tags.CALENDAR_TIME_ZONE: timeZone = CalendarUtilities.tziStringToTimeZone(getValue()); if (timeZone == null) { timeZone = mLocalTimeZone; } cv.put(Events.EVENT_TIMEZONE, timeZone.getID()); break; case Tags.CALENDAR_START_TIME: startTime = Utility.parseDateTimeToMillis(getValue()); break; case Tags.CALENDAR_END_TIME: endTime = Utility.parseDateTimeToMillis(getValue()); break; case Tags.CALENDAR_EXCEPTIONS: // For exceptions to show the organizer, the organizer must be added before // we call exceptionsParser addOrganizerToAttendees(ops, eventId, organizerName, organizerEmail); organizerAdded = true; exceptionsParser(ops, cv, attendeeValues, reminderMins, busyStatus, startTime, endTime); break; case Tags.CALENDAR_LOCATION: cv.put(Events.EVENT_LOCATION, getValue()); break; case Tags.CALENDAR_RECURRENCE: String rrule = recurrenceParser(); if (rrule != null) { cv.put(Events.RRULE, rrule); } break; case Tags.CALENDAR_ORGANIZER_EMAIL: organizerEmail = getValue(); cv.put(Events.ORGANIZER, organizerEmail); break; case Tags.CALENDAR_SUBJECT: cv.put(Events.TITLE, getValue()); break; case Tags.CALENDAR_SENSITIVITY: cv.put(Events.ACCESS_LEVEL, encodeVisibility(getValueInt())); break; case Tags.CALENDAR_ORGANIZER_NAME: organizerName = getValue(); break; case Tags.CALENDAR_REMINDER_MINS_BEFORE: // Save away whether this tag has content; Exchange 2010 sends an empty tag // rather than not sending one (as with Ex07 and Ex03) boolean hasContent = !noContent; reminderMins = getValueInt(); if (hasContent) { ops.newReminder(reminderMins); cv.put(Events.HAS_ALARM, 1); } break; // The following are fields we should save (for changes), though they don't // relate to data used by CalendarProvider at this point case Tags.CALENDAR_UID: cv.put(Events.SYNC_DATA2, getValue()); break; case Tags.CALENDAR_DTSTAMP: dtStamp = getValue(); break; case Tags.CALENDAR_MEETING_STATUS: ops.newExtendedProperty(EXTENDED_PROPERTY_MEETING_STATUS, getValue()); break; case Tags.CALENDAR_BUSY_STATUS: // We'll set the user's status in the Attendees table below // Don't set selfAttendeeStatus or CalendarProvider will create a duplicate // attendee! busyStatus = getValueInt(); break; case Tags.CALENDAR_RESPONSE_TYPE: // EAS 14+ uses this for the user's response status; we'll use this instead // of busy status, if it appears responseType = getValueInt(); break; case Tags.CALENDAR_CATEGORIES: String categories = categoriesParser(ops); if (categories.length() > 0) { ops.newExtendedProperty(EXTENDED_PROPERTY_CATEGORIES, categories); } break; default: skipTag(); } } // Enforce CalendarProvider required properties setTimeRelatedValues(cv, startTime, endTime, allDayEvent); // If we haven't added the organizer to attendees, do it now if (!organizerAdded) { addOrganizerToAttendees(ops, eventId, organizerName, organizerEmail); } // Note that organizerEmail can be null with a DTSTAMP only change from the server boolean selfOrganizer = (mEmailAddress.equals(organizerEmail)); // Store email addresses of attendees (in a tokenizable string) in ExtendedProperties // If the user is an attendee, set the attendee status using busyStatus (note that the // busyStatus is inherited from the parent unless it's specified in the exception) // Add the insert/update operation for each attendee (based on whether it's add/change) int numAttendees = attendeeValues.size(); if (numAttendees > MAX_SYNCED_ATTENDEES) { // Indicate that we've redacted attendees. If we're the organizer, disable edit // by setting organizerEmail to a bogus value and by setting the upsync prohibited // extended properly. // Note that we don't set ANY attendees if we're in this branch; however, the // organizer has already been included above, and WILL show up (which is good) if (eventId < 0) { ops.newExtendedProperty(EXTENDED_PROPERTY_ATTENDEES_REDACTED, "1"); if (selfOrganizer) { ops.newExtendedProperty(EXTENDED_PROPERTY_UPSYNC_PROHIBITED, "1"); } } else { ops.updatedExtendedProperty(EXTENDED_PROPERTY_ATTENDEES_REDACTED, "1", eventId); if (selfOrganizer) { ops.updatedExtendedProperty(EXTENDED_PROPERTY_UPSYNC_PROHIBITED, "1", eventId); } } if (selfOrganizer) { organizerEmail = BOGUS_ORGANIZER_EMAIL; cv.put(Events.ORGANIZER, organizerEmail); } // Tell UI that we don't have any attendees cv.put(Events.HAS_ATTENDEE_DATA, "0"); mService.userLog("Maximum number of attendees exceeded; redacting"); } else if (numAttendees > 0) { StringBuilder sb = new StringBuilder(); for (ContentValues attendee: attendeeValues) { String attendeeEmail = attendee.getAsString(Attendees.ATTENDEE_EMAIL); sb.append(attendeeEmail); sb.append(ATTENDEE_TOKENIZER_DELIMITER); if (mEmailAddress.equalsIgnoreCase(attendeeEmail)) { int attendeeStatus; // We'll use the response type (EAS 14), if we've got one; otherwise, we'll // try to infer it from busy status if (responseType != CalendarUtilities.RESPONSE_TYPE_NONE) { attendeeStatus = CalendarUtilities.attendeeStatusFromResponseType(responseType); } else if (!update) { // For new events in EAS < 14, we have no idea what the busy status // means, so we show "none", allowing the user to select an option. attendeeStatus = Attendees.ATTENDEE_STATUS_NONE; } else { // For updated events, we'll try to infer the attendee status from the // busy status attendeeStatus = CalendarUtilities.attendeeStatusFromBusyStatus(busyStatus); } attendee.put(Attendees.ATTENDEE_STATUS, attendeeStatus); // If we're an attendee, save away our initial attendee status in the // event's ExtendedProperties (we look for differences between this and // the user's current attendee status to determine whether an email needs // to be sent to the organizer) // organizerEmail will be null in the case that this is an attendees-only // change from the server if (organizerEmail == null || !organizerEmail.equalsIgnoreCase(attendeeEmail)) { if (eventId < 0) { ops.newExtendedProperty(EXTENDED_PROPERTY_USER_ATTENDEE_STATUS, Integer.toString(attendeeStatus)); } else { ops.updatedExtendedProperty(EXTENDED_PROPERTY_USER_ATTENDEE_STATUS, Integer.toString(attendeeStatus), eventId); } } } if (eventId < 0) { ops.newAttendee(attendee); } else { ops.updatedAttendee(attendee, eventId); } } if (eventId < 0) { ops.newExtendedProperty(EXTENDED_PROPERTY_ATTENDEES, sb.toString()); ops.newExtendedProperty(EXTENDED_PROPERTY_ATTENDEES_REDACTED, "0"); ops.newExtendedProperty(EXTENDED_PROPERTY_UPSYNC_PROHIBITED, "0"); } else { ops.updatedExtendedProperty(EXTENDED_PROPERTY_ATTENDEES, sb.toString(), eventId); ops.updatedExtendedProperty(EXTENDED_PROPERTY_ATTENDEES_REDACTED, "0", eventId); ops.updatedExtendedProperty(EXTENDED_PROPERTY_UPSYNC_PROHIBITED, "0", eventId); } } // Put the real event in the proper place in the ops ArrayList if (eventOffset >= 0) { // Store away the DTSTAMP here if (dtStamp != null) { ops.newExtendedProperty(EXTENDED_PROPERTY_DTSTAMP, dtStamp); } if (isValidEventValues(cv)) { ops.set(eventOffset, new Operation(ContentProviderOperation .newInsert(mAsSyncAdapterEvents).withValues(cv))); } else { // If we can't add this event (it's invalid), remove all of the inserts // we've built for it int cnt = ops.mCount - eventOffset; userLog(TAG, "Removing " + cnt + " inserts from mOps"); for (int i = 0; i < cnt; i++) { ops.remove(eventOffset); } ops.mCount = eventOffset; // If this is a change, we need to also remove the deletion that comes // before the addition if (deleteOffset >= 0) { // Remove the deletion ops.remove(deleteOffset); // And the deletion of exceptions ops.remove(deleteOffset); userLog(TAG, "Removing deletion ops from mOps"); ops.mCount = deleteOffset; } } } // Mark the end of the event addSeparatorOperation(ops, Events.CONTENT_URI); } private void logEventColumns(ContentValues cv, String reason) { if (Eas.USER_LOG) { StringBuilder sb = new StringBuilder("Event invalid, " + reason + ", skipping: Columns = "); for (Entry<String, Object> entry: cv.valueSet()) { sb.append(entry.getKey()); sb.append('/'); } userLog(TAG, sb.toString()); } } /*package*/ boolean isValidEventValues(ContentValues cv) { boolean isException = cv.containsKey(Events.ORIGINAL_INSTANCE_TIME); // All events require DTSTART if (!cv.containsKey(Events.DTSTART)) { logEventColumns(cv, "DTSTART missing"); return false; // If we're a top-level event, we must have _SYNC_DATA (uid) } else if (!isException && !cv.containsKey(Events.SYNC_DATA2)) { logEventColumns(cv, "_SYNC_DATA missing"); return false; // We must also have DTEND or DURATION if we're not an exception } else if (!isException && !cv.containsKey(Events.DTEND) && !cv.containsKey(Events.DURATION)) { logEventColumns(cv, "DTEND/DURATION missing"); return false; // Exceptions require DTEND } else if (isException && !cv.containsKey(Events.DTEND)) { logEventColumns(cv, "Exception missing DTEND"); return false; // If this is a recurrence, we need a DURATION (in days if an all-day event) } else if (cv.containsKey(Events.RRULE)) { String duration = cv.getAsString(Events.DURATION); if (duration == null) return false; if (cv.containsKey(Events.ALL_DAY)) { Integer ade = cv.getAsInteger(Events.ALL_DAY); if (ade != null && ade != 0 && !duration.endsWith("D")) { return false; } } } return true; } public String recurrenceParser() throws IOException { // Turn this information into an RRULE int type = -1; int occurrences = -1; int interval = -1; int dow = -1; int dom = -1; int wom = -1; int moy = -1; String until = null; while (nextTag(Tags.CALENDAR_RECURRENCE) != END) { switch (tag) { case Tags.CALENDAR_RECURRENCE_TYPE: type = getValueInt(); break; case Tags.CALENDAR_RECURRENCE_INTERVAL: interval = getValueInt(); break; case Tags.CALENDAR_RECURRENCE_OCCURRENCES: occurrences = getValueInt(); break; case Tags.CALENDAR_RECURRENCE_DAYOFWEEK: dow = getValueInt(); break; case Tags.CALENDAR_RECURRENCE_DAYOFMONTH: dom = getValueInt(); break; case Tags.CALENDAR_RECURRENCE_WEEKOFMONTH: wom = getValueInt(); break; case Tags.CALENDAR_RECURRENCE_MONTHOFYEAR: moy = getValueInt(); break; case Tags.CALENDAR_RECURRENCE_UNTIL: until = getValue(); break; default: skipTag(); } } return CalendarUtilities.rruleFromRecurrence(type, occurrences, interval, dow, dom, wom, moy, until); } private void exceptionParser(CalendarOperations ops, ContentValues parentCv, ArrayList<ContentValues> attendeeValues, int reminderMins, int busyStatus, long startTime, long endTime) throws IOException { ContentValues cv = new ContentValues(); cv.put(Events.CALENDAR_ID, mCalendarId); // It appears that these values have to be copied from the parent if they are to appear // Note that they can be overridden below cv.put(Events.ORGANIZER, parentCv.getAsString(Events.ORGANIZER)); cv.put(Events.TITLE, parentCv.getAsString(Events.TITLE)); cv.put(Events.DESCRIPTION, parentCv.getAsString(Events.DESCRIPTION)); cv.put(Events.ORIGINAL_ALL_DAY, parentCv.getAsInteger(Events.ALL_DAY)); cv.put(Events.EVENT_LOCATION, parentCv.getAsString(Events.EVENT_LOCATION)); cv.put(Events.ACCESS_LEVEL, parentCv.getAsString(Events.ACCESS_LEVEL)); cv.put(Events.EVENT_TIMEZONE, parentCv.getAsString(Events.EVENT_TIMEZONE)); // Exceptions should always have this set to zero, since EAS has no concept of // separate attendee lists for exceptions; if we fail to do this, then the UI will // allow the user to change attendee data, and this change would never get reflected // on the server. cv.put(Events.HAS_ATTENDEE_DATA, 0); int allDayEvent = 0; // This column is the key that links the exception to the serverId cv.put(Events.ORIGINAL_SYNC_ID, parentCv.getAsString(Events._SYNC_ID)); String exceptionStartTime = "_noStartTime"; while (nextTag(Tags.SYNC_APPLICATION_DATA) != END) { switch (tag) { case Tags.CALENDAR_ATTACHMENTS: attachmentsParser(); break; case Tags.CALENDAR_EXCEPTION_START_TIME: exceptionStartTime = getValue(); cv.put(Events.ORIGINAL_INSTANCE_TIME, Utility.parseDateTimeToMillis(exceptionStartTime)); break; case Tags.CALENDAR_EXCEPTION_IS_DELETED: if (getValueInt() == 1) { cv.put(Events.STATUS, Events.STATUS_CANCELED); } break; case Tags.CALENDAR_ALL_DAY_EVENT: allDayEvent = getValueInt(); cv.put(Events.ALL_DAY, allDayEvent); break; case Tags.BASE_BODY: cv.put(Events.DESCRIPTION, bodyParser()); break; case Tags.CALENDAR_BODY: cv.put(Events.DESCRIPTION, getValue()); break; case Tags.CALENDAR_START_TIME: startTime = Utility.parseDateTimeToMillis(getValue()); break; case Tags.CALENDAR_END_TIME: endTime = Utility.parseDateTimeToMillis(getValue()); break; case Tags.CALENDAR_LOCATION: cv.put(Events.EVENT_LOCATION, getValue()); break; case Tags.CALENDAR_RECURRENCE: String rrule = recurrenceParser(); if (rrule != null) { cv.put(Events.RRULE, rrule); } break; case Tags.CALENDAR_SUBJECT: cv.put(Events.TITLE, getValue()); break; case Tags.CALENDAR_SENSITIVITY: cv.put(Events.ACCESS_LEVEL, encodeVisibility(getValueInt())); break; case Tags.CALENDAR_BUSY_STATUS: busyStatus = getValueInt(); // Don't set selfAttendeeStatus or CalendarProvider will create a duplicate // attendee! break; // TODO How to handle these items that are linked to event id! // case Tags.CALENDAR_DTSTAMP: // ops.newExtendedProperty("dtstamp", getValue()); // break; // case Tags.CALENDAR_REMINDER_MINS_BEFORE: // ops.newReminder(getValueInt()); // break; default: skipTag(); } } // We need a _sync_id, but it can't be the parent's id, so we generate one cv.put(Events._SYNC_ID, parentCv.getAsString(Events._SYNC_ID) + '_' + exceptionStartTime); // Enforce CalendarProvider required properties setTimeRelatedValues(cv, startTime, endTime, allDayEvent); // Don't insert an invalid exception event if (!isValidEventValues(cv)) return; // Add the exception insert int exceptionStart = ops.mCount; ops.newException(cv); // Also add the attendees, because they need to be copied over from the parent event boolean attendeesRedacted = false; if (attendeeValues != null) { for (ContentValues attValues: attendeeValues) { // If this is the user, use his busy status for attendee status String attendeeEmail = attValues.getAsString(Attendees.ATTENDEE_EMAIL); // Note that the exception at which we surpass the redaction limit might have // any number of attendees shown; since this is an edge case and a workaround, // it seems to be an acceptable implementation if (mEmailAddress.equalsIgnoreCase(attendeeEmail)) { attValues.put(Attendees.ATTENDEE_STATUS, CalendarUtilities.attendeeStatusFromBusyStatus(busyStatus)); ops.newAttendee(attValues, exceptionStart); } else if (ops.size() < MAX_OPS_BEFORE_EXCEPTION_ATTENDEE_REDACTION) { ops.newAttendee(attValues, exceptionStart); } else { attendeesRedacted = true; } } } // And add the parent's reminder value if (reminderMins > 0) { ops.newReminder(reminderMins, exceptionStart); } if (attendeesRedacted) { mService.userLog("Attendees redacted in this exception"); } } private int encodeVisibility(int easVisibility) { int visibility = 0; switch(easVisibility) { case 0: visibility = Events.ACCESS_DEFAULT; break; case 1: visibility = Events.ACCESS_PUBLIC; break; case 2: visibility = Events.ACCESS_PRIVATE; break; case 3: visibility = Events.ACCESS_CONFIDENTIAL; break; } return visibility; } private void exceptionsParser(CalendarOperations ops, ContentValues cv, ArrayList<ContentValues> attendeeValues, int reminderMins, int busyStatus, long startTime, long endTime) throws IOException { while (nextTag(Tags.CALENDAR_EXCEPTIONS) != END) { switch (tag) { case Tags.CALENDAR_EXCEPTION: exceptionParser(ops, cv, attendeeValues, reminderMins, busyStatus, startTime, endTime); break; default: skipTag(); } } } private String categoriesParser(CalendarOperations ops) throws IOException { StringBuilder categories = new StringBuilder(); while (nextTag(Tags.CALENDAR_CATEGORIES) != END) { switch (tag) { case Tags.CALENDAR_CATEGORY: // TODO Handle categories (there's no similar concept for gdata AFAIK) // We need to save them and spit them back when we update the event categories.append(getValue()); categories.append(CATEGORY_TOKENIZER_DELIMITER); break; default: skipTag(); } } return categories.toString(); } /** * For now, we ignore (but still have to parse) event attachments; these are new in EAS 14 */ private void attachmentsParser() throws IOException { while (nextTag(Tags.CALENDAR_ATTACHMENTS) != END) { switch (tag) { case Tags.CALENDAR_ATTACHMENT: skipParser(Tags.CALENDAR_ATTACHMENT); break; default: skipTag(); } } } private ArrayList<ContentValues> attendeesParser(CalendarOperations ops, long eventId) throws IOException { int attendeeCount = 0; ArrayList<ContentValues> attendeeValues = new ArrayList<ContentValues>(); while (nextTag(Tags.CALENDAR_ATTENDEES) != END) { switch (tag) { case Tags.CALENDAR_ATTENDEE: ContentValues cv = attendeeParser(ops, eventId); // If we're going to redact these attendees anyway, let's avoid unnecessary // memory pressure, and not keep them around // We still need to parse them all, however attendeeCount++; // Allow one more than MAX_ATTENDEES, so that the check for "too many" will // succeed in addEvent if (attendeeCount <= (MAX_SYNCED_ATTENDEES+1)) { attendeeValues.add(cv); } break; default: skipTag(); } } return attendeeValues; } private ContentValues attendeeParser(CalendarOperations ops, long eventId) throws IOException { ContentValues cv = new ContentValues(); while (nextTag(Tags.CALENDAR_ATTENDEE) != END) { switch (tag) { case Tags.CALENDAR_ATTENDEE_EMAIL: cv.put(Attendees.ATTENDEE_EMAIL, getValue()); break; case Tags.CALENDAR_ATTENDEE_NAME: cv.put(Attendees.ATTENDEE_NAME, getValue()); break; case Tags.CALENDAR_ATTENDEE_STATUS: int status = getValueInt(); cv.put(Attendees.ATTENDEE_STATUS, (status == 2) ? Attendees.ATTENDEE_STATUS_TENTATIVE : (status == 3) ? Attendees.ATTENDEE_STATUS_ACCEPTED : (status == 4) ? Attendees.ATTENDEE_STATUS_DECLINED : (status == 5) ? Attendees.ATTENDEE_STATUS_INVITED : Attendees.ATTENDEE_STATUS_NONE); break; case Tags.CALENDAR_ATTENDEE_TYPE: int type = Attendees.TYPE_NONE; // EAS types: 1 = req'd, 2 = opt, 3 = resource switch (getValueInt()) { case 1: type = Attendees.TYPE_REQUIRED; break; case 2: type = Attendees.TYPE_OPTIONAL; break; } cv.put(Attendees.ATTENDEE_TYPE, type); break; default: skipTag(); } } cv.put(Attendees.ATTENDEE_RELATIONSHIP, Attendees.RELATIONSHIP_ATTENDEE); return cv; } private String bodyParser() throws IOException { String body = null; while (nextTag(Tags.BASE_BODY) != END) { switch (tag) { case Tags.BASE_DATA: body = getValue(); break; default: skipTag(); } } // Handle null data without error if (body == null) return ""; // Remove \r's from any body text return body.replace("\r\n", "\n"); } public void addParser(CalendarOperations ops) throws IOException { String serverId = null; while (nextTag(Tags.SYNC_ADD) != END) { switch (tag) { case Tags.SYNC_SERVER_ID: // same as serverId = getValue(); break; case Tags.SYNC_APPLICATION_DATA: addEvent(ops, serverId, false); break; default: skipTag(); } } } private Cursor getServerIdCursor(String serverId) { return mContentResolver.query(mAccountUri, ID_PROJECTION, SERVER_ID_AND_CALENDAR_ID, new String[] {serverId, mCalendarIdString}, null); } private Cursor getClientIdCursor(String clientId) { mBindArgument[0] = clientId; return mContentResolver.query(mAccountUri, ID_PROJECTION, CLIENT_ID_SELECTION, mBindArgument, null); } public void deleteParser(CalendarOperations ops) throws IOException { while (nextTag(Tags.SYNC_DELETE) != END) { switch (tag) { case Tags.SYNC_SERVER_ID: String serverId = getValue(); // Find the event with the given serverId Cursor c = getServerIdCursor(serverId); try { if (c.moveToFirst()) { userLog("Deleting ", serverId); ops.delete(c.getLong(0), serverId); } } finally { c.close(); } break; default: skipTag(); } } } /** * A change is handled as a delete (including all exceptions) and an add * This isn't as efficient as attempting to traverse the original and all of its exceptions, * but changes happen infrequently and this code is both simpler and easier to maintain * @param ops the array of pending ContactProviderOperations. * @throws IOException */ public void changeParser(CalendarOperations ops) throws IOException { String serverId = null; while (nextTag(Tags.SYNC_CHANGE) != END) { switch (tag) { case Tags.SYNC_SERVER_ID: serverId = getValue(); break; case Tags.SYNC_APPLICATION_DATA: userLog("Changing " + serverId); addEvent(ops, serverId, true); break; default: skipTag(); } } } @Override public void commandsParser() throws IOException { while (nextTag(Tags.SYNC_COMMANDS) != END) { if (tag == Tags.SYNC_ADD) { addParser(mOps); incrementChangeCount(); } else if (tag == Tags.SYNC_DELETE) { deleteParser(mOps); incrementChangeCount(); } else if (tag == Tags.SYNC_CHANGE) { changeParser(mOps); incrementChangeCount(); } else skipTag(); } } @Override public void commit() throws IOException { userLog("Calendar SyncKey saved as: ", mMailbox.mSyncKey); // Save the syncKey here, using the Helper provider by Calendar provider mOps.add(new Operation(SyncStateContract.Helpers.newSetOperation( asSyncAdapter(SyncState.CONTENT_URI, mEmailAddress, Eas.EXCHANGE_ACCOUNT_MANAGER_TYPE), mAccountManagerAccount, mMailbox.mSyncKey.getBytes()))); // We need to send cancellations now, because the Event won't exist after the commit for (long eventId: mSendCancelIdList) { EmailContent.Message msg; try { msg = CalendarUtilities.createMessageForEventId(mContext, eventId, EmailContent.Message.FLAG_OUTGOING_MEETING_CANCEL, null, mAccount); } catch (RemoteException e) { // Nothing to do here; the Event may no longer exist continue; } if (msg != null) { EasOutboxService.sendMessage(mContext, mAccount.mId, msg); } } // Execute our CPO's safely try { mOps.mResults = safeExecute(CalendarContract.AUTHORITY, mOps); } catch (RemoteException e) { throw new IOException("Remote exception caught; will retry"); } if (mOps.mResults != null) { // Clear dirty and mark flags for updates sent to server if (!mUploadedIdList.isEmpty()) { ContentValues cv = new ContentValues(); cv.put(Events.DIRTY, 0); cv.put(EVENT_SYNC_MARK, "0"); for (long eventId : mUploadedIdList) { mContentResolver.update( asSyncAdapter( ContentUris.withAppendedId(Events.CONTENT_URI, eventId), mEmailAddress, Eas.EXCHANGE_ACCOUNT_MANAGER_TYPE), cv, null, null); } } // Delete events marked for deletion if (!mDeletedIdList.isEmpty()) { for (long eventId : mDeletedIdList) { mContentResolver.delete( asSyncAdapter( ContentUris.withAppendedId(Events.CONTENT_URI, eventId), mEmailAddress, Eas.EXCHANGE_ACCOUNT_MANAGER_TYPE), null, null); } } // Send any queued up email (invitations replies, etc.) for (Message msg: mOutgoingMailList) { EasOutboxService.sendMessage(mContext, mAccount.mId, msg); } } } public void addResponsesParser() throws IOException { String serverId = null; String clientId = null; int status = -1; ContentValues cv = new ContentValues(); while (nextTag(Tags.SYNC_ADD) != END) { switch (tag) { case Tags.SYNC_SERVER_ID: serverId = getValue(); break; case Tags.SYNC_CLIENT_ID: clientId = getValue(); break; case Tags.SYNC_STATUS: status = getValueInt(); if (status != 1) { userLog("Attempt to add event failed with status: " + status); } break; default: skipTag(); } } if (clientId == null) return; if (serverId == null) { // TODO Reconsider how to handle this serverId = "FAIL:" + status; } Cursor c = getClientIdCursor(clientId); try { if (c.moveToFirst()) { cv.put(Events._SYNC_ID, serverId); cv.put(Events.SYNC_DATA2, clientId); long id = c.getLong(0); // Write the serverId into the Event mOps.add(new Operation(ContentProviderOperation .newUpdate(ContentUris.withAppendedId(mAsSyncAdapterEvents, id)) .withValues(cv))); userLog("New event " + clientId + " was given serverId: " + serverId); } } finally { c.close(); } } public void changeResponsesParser() throws IOException { String serverId = null; String status = null; while (nextTag(Tags.SYNC_CHANGE) != END) { switch (tag) { case Tags.SYNC_SERVER_ID: serverId = getValue(); break; case Tags.SYNC_STATUS: status = getValue(); break; default: skipTag(); } } if (serverId != null && status != null) { userLog("Changed event " + serverId + " failed with status: " + status); } } @Override public void responsesParser() throws IOException { // Handle server responses here (for Add and Change) while (nextTag(Tags.SYNC_RESPONSES) != END) { if (tag == Tags.SYNC_ADD) { addResponsesParser(); } else if (tag == Tags.SYNC_CHANGE) { changeResponsesParser(); } else skipTag(); } } } protected class CalendarOperations extends ArrayList<Operation> { private static final long serialVersionUID = 1L; public int mCount = 0; private ContentProviderResult[] mResults = null; private int mEventStart = 0; @Override public boolean add(Operation op) { super.add(op); mCount++; return true; } public int newEvent(Operation op) { mEventStart = mCount; add(op); return mEventStart; } public int newDelete(long id, String serverId) { int offset = mCount; delete(id, serverId); return offset; } public void newAttendee(ContentValues cv) { newAttendee(cv, mEventStart); } public void newAttendee(ContentValues cv, int eventStart) { add(new Operation(ContentProviderOperation.newInsert(mAsSyncAdapterAttendees) .withValues(cv), Attendees.EVENT_ID, eventStart)); } public void updatedAttendee(ContentValues cv, long id) { cv.put(Attendees.EVENT_ID, id); add(new Operation(ContentProviderOperation.newInsert(mAsSyncAdapterAttendees) .withValues(cv))); } public void newException(ContentValues cv) { add(new Operation(ContentProviderOperation.newInsert(mAsSyncAdapterEvents) .withValues(cv))); } public void newExtendedProperty(String name, String value) { add(new Operation(ContentProviderOperation.newInsert(mAsSyncAdapterExtendedProperties) .withValue(ExtendedProperties.NAME, name) .withValue(ExtendedProperties.VALUE, value), ExtendedProperties.EVENT_ID, mEventStart)); } public void updatedExtendedProperty(String name, String value, long id) { // Find an existing ExtendedProperties row for this event and property name Cursor c = mService.mContentResolver.query(ExtendedProperties.CONTENT_URI, EXTENDED_PROPERTY_PROJECTION, EVENT_ID_AND_NAME, new String[] {Long.toString(id), name}, null); long extendedPropertyId = -1; // If there is one, capture its _id if (c != null) { try { if (c.moveToFirst()) { extendedPropertyId = c.getLong(EXTENDED_PROPERTY_ID); } } finally { c.close(); } } // Either do an update or an insert, depending on whether one // already exists if (extendedPropertyId >= 0) { add(new Operation(ContentProviderOperation .newUpdate( ContentUris.withAppendedId(mAsSyncAdapterExtendedProperties, extendedPropertyId)) .withValue(ExtendedProperties.VALUE, value))); } else { newExtendedProperty(name, value); } } public void newReminder(int mins, int eventStart) { add(new Operation(ContentProviderOperation.newInsert(mAsSyncAdapterReminders) .withValue(Reminders.MINUTES, mins) .withValue(Reminders.METHOD, Reminders.METHOD_ALERT), ExtendedProperties.EVENT_ID, eventStart)); } public void newReminder(int mins) { newReminder(mins, mEventStart); } public void delete(long id, String syncId) { add(new Operation(ContentProviderOperation.newDelete( ContentUris.withAppendedId(mAsSyncAdapterEvents, id)))); // Delete the exceptions for this Event (CalendarProvider doesn't do this) add(new Operation(ContentProviderOperation .newDelete(mAsSyncAdapterEvents) .withSelection(Events.ORIGINAL_SYNC_ID + "=?", new String[] {syncId}))); } } private String decodeVisibility(int visibility) { int easVisibility = 0; switch(visibility) { case Events.ACCESS_DEFAULT: easVisibility = 0; break; case Events.ACCESS_PUBLIC: easVisibility = 1; break; case Events.ACCESS_PRIVATE: easVisibility = 2; break; case Events.ACCESS_CONFIDENTIAL: easVisibility = 3; break; } return Integer.toString(easVisibility); } private int getInt(ContentValues cv, String column) { Integer i = cv.getAsInteger(column); if (i == null) return 0; return i; } private void sendEvent(Entity entity, String clientId, Serializer s) throws IOException { // Serialize for EAS here // Set uid with the client id we created // 1) Serialize the top-level event // 2) Serialize attendees and reminders from subvalues // 3) Look for exceptions and serialize with the top-level event ContentValues entityValues = entity.getEntityValues(); final boolean isException = (clientId == null); boolean hasAttendees = false; final boolean isChange = entityValues.containsKey(Events._SYNC_ID); final Double version = mService.mProtocolVersionDouble; final boolean allDay = CalendarUtilities.getIntegerValueAsBoolean(entityValues, Events.ALL_DAY); // NOTE: Exchange 2003 (EAS 2.5) seems to require the "exception deleted" and "exception // start time" data before other data in exceptions. Failure to do so results in a // status 6 error during sync if (isException) { // Send exception deleted flag if necessary Integer deleted = entityValues.getAsInteger(Events.DELETED); boolean isDeleted = deleted != null && deleted == 1; Integer eventStatus = entityValues.getAsInteger(Events.STATUS); boolean isCanceled = eventStatus != null && eventStatus.equals(Events.STATUS_CANCELED); if (isDeleted || isCanceled) { s.data(Tags.CALENDAR_EXCEPTION_IS_DELETED, "1"); // If we're deleted, the UI will continue to show this exception until we mark // it canceled, so we'll do that here... if (isDeleted && !isCanceled) { final long eventId = entityValues.getAsLong(Events._ID); ContentValues cv = new ContentValues(); cv.put(Events.STATUS, Events.STATUS_CANCELED); mService.mContentResolver.update( asSyncAdapter(ContentUris.withAppendedId(Events.CONTENT_URI, eventId), mEmailAddress, Eas.EXCHANGE_ACCOUNT_MANAGER_TYPE), cv, null, null); } } else { s.data(Tags.CALENDAR_EXCEPTION_IS_DELETED, "0"); } // TODO Add reminders to exceptions (allow them to be specified!) Long originalTime = entityValues.getAsLong(Events.ORIGINAL_INSTANCE_TIME); if (originalTime != null) { final boolean originalAllDay = CalendarUtilities.getIntegerValueAsBoolean(entityValues, Events.ORIGINAL_ALL_DAY); if (originalAllDay) { // For all day events, we need our local all-day time originalTime = CalendarUtilities.getLocalAllDayCalendarTime(originalTime, mLocalTimeZone); } s.data(Tags.CALENDAR_EXCEPTION_START_TIME, CalendarUtilities.millisToEasDateTime(originalTime)); } else { // Illegal; what should we do? } } // Get the event's time zone String timeZoneName = entityValues.getAsString(allDay ? EVENT_SAVED_TIMEZONE_COLUMN : Events.EVENT_TIMEZONE); if (timeZoneName == null) { timeZoneName = mLocalTimeZone.getID(); } TimeZone eventTimeZone = TimeZone.getTimeZone(timeZoneName); if (!isException) { // A time zone is required in all EAS events; we'll use the default if none is set // Exchange 2003 seems to require this first... :-) String timeZone = CalendarUtilities.timeZoneToTziString(eventTimeZone); s.data(Tags.CALENDAR_TIME_ZONE, timeZone); } s.data(Tags.CALENDAR_ALL_DAY_EVENT, allDay ? "1" : "0"); // DTSTART is always supplied long startTime = entityValues.getAsLong(Events.DTSTART); // Determine endTime; it's either provided as DTEND or we calculate using DURATION // If no DURATION is provided, we default to one hour long endTime; if (entityValues.containsKey(Events.DTEND)) { endTime = entityValues.getAsLong(Events.DTEND); } else { long durationMillis = HOURS; if (entityValues.containsKey(Events.DURATION)) { Duration duration = new Duration(); try { duration.parse(entityValues.getAsString(Events.DURATION)); durationMillis = duration.getMillis(); } catch (ParseException e) { // Can't do much about this; use the default (1 hour) } } endTime = startTime + durationMillis; } if (allDay) { TimeZone tz = mLocalTimeZone; startTime = CalendarUtilities.getLocalAllDayCalendarTime(startTime, tz); endTime = CalendarUtilities.getLocalAllDayCalendarTime(endTime, tz); } s.data(Tags.CALENDAR_START_TIME, CalendarUtilities.millisToEasDateTime(startTime)); s.data(Tags.CALENDAR_END_TIME, CalendarUtilities.millisToEasDateTime(endTime)); s.data(Tags.CALENDAR_DTSTAMP, CalendarUtilities.millisToEasDateTime(System.currentTimeMillis())); String loc = entityValues.getAsString(Events.EVENT_LOCATION); if (!TextUtils.isEmpty(loc)) { if (version < Eas.SUPPORTED_PROTOCOL_EX2007_DOUBLE) { // EAS 2.5 doesn't like bare line feeds loc = Utility.replaceBareLfWithCrlf(loc); } s.data(Tags.CALENDAR_LOCATION, loc); } s.writeStringValue(entityValues, Events.TITLE, Tags.CALENDAR_SUBJECT); if (version >= Eas.SUPPORTED_PROTOCOL_EX2007_DOUBLE) { s.start(Tags.BASE_BODY); s.data(Tags.BASE_TYPE, "1"); s.writeStringValue(entityValues, Events.DESCRIPTION, Tags.BASE_DATA); s.end(); } else { // EAS 2.5 doesn't like bare line feeds s.writeStringValue(entityValues, Events.DESCRIPTION, Tags.CALENDAR_BODY); } if (!isException) { // For Exchange 2003, only upsync if the event is new if ((version >= Eas.SUPPORTED_PROTOCOL_EX2007_DOUBLE) || !isChange) { s.writeStringValue(entityValues, Events.ORGANIZER, Tags.CALENDAR_ORGANIZER_EMAIL); } String rrule = entityValues.getAsString(Events.RRULE); if (rrule != null) { CalendarUtilities.recurrenceFromRrule(rrule, startTime, s); } // Handle associated data EXCEPT for attendees, which have to be grouped ArrayList<NamedContentValues> subValues = entity.getSubValues(); // The earliest of the reminders for this Event; we can only send one reminder... int earliestReminder = -1; for (NamedContentValues ncv: subValues) { Uri ncvUri = ncv.uri; ContentValues ncvValues = ncv.values; if (ncvUri.equals(ExtendedProperties.CONTENT_URI)) { String propertyName = ncvValues.getAsString(ExtendedProperties.NAME); String propertyValue = ncvValues.getAsString(ExtendedProperties.VALUE); if (TextUtils.isEmpty(propertyValue)) { continue; } if (propertyName.equals(EXTENDED_PROPERTY_CATEGORIES)) { // Send all the categories back to the server // We've saved them as a String of delimited tokens StringTokenizer st = new StringTokenizer(propertyValue, CATEGORY_TOKENIZER_DELIMITER); if (st.countTokens() > 0) { s.start(Tags.CALENDAR_CATEGORIES); while (st.hasMoreTokens()) { String category = st.nextToken(); s.data(Tags.CALENDAR_CATEGORY, category); } s.end(); } } } else if (ncvUri.equals(Reminders.CONTENT_URI)) { Integer mins = ncvValues.getAsInteger(Reminders.MINUTES); if (mins != null) { // -1 means "default", which for Exchange, is 30 if (mins < 0) { mins = 30; } // Save this away if it's the earliest reminder (greatest minutes) if (mins > earliestReminder) { earliestReminder = mins; } } } } // If we have a reminder, send it to the server if (earliestReminder >= 0) { s.data(Tags.CALENDAR_REMINDER_MINS_BEFORE, Integer.toString(earliestReminder)); } // We've got to send a UID, unless this is an exception. If the event is new, we've // generated one; if not, we should have gotten one from extended properties. if (clientId != null) { s.data(Tags.CALENDAR_UID, clientId); } // Handle attendee data here; keep track of organizer and stream it afterward String organizerName = null; String organizerEmail = null; for (NamedContentValues ncv: subValues) { Uri ncvUri = ncv.uri; ContentValues ncvValues = ncv.values; if (ncvUri.equals(Attendees.CONTENT_URI)) { Integer relationship = ncvValues.getAsInteger(Attendees.ATTENDEE_RELATIONSHIP); // If there's no relationship, we can't create this for EAS // Similarly, we need an attendee email for each invitee if (relationship != null && ncvValues.containsKey(Attendees.ATTENDEE_EMAIL)) { // Organizer isn't among attendees in EAS if (relationship == Attendees.RELATIONSHIP_ORGANIZER) { organizerName = ncvValues.getAsString(Attendees.ATTENDEE_NAME); organizerEmail = ncvValues.getAsString(Attendees.ATTENDEE_EMAIL); continue; } if (!hasAttendees) { s.start(Tags.CALENDAR_ATTENDEES); hasAttendees = true; } s.start(Tags.CALENDAR_ATTENDEE); String attendeeEmail = ncvValues.getAsString(Attendees.ATTENDEE_EMAIL); String attendeeName = ncvValues.getAsString(Attendees.ATTENDEE_NAME); if (attendeeName == null) { attendeeName = attendeeEmail; } s.data(Tags.CALENDAR_ATTENDEE_NAME, attendeeName); s.data(Tags.CALENDAR_ATTENDEE_EMAIL, attendeeEmail); if (version >= Eas.SUPPORTED_PROTOCOL_EX2007_DOUBLE) { s.data(Tags.CALENDAR_ATTENDEE_TYPE, "1"); // Required } s.end(); // Attendee } } } if (hasAttendees) { s.end(); // Attendees } // Get busy status from Attendees table long eventId = entityValues.getAsLong(Events._ID); int busyStatus = CalendarUtilities.BUSY_STATUS_TENTATIVE; Cursor c = mService.mContentResolver.query( asSyncAdapter(Attendees.CONTENT_URI, mEmailAddress, Eas.EXCHANGE_ACCOUNT_MANAGER_TYPE), ATTENDEE_STATUS_PROJECTION, EVENT_AND_EMAIL, new String[] {Long.toString(eventId), mEmailAddress}, null); if (c != null) { try { if (c.moveToFirst()) { busyStatus = CalendarUtilities.busyStatusFromAttendeeStatus( c.getInt(ATTENDEE_STATUS_COLUMN_STATUS)); } } finally { c.close(); } } s.data(Tags.CALENDAR_BUSY_STATUS, Integer.toString(busyStatus)); // Meeting status, 0 = appointment, 1 = meeting, 3 = attendee if (mEmailAddress.equalsIgnoreCase(organizerEmail)) { s.data(Tags.CALENDAR_MEETING_STATUS, hasAttendees ? "1" : "0"); } else { s.data(Tags.CALENDAR_MEETING_STATUS, "3"); } // For Exchange 2003, only upsync if the event is new if (((version >= Eas.SUPPORTED_PROTOCOL_EX2007_DOUBLE) || !isChange) && organizerName != null) { s.data(Tags.CALENDAR_ORGANIZER_NAME, organizerName); } // NOTE: Sensitivity must NOT be sent to the server for exceptions in Exchange 2003 // The result will be a status 6 failure during sync Integer visibility = entityValues.getAsInteger(Events.ACCESS_LEVEL); if (visibility != null) { s.data(Tags.CALENDAR_SENSITIVITY, decodeVisibility(visibility)); } else { // Default to private if not set s.data(Tags.CALENDAR_SENSITIVITY, "1"); } } } /** * Convenience method for sending an email to the organizer declining the meeting * @param entity * @param clientId */ private void sendDeclinedEmail(Entity entity, String clientId) { Message msg = CalendarUtilities.createMessageForEntity(mContext, entity, Message.FLAG_OUTGOING_MEETING_DECLINE, clientId, mAccount); if (msg != null) { userLog("Queueing declined response to " + msg.mTo); mOutgoingMailList.add(msg); } } @Override public boolean sendLocalChanges(Serializer s) throws IOException { ContentResolver cr = mService.mContentResolver; if (getSyncKey().equals("0")) { return false; } try { // We've got to handle exceptions as part of the parent when changes occur, so we need // to find new/changed exceptions and mark the parent dirty ArrayList<Long> orphanedExceptions = new ArrayList<Long>(); Cursor c = cr.query(Events.CONTENT_URI, ORIGINAL_EVENT_PROJECTION, DIRTY_EXCEPTION_IN_CALENDAR, mCalendarIdArgument, null); try { ContentValues cv = new ContentValues(); // We use _sync_mark here to distinguish dirty parents from parents with dirty // exceptions cv.put(EVENT_SYNC_MARK, "1"); while (c.moveToNext()) { // Mark the parents of dirty exceptions long parentId = c.getLong(0); int cnt = cr.update( asSyncAdapter(Events.CONTENT_URI, mEmailAddress, Eas.EXCHANGE_ACCOUNT_MANAGER_TYPE), cv, EVENT_ID_AND_CALENDAR_ID, new String[] { Long.toString(parentId), mCalendarIdString }); // Keep track of any orphaned exceptions if (cnt == 0) { orphanedExceptions.add(c.getLong(1)); } } } finally { c.close(); } // Delete any orphaned exceptions for (long orphan : orphanedExceptions) { userLog(TAG, "Deleted orphaned exception: " + orphan); cr.delete( asSyncAdapter(ContentUris.withAppendedId(Events.CONTENT_URI, orphan), mEmailAddress, Eas.EXCHANGE_ACCOUNT_MANAGER_TYPE), null, null); } orphanedExceptions.clear(); // Now we can go through dirty/marked top-level events and send them // back to the server EntityIterator eventIterator = EventsEntity.newEntityIterator(cr.query( asSyncAdapter(Events.CONTENT_URI, mEmailAddress, Eas.EXCHANGE_ACCOUNT_MANAGER_TYPE), null, DIRTY_OR_MARKED_TOP_LEVEL_IN_CALENDAR, mCalendarIdArgument, null), cr); ContentValues cidValues = new ContentValues(); try { boolean first = true; while (eventIterator.hasNext()) { Entity entity = eventIterator.next(); // For each of these entities, create the change commands ContentValues entityValues = entity.getEntityValues(); String serverId = entityValues.getAsString(Events._SYNC_ID); // We first need to check whether we can upsync this event; our test for this // is currently the value of EXTENDED_PROPERTY_ATTENDEES_REDACTED // If this is set to "1", we can't upsync the event for (NamedContentValues ncv: entity.getSubValues()) { if (ncv.uri.equals(ExtendedProperties.CONTENT_URI)) { ContentValues ncvValues = ncv.values; if (ncvValues.getAsString(ExtendedProperties.NAME).equals( EXTENDED_PROPERTY_UPSYNC_PROHIBITED)) { if ("1".equals(ncvValues.getAsString(ExtendedProperties.VALUE))) { // Make sure we mark this to clear the dirty flag mUploadedIdList.add(entityValues.getAsLong(Events._ID)); continue; } } } } // Find our uid in the entity; otherwise create one String clientId = entityValues.getAsString(Events.SYNC_DATA2); if (clientId == null) { clientId = UUID.randomUUID().toString(); } // EAS 2.5 needs: BusyStatus DtStamp EndTime Sensitivity StartTime TimeZone UID // We can generate all but what we're testing for below String organizerEmail = entityValues.getAsString(Events.ORGANIZER); boolean selfOrganizer = organizerEmail.equalsIgnoreCase(mEmailAddress); if (!entityValues.containsKey(Events.DTSTART) || (!entityValues.containsKey(Events.DURATION) && !entityValues.containsKey(Events.DTEND)) || organizerEmail == null) { continue; } if (first) { s.start(Tags.SYNC_COMMANDS); userLog("Sending Calendar changes to the server"); first = false; } long eventId = entityValues.getAsLong(Events._ID); if (serverId == null) { // This is a new event; create a clientId userLog("Creating new event with clientId: ", clientId); s.start(Tags.SYNC_ADD).data(Tags.SYNC_CLIENT_ID, clientId); // And save it in the Event as the local id cidValues.put(Events.SYNC_DATA2, clientId); cidValues.put(EVENT_SYNC_VERSION, "0"); cr.update( asSyncAdapter( ContentUris.withAppendedId(Events.CONTENT_URI, eventId), mEmailAddress, Eas.EXCHANGE_ACCOUNT_MANAGER_TYPE), cidValues, null, null); } else { if (entityValues.getAsInteger(Events.DELETED) == 1) { userLog("Deleting event with serverId: ", serverId); s.start(Tags.SYNC_DELETE).data(Tags.SYNC_SERVER_ID, serverId).end(); mDeletedIdList.add(eventId); if (selfOrganizer) { mSendCancelIdList.add(eventId); } else { sendDeclinedEmail(entity, clientId); } continue; } userLog("Upsync change to event with serverId: " + serverId); // Get the current version String version = entityValues.getAsString(EVENT_SYNC_VERSION); // This should never be null, but catch this error anyway // Version should be "0" when we create the event, so use that if (version == null) { version = "0"; } else { // Increment and save try { version = Integer.toString((Integer.parseInt(version) + 1)); } catch (Exception e) { // Handle the case in which someone writes a non-integer here; // shouldn't happen, but we don't want to kill the sync for his version = "0"; } } cidValues.put(EVENT_SYNC_VERSION, version); // Also save in entityValues so that we send it this time around entityValues.put(EVENT_SYNC_VERSION, version); cr.update( asSyncAdapter( ContentUris.withAppendedId(Events.CONTENT_URI, eventId), mEmailAddress, Eas.EXCHANGE_ACCOUNT_MANAGER_TYPE), cidValues, null, null); s.start(Tags.SYNC_CHANGE).data(Tags.SYNC_SERVER_ID, serverId); } s.start(Tags.SYNC_APPLICATION_DATA); sendEvent(entity, clientId, s); // Now, the hard part; find exceptions for this event if (serverId != null) { EntityIterator exIterator = EventsEntity.newEntityIterator(cr.query( asSyncAdapter(Events.CONTENT_URI, mEmailAddress, Eas.EXCHANGE_ACCOUNT_MANAGER_TYPE), null, ORIGINAL_EVENT_AND_CALENDAR, new String[] { serverId, mCalendarIdString }, null), cr); boolean exFirst = true; while (exIterator.hasNext()) { Entity exEntity = exIterator.next(); if (exFirst) { s.start(Tags.CALENDAR_EXCEPTIONS); exFirst = false; } s.start(Tags.CALENDAR_EXCEPTION); sendEvent(exEntity, null, s); ContentValues exValues = exEntity.getEntityValues(); if (getInt(exValues, Events.DIRTY) == 1) { // This is a new/updated exception, so we've got to notify our // attendees about it long exEventId = exValues.getAsLong(Events._ID); int flag; // Copy subvalues into the exception; otherwise, we won't see the // attendees when preparing the message for (NamedContentValues ncv: entity.getSubValues()) { exEntity.addSubValue(ncv.uri, ncv.values); } if ((getInt(exValues, Events.DELETED) == 1) || (getInt(exValues, Events.STATUS) == Events.STATUS_CANCELED)) { flag = Message.FLAG_OUTGOING_MEETING_CANCEL; if (!selfOrganizer) { // Send a cancellation notice to the organizer // Since CalendarProvider2 sets the organizer of exceptions // to the user, we have to reset it first to the original // organizer exValues.put(Events.ORGANIZER, entityValues.getAsString(Events.ORGANIZER)); sendDeclinedEmail(exEntity, clientId); } } else { flag = Message.FLAG_OUTGOING_MEETING_INVITE; } // Add the eventId of the exception to the uploaded id list, so that // the dirty/mark bits are cleared mUploadedIdList.add(exEventId); // Copy version so the ics attachment shows the proper sequence # exValues.put(EVENT_SYNC_VERSION, entityValues.getAsString(EVENT_SYNC_VERSION)); // Copy location so that it's included in the outgoing email if (entityValues.containsKey(Events.EVENT_LOCATION)) { exValues.put(Events.EVENT_LOCATION, entityValues.getAsString(Events.EVENT_LOCATION)); } if (selfOrganizer) { Message msg = CalendarUtilities.createMessageForEntity(mContext, exEntity, flag, clientId, mAccount); if (msg != null) { userLog("Queueing exception update to " + msg.mTo); mOutgoingMailList.add(msg); } } } s.end(); // EXCEPTION } if (!exFirst) { s.end(); // EXCEPTIONS } } s.end().end(); // ApplicationData & Change mUploadedIdList.add(eventId); // Go through the extended properties of this Event and pull out our tokenized // attendees list and the user attendee status; we will need them later String attendeeString = null; long attendeeStringId = -1; String userAttendeeStatus = null; long userAttendeeStatusId = -1; for (NamedContentValues ncv: entity.getSubValues()) { if (ncv.uri.equals(ExtendedProperties.CONTENT_URI)) { ContentValues ncvValues = ncv.values; String propertyName = ncvValues.getAsString(ExtendedProperties.NAME); if (propertyName.equals(EXTENDED_PROPERTY_ATTENDEES)) { attendeeString = ncvValues.getAsString(ExtendedProperties.VALUE); attendeeStringId = ncvValues.getAsLong(ExtendedProperties._ID); } else if (propertyName.equals( EXTENDED_PROPERTY_USER_ATTENDEE_STATUS)) { userAttendeeStatus = ncvValues.getAsString(ExtendedProperties.VALUE); userAttendeeStatusId = ncvValues.getAsLong(ExtendedProperties._ID); } } } // Send the meeting invite if there are attendees and we're the organizer AND // if the Event itself is dirty (we might be syncing only because an exception // is dirty, in which case we DON'T send email about the Event) if (selfOrganizer && (getInt(entityValues, Events.DIRTY) == 1)) { EmailContent.Message msg = CalendarUtilities.createMessageForEventId(mContext, eventId, EmailContent.Message.FLAG_OUTGOING_MEETING_INVITE, clientId, mAccount); if (msg != null) { userLog("Queueing invitation to ", msg.mTo); mOutgoingMailList.add(msg); } // Make a list out of our tokenized attendees, if we have any ArrayList<String> originalAttendeeList = new ArrayList<String>(); if (attendeeString != null) { StringTokenizer st = new StringTokenizer(attendeeString, ATTENDEE_TOKENIZER_DELIMITER); while (st.hasMoreTokens()) { originalAttendeeList.add(st.nextToken()); } } StringBuilder newTokenizedAttendees = new StringBuilder(); // See if any attendees have been dropped and while we're at it, build // an updated String with tokenized attendee addresses for (NamedContentValues ncv: entity.getSubValues()) { if (ncv.uri.equals(Attendees.CONTENT_URI)) { String attendeeEmail = ncv.values.getAsString(Attendees.ATTENDEE_EMAIL); // Remove all found attendees originalAttendeeList.remove(attendeeEmail); newTokenizedAttendees.append(attendeeEmail); newTokenizedAttendees.append(ATTENDEE_TOKENIZER_DELIMITER); } } // Update extended properties with the new attendee list, if we have one // Otherwise, create one (this would be the case for Events created on // device or "legacy" events (before this code was added) ContentValues cv = new ContentValues(); cv.put(ExtendedProperties.VALUE, newTokenizedAttendees.toString()); if (attendeeString != null) { cr.update(asSyncAdapter(ContentUris.withAppendedId( ExtendedProperties.CONTENT_URI, attendeeStringId), mEmailAddress, Eas.EXCHANGE_ACCOUNT_MANAGER_TYPE), cv, null, null); } else { // If there wasn't an "attendees" property, insert one cv.put(ExtendedProperties.NAME, EXTENDED_PROPERTY_ATTENDEES); cv.put(ExtendedProperties.EVENT_ID, eventId); cr.insert(asSyncAdapter(ExtendedProperties.CONTENT_URI, mEmailAddress, Eas.EXCHANGE_ACCOUNT_MANAGER_TYPE), cv); } // Whoever is left has been removed from the attendee list; send them // a cancellation for (String removedAttendee: originalAttendeeList) { // Send a cancellation message to each of them msg = CalendarUtilities.createMessageForEventId(mContext, eventId, Message.FLAG_OUTGOING_MEETING_CANCEL, clientId, mAccount, removedAttendee); if (msg != null) { // Just send it to the removed attendee userLog("Queueing cancellation to removed attendee " + msg.mTo); mOutgoingMailList.add(msg); } } } else if (!selfOrganizer) { // If we're not the organizer, see if we've changed our attendee status // Our last synced attendee status is in ExtendedProperties, and we've // retrieved it above as userAttendeeStatus int currentStatus = entityValues.getAsInteger(Events.SELF_ATTENDEE_STATUS); int syncStatus = Attendees.ATTENDEE_STATUS_NONE; if (userAttendeeStatus != null) { try { syncStatus = Integer.parseInt(userAttendeeStatus); } catch (NumberFormatException e) { // Just in case somebody else mucked with this and it's not Integer } } if ((currentStatus != syncStatus) && (currentStatus != Attendees.ATTENDEE_STATUS_NONE)) { // If so, send a meeting reply int messageFlag = 0; switch (currentStatus) { case Attendees.ATTENDEE_STATUS_ACCEPTED: messageFlag = Message.FLAG_OUTGOING_MEETING_ACCEPT; break; case Attendees.ATTENDEE_STATUS_DECLINED: messageFlag = Message.FLAG_OUTGOING_MEETING_DECLINE; break; case Attendees.ATTENDEE_STATUS_TENTATIVE: messageFlag = Message.FLAG_OUTGOING_MEETING_TENTATIVE; break; } // Make sure we have a valid status (messageFlag should never be zero) if (messageFlag != 0 && userAttendeeStatusId >= 0) { // Save away the new status cidValues.clear(); cidValues.put(ExtendedProperties.VALUE, Integer.toString(currentStatus)); cr.update(asSyncAdapter(ContentUris.withAppendedId( ExtendedProperties.CONTENT_URI, userAttendeeStatusId), mEmailAddress, Eas.EXCHANGE_ACCOUNT_MANAGER_TYPE), cidValues, null, null); // Send mail to the organizer advising of the new status EmailContent.Message msg = CalendarUtilities.createMessageForEventId(mContext, eventId, messageFlag, clientId, mAccount); if (msg != null) { userLog("Queueing invitation reply to " + msg.mTo); mOutgoingMailList.add(msg); } } } } } if (!first) { s.end(); // Commands } } finally { eventIterator.close(); } } catch (RemoteException e) { Log.e(TAG, "Could not read dirty events."); } return false; } }