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