package com.android.exchange.adapter; import android.content.ContentProviderOperation; import android.content.ContentProviderResult; import android.content.ContentResolver; import android.content.ContentUris; import android.content.ContentValues; import android.content.Context; import android.content.OperationApplicationException; import android.database.Cursor; import android.net.Uri; import android.os.RemoteException; import android.os.TransactionTooLargeException; import android.provider.CalendarContract; import android.provider.CalendarContract.Attendees; import android.provider.CalendarContract.Calendars; import android.provider.CalendarContract.Events; import android.provider.CalendarContract.ExtendedProperties; import android.provider.CalendarContract.Reminders; import android.provider.CalendarContract.SyncState; import android.provider.SyncStateContract; import android.text.format.DateUtils; import com.android.emailcommon.provider.Account; import com.android.emailcommon.provider.Mailbox; import com.android.emailcommon.utility.Utility; import com.android.exchange.Eas; import com.android.exchange.adapter.AbstractSyncAdapter.Operation; import com.android.exchange.eas.EasSyncCalendar; import com.android.exchange.utility.CalendarUtilities; import com.android.mail.utils.LogUtils; import com.google.common.annotations.VisibleForTesting; 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.TimeZone; public class CalendarSyncParser extends AbstractSyncParser { private static final String TAG = Eas.LOG_TAG; private final TimeZone UTC_TIMEZONE = TimeZone.getTimeZone("UTC"); private final TimeZone mLocalTimeZone = TimeZone.getDefault(); private final long mCalendarId; private final android.accounts.Account mAccountManagerAccount; private final Uri mAsSyncAdapterAttendees; private final Uri mAsSyncAdapterEvents; private final String[] mBindArgument = new String[1]; private final CalendarOperations mOps; private static final String EVENT_SAVED_TIMEZONE_COLUMN = Events.SYNC_DATA1; // 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 CLIENT_ID_SELECTION = Events.SYNC_DATA2 + "=?"; 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 EVENT_ID_AND_NAME = ExtendedProperties.EVENT_ID + "=? AND " + ExtendedProperties.NAME + "=?"; 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 long SEPARATOR_ID = Long.MAX_VALUE; // 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; public CalendarSyncParser(final Context context, final ContentResolver resolver, final InputStream in, final Mailbox mailbox, final Account account, final android.accounts.Account accountManagerAccount, final long calendarId) throws IOException { super(context, resolver, in, mailbox, account); mAccountManagerAccount = accountManagerAccount; mCalendarId = calendarId; mAsSyncAdapterAttendees = asSyncAdapter(Attendees.CONTENT_URI, mAccount.mEmailAddress, Eas.EXCHANGE_ACCOUNT_MANAGER_TYPE); mAsSyncAdapterEvents = asSyncAdapter(Events.CONTENT_URI, mAccount.mEmailAddress, Eas.EXCHANGE_ACCOUNT_MANAGER_TYPE); mOps = new CalendarOperations(resolver, mAsSyncAdapterAttendees, mAsSyncAdapterEvents, asSyncAdapter(Reminders.CONTENT_URI, mAccount.mEmailAddress, Eas.EXCHANGE_ACCOUNT_MANAGER_TYPE), asSyncAdapter(ExtendedProperties.CONTENT_URI, mAccount.mEmailAddress, Eas.EXCHANGE_ACCOUNT_MANAGER_TYPE)); } protected static class CalendarOperations extends ArrayList { private static final long serialVersionUID = 1L; public int mCount = 0; private int mEventStart = 0; private final ContentResolver mContentResolver; private final Uri mAsSyncAdapterAttendees; private final Uri mAsSyncAdapterEvents; private final Uri mAsSyncAdapterReminders; private final Uri mAsSyncAdapterExtendedProperties; public CalendarOperations(final ContentResolver contentResolver, final Uri asSyncAdapterAttendees, final Uri asSyncAdapterEvents, final Uri asSyncAdapterReminders, final Uri asSyncAdapterExtendedProperties) { mContentResolver = contentResolver; mAsSyncAdapterAttendees = asSyncAdapterAttendees; mAsSyncAdapterEvents = asSyncAdapterEvents; mAsSyncAdapterReminders = asSyncAdapterReminders; mAsSyncAdapterExtendedProperties = asSyncAdapterExtendedProperties; } @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 = 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 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(); } private static 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 PD * Other events MAY have a DURATION in any valid form (we use PM) * 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 * DateUtils.MINUTE_IN_MILLIS); // 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); final GregorianCalendar cal = new GregorianCalendar(UTC_TIMEZONE); exceptionTime = CalendarUtilities.getUtcAllDayCalendarTime(exceptionTime, mLocalTimeZone); 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 PD format for all day events if (cv.containsKey(Events.RRULE)) { if (allDayEvent != 0) { cv.put(Events.DURATION, "P" + ((endTime - startTime) / DateUtils.DAY_IN_MILLIS) + "D"); } else { cv.put(Events.DURATION, "P" + ((endTime - startTime) / DateUtils.MINUTE_IN_MILLIS) + "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 attendeeValues = new ArrayList(); 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(); 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: try { startTime = Utility.parseDateTimeToMillis(getValue()); } catch (ParseException e) { LogUtils.w(TAG, "Parse error for CALENDAR_START_TIME tag.", e); } break; case Tags.CALENDAR_END_TIME: try { endTime = Utility.parseDateTimeToMillis(getValue()); } catch (ParseException e) { LogUtils.w(TAG, "Parse error for CALENDAR_END_TIME tag.", e); } 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(); if (categories.length() > 0) { ops.newExtendedProperty(EXTENDED_PROPERTY_CATEGORIES, categories); } break; default: skipTag(); } } // Enforce CalendarProvider required properties setTimeRelatedValues(cv, startTime, endTime, allDayEvent); // Set user's availability cv.put(Events.AVAILABILITY, CalendarUtilities.availabilityFromBusyStatus(busyStatus)); // 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 = (mAccount.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"); LogUtils.d(TAG, "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 (mAccount.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 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 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.CALENDAR_EXCEPTION) != END) { switch (tag) { case Tags.CALENDAR_ATTACHMENTS: attachmentsParser(); break; case Tags.CALENDAR_EXCEPTION_START_TIME: final String valueStr = getValue(); try { cv.put(Events.ORIGINAL_INSTANCE_TIME, Utility.parseDateTimeToMillis(valueStr)); exceptionStartTime = valueStr; } catch (ParseException e) { LogUtils.w(TAG, "Parse error for CALENDAR_EXCEPTION_START_TIME tag.", e); } 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: try { startTime = Utility.parseDateTimeToMillis(getValue()); } catch (ParseException e) { LogUtils.w(TAG, "Parse error for CALENDAR_START_TIME tag.", e); } break; case Tags.CALENDAR_END_TIME: try { endTime = Utility.parseDateTimeToMillis(getValue()); } catch (ParseException e) { LogUtils.w(TAG, "Parse error for CALENDAR_END_TIME tag.", e); } 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 (mAccount.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) { LogUtils.d(TAG, "Attendees redacted in this exception"); } } private static 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 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() 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 attendeesParser() throws IOException { int attendeeCount = 0; ArrayList attendeeValues = new ArrayList(); while (nextTag(Tags.CALENDAR_ATTENDEES) != END) { switch (tag) { case Tags.CALENDAR_ATTENDEE: ContentValues cv = attendeeParser(); // 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() 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(Events.CONTENT_URI, ID_PROJECTION, SERVER_ID_AND_CALENDAR_ID, new String[] {serverId, Long.toString(mCalendarId)}, null); } private Cursor getClientIdCursor(String clientId) { mBindArgument[0] = clientId; return mContentResolver.query(Events.CONTENT_URI, 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); } else if (tag == Tags.SYNC_DELETE) { deleteParser(mOps); } else if (tag == Tags.SYNC_CHANGE) { changeParser(mOps); } 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, mAccount.mEmailAddress, Eas.EXCHANGE_ACCOUNT_MANAGER_TYPE), mAccountManagerAccount, mMailbox.mSyncKey.getBytes()))); // Execute our CPO's safely try { safeExecute(mContentResolver, CalendarContract.AUTHORITY, mOps); } catch (RemoteException e) { throw new IOException("Remote exception caught; will retry"); } } 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(); } } /** * We apply the batch of CPO's here. We synchronize on the service to avoid thread-nasties, * and we just return quickly if the service has already been stopped. */ private static ContentProviderResult[] execute(final ContentResolver contentResolver, final String authority, final ArrayList ops) throws RemoteException, OperationApplicationException { if (!ops.isEmpty()) { try { ContentProviderResult[] result = contentResolver.applyBatch(authority, ops); //mService.userLog("Results: " + result.length); return result; } catch (IllegalArgumentException e) { // Thrown when Calendar Provider is disabled LogUtils.e(TAG, "Error executing operation; provider is disabled.", e); } } return new ContentProviderResult[0]; } /** * Convert an Operation to a CPO; if the Operation has a back reference, apply it with the * passed-in offset */ @VisibleForTesting static ContentProviderOperation operationToContentProviderOperation(Operation op, int offset) { if (op.mOp != null) { return op.mOp; } else if (op.mBuilder == null) { throw new IllegalArgumentException("Operation must have CPO.Builder"); } ContentProviderOperation.Builder builder = op.mBuilder; if (op.mColumnName != null) { builder.withValueBackReference(op.mColumnName, op.mOffset - offset); } return builder.build(); } /** * Create a list of CPOs from a list of Operations, and then apply them in a batch */ private static ContentProviderResult[] applyBatch(final ContentResolver contentResolver, final String authority, final ArrayList ops, final int offset) throws RemoteException, OperationApplicationException { // Handle the empty case if (ops.isEmpty()) { return new ContentProviderResult[0]; } ArrayList cpos = new ArrayList(); for (Operation op: ops) { cpos.add(operationToContentProviderOperation(op, offset)); } return execute(contentResolver, authority, cpos); } /** * Apply the list of CPO's in the provider and copy the "mini" result into our full result array */ private static void applyAndCopyResults(final ContentResolver contentResolver, final String authority, final ArrayList mini, final ContentProviderResult[] result, final int offset) throws RemoteException { // Empty lists are ok; we just ignore them if (mini.isEmpty()) return; try { ContentProviderResult[] miniResult = applyBatch(contentResolver, authority, mini, offset); // Copy the results from this mini-batch into our results array System.arraycopy(miniResult, 0, result, offset, miniResult.length); } catch (OperationApplicationException e) { // Not possible since we're building the ops ourselves } } /** * Called by a sync adapter to execute a list of Operations in the ContentProvider handling * the passed-in authority. If the attempt to apply the batch fails due to a too-large * binder transaction, we split the Operations as directed by separators. If any of the * "mini" batches fails due to a too-large transaction, we're screwed, but this would be * vanishingly rare. Other, possibly transient, errors are handled by throwing a * RemoteException, which the caller will likely re-throw as an IOException so that the sync * can be attempted again. * * Callers MAY leave a dangling separator at the end of the list; note that the separators * themselves are only markers and are not sent to the provider. */ protected static ContentProviderResult[] safeExecute(final ContentResolver contentResolver, final String authority, final ArrayList ops) throws RemoteException { //mService.userLog("Try to execute ", ops.size(), " CPO's for " + authority); ContentProviderResult[] result = null; try { // Try to execute the whole thing return applyBatch(contentResolver, authority, ops, 0); } catch (TransactionTooLargeException e) { // Nope; split into smaller chunks, demarcated by the separator operation //mService.userLog("Transaction too large; spliting!"); ArrayList mini = new ArrayList(); // Build a result array with the total size we're sending result = new ContentProviderResult[ops.size()]; int count = 0; int offset = 0; for (Operation op: ops) { if (op.mSeparator) { //mService.userLog("Try mini-batch of ", mini.size(), " CPO's"); applyAndCopyResults(contentResolver, authority, mini, result, offset); mini.clear(); // Save away the offset here; this will need to be subtracted out of the // value originally set by the adapter offset = count + 1; // Remember to add 1 for the separator! } else { mini.add(op); } count++; } // Check out what's left; if it's more than just a separator, apply the batch int miniSize = mini.size(); if ((miniSize > 0) && !(miniSize == 1 && mini.get(0).mSeparator)) { applyAndCopyResults(contentResolver, authority, mini, result, offset); } } catch (RemoteException e) { throw e; } catch (OperationApplicationException e) { // Not possible since we're building the ops ourselves } return result; } /** * Called by a sync adapter to indicate a relatively safe place to split a batch of CPO's */ protected static void addSeparatorOperation(ArrayList ops, Uri uri) { Operation op = new Operation( ContentProviderOperation.newDelete(ContentUris.withAppendedId(uri, SEPARATOR_ID))); op.mSeparator = true; ops.add(op); } @Override protected void wipe() { LogUtils.w(TAG, "Wiping calendar for account %d", mAccount.mId); EasSyncCalendar.wipeAccountFromContentProvider(mContext, mAccount.mEmailAddress); } }