/*
 * 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.content.OperationApplicationException;
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 ContentProviderOperation PLACEHOLDER_OPERATION =
        ContentProviderOperation.newInsert(Uri.EMPTY).build();

    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>();

    public CalendarSyncAdapter(EasSyncService service) {
        super(service);
        mEmailAddress = mAccount.mEmailAddress;
        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(ContentProviderOperation
                                    .newDelete(
                                            asSyncAdapter(Attendees.CONTENT_URI, mEmailAddress,
                                                    Eas.EXCHANGE_ACCOUNT_MANAGER_TYPE))
                                    .withSelection(ATTENDEES_EXCEPT_ORGANIZER, mBindArgument)
                                    .build());
                            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:
                        reminderMins = getValueInt();
                        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,
                            ContentProviderOperation
                                    .newInsert(
                                            asSyncAdapter(Events.CONTENT_URI, mEmailAddress,
                                                    Eas.EXCHANGE_ACCOUNT_MANAGER_TYPE))
                                    .withValues(cv).build());
                } 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;
                    }
                }
            }
        }

        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(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 these all at once...
            mOps.execute();

            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(ContentProviderOperation
                            .newUpdate(
                                    asSyncAdapter(
                                            ContentUris.withAppendedId(Events.CONTENT_URI, id),
                                            mEmailAddress, Eas.EXCHANGE_ACCOUNT_MANAGER_TYPE))
                            .withValues(cv).build());
                    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<ContentProviderOperation> {
        private static final long serialVersionUID = 1L;
        public int mCount = 0;
        private ContentProviderResult[] mResults = null;
        private int mEventStart = 0;

        @Override
        public boolean add(ContentProviderOperation op) {
            super.add(op);
            mCount++;
            return true;
        }

        public int newEvent(ContentProviderOperation 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(ContentProviderOperation
                    .newInsert(asSyncAdapter(Attendees.CONTENT_URI, mEmailAddress,
                            Eas.EXCHANGE_ACCOUNT_MANAGER_TYPE)).withValues(cv)
                    .withValueBackReference(Attendees.EVENT_ID, eventStart).build());
        }

        public void updatedAttendee(ContentValues cv, long id) {
            cv.put(Attendees.EVENT_ID, id);
            add(ContentProviderOperation.newInsert(asSyncAdapter(Attendees.CONTENT_URI,
                    mEmailAddress, Eas.EXCHANGE_ACCOUNT_MANAGER_TYPE)).withValues(cv).build());
        }

        public void newException(ContentValues cv) {
            add(ContentProviderOperation.newInsert(
                    asSyncAdapter(Events.CONTENT_URI, mEmailAddress,
                            Eas.EXCHANGE_ACCOUNT_MANAGER_TYPE)).withValues(cv).build());
        }

        public void newExtendedProperty(String name, String value) {
            add(ContentProviderOperation
                    .newInsert(asSyncAdapter(ExtendedProperties.CONTENT_URI, mEmailAddress,
                            Eas.EXCHANGE_ACCOUNT_MANAGER_TYPE))
                    .withValue(ExtendedProperties.NAME, name)
                    .withValue(ExtendedProperties.VALUE, value)
                    .withValueBackReference(ExtendedProperties.EVENT_ID, mEventStart).build());
        }

        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(ContentProviderOperation
                        .newUpdate(
                                ContentUris.withAppendedId(
                                        asSyncAdapter(ExtendedProperties.CONTENT_URI,
                                                mEmailAddress, Eas.EXCHANGE_ACCOUNT_MANAGER_TYPE),
                                        extendedPropertyId))
                        .withValue(ExtendedProperties.VALUE, value).build());
            } else {
                newExtendedProperty(name, value);
            }
        }

        public void newReminder(int mins, int eventStart) {
            add(ContentProviderOperation
                    .newInsert(asSyncAdapter(Reminders.CONTENT_URI, mEmailAddress,
                            Eas.EXCHANGE_ACCOUNT_MANAGER_TYPE))
                    .withValue(Reminders.MINUTES, mins)
                    .withValue(Reminders.METHOD, Reminders.METHOD_ALERT)
                    .withValueBackReference(ExtendedProperties.EVENT_ID, eventStart).build());
        }

        public void newReminder(int mins) {
            newReminder(mins, mEventStart);
        }

        public void delete(long id, String syncId) {
            add(ContentProviderOperation.newDelete(
                    asSyncAdapter(ContentUris.withAppendedId(Events.CONTENT_URI, id),
                            mEmailAddress, Eas.EXCHANGE_ACCOUNT_MANAGER_TYPE)).build());
            // Delete the exceptions for this Event (CalendarProvider doesn't do
            // this)
            add(ContentProviderOperation
                    .newDelete(asSyncAdapter(Events.CONTENT_URI, mEmailAddress,
                            Eas.EXCHANGE_ACCOUNT_MANAGER_TYPE))
                    .withSelection(Events.ORIGINAL_SYNC_ID + "=?", new String[] {syncId}).build());
        }

        public void execute() {
            synchronized (mService.getSynchronizer()) {
                if (!mService.isStopped()) {
                    try {
                        if (!isEmpty()) {
                            mService.userLog("Executing ", size(), " CPO's");
                            mResults = mContext.getContentResolver().applyBatch(
                                    CalendarContract.AUTHORITY, this);
                        }
                    } catch (RemoteException e) {
                        // There is nothing sensible to be done here
                        Log.e(TAG, "problem inserting event during server update", e);
                    } catch (OperationApplicationException e) {
                        // There is nothing sensible to be done here
                        Log.e(TAG, "problem inserting event during server update", e);
                    }
                }
            }
        }
    }

    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(ContentUris.withAppendedId(ExtendedProperties.CONTENT_URI,
                                        userAttendeeStatusId), 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;
    }
}
