Implement Exchange calendar sync support

What should be working:
* Events sync down from server and appear in calendar
* Recurrences and exceptions appear in calendar
* Changed events on server should be reflected in calendar
* Deletions on server should be reflected in calendar
* Push of new/changed/deleted events should work
* Changes on device are NOT synced back to server
* New, single events on device are synced back to server
  (no time zone, attendee, or recurrence support)
* Checkbox for syncing calendar added to setup flow
* System sync glue in manifest, etc.
* Bugs are to be expected
* A few unit tests; needs more

Change-Id: I7ca262eaba562ccb9d1af5b0cd948c6bac30e5dd
diff --git a/AndroidManifest.xml b/AndroidManifest.xml
index 9b376af..b7cb864 100644
--- a/AndroidManifest.xml
+++ b/AndroidManifest.xml
@@ -31,6 +31,8 @@
 
     <!--  For EAS purposes; could be removed when EAS has a permanent home -->
     <uses-permission android:name="android.permission.WRITE_CONTACTS"/>
+    <uses-permission android:name="android.permission.WRITE_CALENDAR"/>
+    <uses-permission android:name="android.permission.READ_CALENDAR"/>
 
     <!-- Only required if a store implements push mail and needs to keep network open -->
     <uses-permission android:name="android.permission.WAKE_LOCK"/>
@@ -208,6 +210,17 @@
                        android:resource="@xml/syncadapter_contacts" />
         </service>
 
+        <!--Required stanza to register the CalendarSyncAdapterService with SyncManager -->
+        <service
+            android:name="com.android.exchange.CalendarSyncAdapterService"
+            android:exported="true">
+            <intent-filter>
+                <action android:name="android.content.SyncAdapter" />
+            </intent-filter>
+            <meta-data android:name="android.content.SyncAdapter"
+                       android:resource="@xml/syncadapter_calendar" />
+        </service>
+
         <!-- Add android:process=":remote" below to enable SyncManager as a separate process -->
         <service
             android:name="com.android.exchange.SyncManager"
diff --git a/res/values/strings.xml b/res/values/strings.xml
index 35a9e44..c34ac09 100644
--- a/res/values/strings.xml
+++ b/res/values/strings.xml
@@ -454,7 +454,11 @@
     <!-- In Account setup options & Account Settings screens, check box for new-mail notification -->
     <string name="account_setup_options_notify_label">Notify me when email arrives.</string>
     <!-- In Account setup options screen, optional check box to also sync contacts -->
-    <string name="account_setup_options_sync_contacts_label">Sync contacts from this account.</string>
+    <string name="account_setup_options_sync_contacts_label">Sync contacts from this account.
+    </string>
+    <!-- In Account setup options screen, optional check box to also sync contacts -->
+    <string name="account_setup_options_sync_calendar_label">Sync calendar from this account.
+    </string>
     <!-- Dialog title when "setup" could not finish -->
     <string name="account_setup_failed_dlg_title">Setup could not finish</string>
     <!-- In Account setup options screen, label for email check frequency selector -->
diff --git a/res/xml/syncadapter_calendar.xml b/res/xml/syncadapter_calendar.xml
new file mode 100644
index 0000000..a6c0fc6
--- /dev/null
+++ b/res/xml/syncadapter_calendar.xml
@@ -0,0 +1,26 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+/**
+ * Copyright (c) 2010, The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+-->
+
+<!-- The attributes in this XML file provide configuration information -->
+<!-- for the SyncAdapter. -->
+
+<sync-adapter xmlns:android="http://schemas.android.com/apk/res/android"
+    android:contentAuthority="com.android.calendar"
+    android:accountType="com.android.exchange"
+/>
diff --git a/src/com/android/exchange/CalendarSyncAdapterService.java b/src/com/android/exchange/CalendarSyncAdapterService.java
new file mode 100644
index 0000000..91c7c99
--- /dev/null
+++ b/src/com/android/exchange/CalendarSyncAdapterService.java
@@ -0,0 +1,142 @@
+/*
+ * Copyright (C) 2010 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.exchange;
+
+import com.android.email.provider.EmailContent;
+import com.android.email.provider.EmailContent.AccountColumns;
+import com.android.email.provider.EmailContent.Mailbox;
+import com.android.email.provider.EmailContent.MailboxColumns;
+
+import android.accounts.Account;
+import android.accounts.OperationCanceledException;
+import android.app.Service;
+import android.content.AbstractThreadedSyncAdapter;
+import android.content.ContentProviderClient;
+import android.content.ContentResolver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.SyncResult;
+import android.database.Cursor;
+import android.os.Bundle;
+import android.os.IBinder;
+import android.provider.Calendar.Events;
+import android.util.Log;
+
+public class CalendarSyncAdapterService extends Service {
+    private static final String TAG = "EAS CalendarSyncAdapterService";
+    private static SyncAdapterImpl sSyncAdapter = null;
+    private static final Object sSyncAdapterLock = new Object();
+
+    private static final String ACCOUNT_AND_TYPE_CALENDAR =
+        MailboxColumns.ACCOUNT_KEY + "=? AND " + MailboxColumns.TYPE + '=' + Mailbox.TYPE_CALENDAR;
+
+    public CalendarSyncAdapterService() {
+        super();
+    }
+
+    private static class SyncAdapterImpl extends AbstractThreadedSyncAdapter {
+        private Context mContext;
+
+        public SyncAdapterImpl(Context context) {
+            super(context, true /* autoInitialize */);
+            mContext = context;
+        }
+
+        @Override
+        public void onPerformSync(Account account, Bundle extras,
+                String authority, ContentProviderClient provider, SyncResult syncResult) {
+            try {
+                CalendarSyncAdapterService.performSync(mContext, account, extras,
+                        authority, provider, syncResult);
+            } catch (OperationCanceledException e) {
+            }
+        }
+    }
+
+    @Override
+    public void onCreate() {
+        super.onCreate();
+        synchronized (sSyncAdapterLock) {
+            if (sSyncAdapter == null) {
+                sSyncAdapter = new SyncAdapterImpl(getApplicationContext());
+            }
+        }
+    }
+
+    @Override
+    public IBinder onBind(Intent intent) {
+        return sSyncAdapter.getSyncAdapterBinder();
+    }
+
+    /**
+     * Partial integration with system SyncManager; we tell our EAS SyncManager to start a calendar
+     * sync when we get the signal from the system SyncManager.
+     * The missing piece at this point is integration with the push/ping mechanism in EAS; this will
+     * be put in place at a later time.
+     */
+    private static void performSync(Context context, Account account, Bundle extras,
+            String authority, ContentProviderClient provider, SyncResult syncResult)
+            throws OperationCanceledException {
+        ContentResolver cr = context.getContentResolver();
+        boolean logging = Eas.USER_LOG;
+        if (logging) {
+            Log.d(TAG, "performSync");
+        }
+        if (extras.getBoolean(ContentResolver.SYNC_EXTRAS_UPLOAD)) {
+            Cursor c = cr.query(Events.CONTENT_URI,
+                    new String[] {Events._ID}, Events._SYNC_ID+ " ISNULL", null, null);
+            try {
+                if (!c.moveToFirst()) {
+                    if (logging) {
+                        Log.d(TAG, "Upload sync; no changes");
+                    }
+                    return;
+                }
+            } finally {
+                c.close();
+            }
+        }
+
+        // Find the (EmailProvider) account associated with this email address
+        Cursor accountCursor =
+            cr.query(EmailContent.Account.CONTENT_URI,
+                    EmailContent.ID_PROJECTION, AccountColumns.EMAIL_ADDRESS + "=?",
+                    new String[] {account.name}, null);
+        try {
+            if (accountCursor.moveToFirst()) {
+                long accountId = accountCursor.getLong(0);
+                // Now, find the calendar mailbox associated with the account
+                Cursor mailboxCursor = cr.query(Mailbox.CONTENT_URI, EmailContent.ID_PROJECTION,
+                        ACCOUNT_AND_TYPE_CALENDAR, new String[] {Long.toString(accountId)}, null);
+                try {
+                     if (mailboxCursor.moveToFirst()) {
+                        if (logging) {
+                            Log.d(TAG, "Calendar sync requested for " + account.name);
+                        }
+                        // Ask for a sync from our sync manager
+                        SyncManager.serviceRequest(mailboxCursor.getLong(0),
+                                SyncManager.SYNC_UPSYNC);
+                    }
+                } finally {
+                    mailboxCursor.close();
+                }
+            }
+        } finally {
+            accountCursor.close();
+        }
+    }
+}
\ No newline at end of file
diff --git a/src/com/android/exchange/EasSyncService.java b/src/com/android/exchange/EasSyncService.java
index 8b9b424..63f1587 100644
--- a/src/com/android/exchange/EasSyncService.java
+++ b/src/com/android/exchange/EasSyncService.java
@@ -31,6 +31,7 @@
 import com.android.email.service.EmailServiceProxy;
 import com.android.exchange.adapter.AbstractSyncAdapter;
 import com.android.exchange.adapter.AccountSyncAdapter;
+import com.android.exchange.adapter.CalendarSyncAdapter;
 import com.android.exchange.adapter.ContactsSyncAdapter;
 import com.android.exchange.adapter.EmailSyncAdapter;
 import com.android.exchange.adapter.FolderSyncParser;
@@ -1306,7 +1307,7 @@
         return pp.getSyncStatus();
     }
 
-    private String getFilterType() {
+    private String getEmailFilter() {
         String filter = Eas.FILTER_1_WEEK;
         switch (mAccount.mSyncLookback) {
             case com.android.email.Account.SYNC_WINDOW_1_DAY: {
@@ -1403,8 +1404,11 @@
             // Handle options
             s.start(Tags.SYNC_OPTIONS);
             // Set the lookback appropriately (EAS calls this a "filter") for all but Contacts
-            if (!className.equals("Contacts")) {
-                s.data(Tags.SYNC_FILTER_TYPE, getFilterType());
+            if (className.equals("Email")) {
+                s.data(Tags.SYNC_FILTER_TYPE, getEmailFilter());
+            } else if (className.equals("Calendar")) {
+                // TODO Force one month for calendar until we can set this!
+                s.data(Tags.SYNC_FILTER_TYPE, Eas.FILTER_1_MONTH);
             }
             // Set the truncation amount for all classes
             if (mProtocolVersionDouble >= 12.0) {
@@ -1495,6 +1499,8 @@
                 AbstractSyncAdapter target;
                 if (mMailbox.mType == Mailbox.TYPE_CONTACTS) {
                     target = new ContactsSyncAdapter(mMailbox, this);
+                } else if (mMailbox.mType == Mailbox.TYPE_CALENDAR) {
+                    target = new CalendarSyncAdapter(mMailbox, this);
                 } else {
                     target = new EmailSyncAdapter(mMailbox, this);
                 }
diff --git a/src/com/android/exchange/adapter/CalendarSyncAdapter.java b/src/com/android/exchange/adapter/CalendarSyncAdapter.java
index 739c9e2..2590d23 100644
--- a/src/com/android/exchange/adapter/CalendarSyncAdapter.java
+++ b/src/com/android/exchange/adapter/CalendarSyncAdapter.java
@@ -18,10 +18,36 @@
 package com.android.exchange.adapter;
 
 import com.android.email.provider.EmailContent.Mailbox;
+import com.android.exchange.Eas;
 import com.android.exchange.EasSyncService;
+import com.android.exchange.utility.CalendarUtilities;
+
+import android.content.ContentProviderOperation;
+import android.content.ContentProviderResult;
+import android.content.ContentResolver;
+import android.content.ContentUris;
+import android.content.ContentValues;
+import android.content.Entity;
+import android.content.EntityIterator;
+import android.content.OperationApplicationException;
+import android.content.Entity.NamedContentValues;
+import android.database.Cursor;
+import android.net.Uri;
+import android.os.RemoteException;
+import android.provider.Calendar;
+import android.provider.Calendar.Attendees;
+import android.provider.Calendar.Calendars;
+import android.provider.Calendar.Events;
+import android.provider.Calendar.EventsEntity;
+import android.provider.Calendar.ExtendedProperties;
+import android.provider.Calendar.Reminders;
+import android.provider.ContactsContract.RawContacts;
+import android.util.Log;
 
 import java.io.IOException;
 import java.io.InputStream;
+import java.util.ArrayList;
+import java.util.TimeZone;
 
 /**
  * Sync adapter class for EAS calendars
@@ -29,8 +55,51 @@
  */
 public class CalendarSyncAdapter extends AbstractSyncAdapter {
 
+    private static final String TAG = "EasCalendarSyncAdapter";
+    // Since exceptions will have the same _SYNC_ID as the original event we have to check that
+    // there's no original event when finding an item by _SYNC_ID
+    private static final String SERVER_ID_SELECTION = Events._SYNC_ID + "=? AND " +
+        Events.ORIGINAL_EVENT + " ISNULL";
+    private static final String CLIENT_ID_SELECTION = Events._SYNC_LOCAL_ID + "=?";
+    private static final String ATTENDEES_EXCEPT_ORGANIZER = Attendees.EVENT_ID + "=? AND " +
+        Attendees.ATTENDEE_RELATIONSHIP + "!=" + Attendees.RELATIONSHIP_ORGANIZER;
+    private static final String[] ID_PROJECTION = new String[] {Events._ID};
+
+    private static final String CALENDAR_SELECTION =
+        Calendars._SYNC_ACCOUNT + "=? AND " + Calendars._SYNC_ACCOUNT_TYPE + "=?";
+    private static final int CALENDAR_SELECTION_ID = 0;
+
+    private static final ContentProviderOperation PLACEHOLDER_OPERATION =
+        ContentProviderOperation.newInsert(Uri.EMPTY).build();
+    
+    private static final Uri sEventsUri = asSyncAdapter(Events.CONTENT_URI);
+    private static final Uri sAttendeesUri = asSyncAdapter(Attendees.CONTENT_URI);
+    private static final Uri sExtendedPropertiesUri = asSyncAdapter(ExtendedProperties.CONTENT_URI);
+    private static final Uri sRemindersUri = asSyncAdapter(Reminders.CONTENT_URI);
+
+    private android.accounts.Account mAccountManagerAccount;
+    private long mCalendarId = -1;
+
+    private ArrayList<Long> mDeletedIdList = new ArrayList<Long>();
+    private ArrayList<Long> mUpdatedIdList = new ArrayList<Long>();
+
     public CalendarSyncAdapter(Mailbox mailbox, EasSyncService service) {
         super(mailbox, service);
+
+        Cursor c = mService.mContentResolver.query(Calendars.CONTENT_URI,
+                new String[] {Calendars._ID}, CALENDAR_SELECTION,
+                new String[] {mAccount.mEmailAddress, Eas.ACCOUNT_MANAGER_TYPE}, null);
+        try {
+            if (c.moveToFirst()) {
+                mCalendarId = c.getLong(CALENDAR_SELECTION_ID);
+            }
+        } finally {
+            c.close();
+        }
+
+        if (mCalendarId == -1) {
+            mCalendarId = Long.parseLong(mailbox.mSyncStatus);
+        }
     }
 
     @Override
@@ -39,19 +108,993 @@
     }
 
     @Override
-    public boolean sendLocalChanges(Serializer s) throws IOException {
-        // TODO Auto-generated method stub
-        return false;
-    }
-
-    @Override
     public void cleanup() {
-        // TODO Auto-generated method stub
     }
 
     @Override
     public boolean parse(InputStream is) throws IOException {
-        // TODO Auto-generated method stub
+        EasCalendarSyncParser p = new EasCalendarSyncParser(is, this);
+        return p.parse();
+    }
+    
+    static Uri asSyncAdapter(Uri uri) {
+        return uri.buildUpon().appendQueryParameter(Calendar.CALLER_IS_SYNCADAPTER, "true").build();
+    }
+
+    /**
+     * Generate the uri for the data row associated with this NamedContentValues object
+     * @param ncv the NamedContentValues object
+     * @return a uri that can be used to refer to this row
+     */
+    public Uri dataUriFromNamedContentValues(NamedContentValues ncv) {
+        long id = ncv.values.getAsLong(RawContacts._ID);
+        Uri dataUri = ContentUris.withAppendedId(ncv.uri, id);
+        return dataUri;
+    }
+
+    /**
+     * We will eventually get our SyncKey from CalendarProvider.
+     */
+    @Override
+    public String getSyncKey() throws IOException {
+        return super.getSyncKey();
+    }
+
+    /**
+     * We will eventually set our SyncKey in CalendarProvider
+     */
+    @Override
+    public void setSyncKey(String syncKey, boolean inCommands) throws IOException {
+        super.setSyncKey(syncKey, inCommands);
+    }
+
+    public android.accounts.Account getAccountManagerAccount() {
+        if (mAccountManagerAccount == null) {
+            mAccountManagerAccount =
+                new android.accounts.Account(mAccount.mEmailAddress, Eas.ACCOUNT_MANAGER_TYPE);
+        }
+        return mAccountManagerAccount;
+    }
+
+    class EasCalendarSyncParser extends AbstractSyncParser {
+
+        String[] mBindArgument = new String[1];
+        String mMailboxIdAsString;
+        Uri mAccountUri;
+        CalendarOperations mOps = new CalendarOperations();
+
+        public EasCalendarSyncParser(InputStream in, CalendarSyncAdapter adapter)
+                throws IOException {
+            super(in, adapter);
+            setDebug(true);
+            setLoggingTag("CalendarParser");
+            mAccountUri = Events.CONTENT_URI;
+        }
+
+        @Override
+        public void wipe() {
+            mContentResolver.delete(mAccountUri, null, null);
+        }
+
+        public void addEvent(CalendarOperations ops, String serverId, boolean update)
+                throws IOException {
+            ContentValues cv = new ContentValues();
+            cv.put(Events.CALENDAR_ID, mCalendarId);
+            cv.put(Events._SYNC_ACCOUNT, mAccount.mEmailAddress);
+            cv.put(Events._SYNC_ACCOUNT_TYPE, Eas.ACCOUNT_MANAGER_TYPE);
+            cv.put(Events._SYNC_ID, serverId);
+
+            int allDayEvent = 0;
+            String organizerName = null;
+            String organizerEmail = null;
+            int eventOffset = -1;
+
+            boolean firstTag = true;
+            long eventId = -1;
+            while (nextTag(Tags.SYNC_APPLICATION_DATA) != END) {
+                if (update && firstTag) {
+                    // Find the event that's being updated
+                    Cursor c = getServerIdCursor(serverId);
+                    long id = -1;
+                    try {
+                        if (c.moveToFirst()) {
+                            id = c.getLong(0);
+                        }
+                    } finally {
+                        c.close();
+                    }
+                    if (id > 0) {
+                        if (tag == Tags.CALENDAR_ATTENDEES) {
+                            // This is an attendees-only update; just delete/re-add attendees
+                            mBindArgument[0] = Long.toString(id);
+                            ops.add(ContentProviderOperation.newDelete(Attendees.CONTENT_URI)
+                                    .withSelection(ATTENDEES_EXCEPT_ORGANIZER, mBindArgument)
+                                    .build());
+                            eventId = id;
+                        } else {
+                            // Otherwise, delete the original event and recreate it
+                            userLog("Changing (delete/add) event ", serverId);
+                            ops.delete(id);
+                            // Add a placeholder event so that associated tables can reference
+                            // this as a back reference.  We add the event at the end of the method
+                            eventOffset = ops.newEvent(PLACEHOLDER_OPERATION);
+                        }
+                    }
+                } else if (firstTag) {
+                    // Add a placeholder event so that associated tables can reference
+                    // this as a back reference.  We add the event at the end of the method
+                   eventOffset = ops.newEvent(PLACEHOLDER_OPERATION);
+                }
+                firstTag = false;
+                switch (tag) {
+                    case Tags.CALENDAR_ALL_DAY_EVENT:
+                        allDayEvent = getValueInt();
+                        cv.put(Events.ALL_DAY, allDayEvent);
+                        break;
+                    case Tags.CALENDAR_ATTENDEES:
+                        // If eventId >= 0, this is an update; otherwise, a new Event
+                        attendeesParser(ops, organizerName, organizerEmail, eventId);
+                        break;
+                    case Tags.BASE_BODY:
+                        cv.put(Events.DESCRIPTION, bodyParser());
+                        break;
+                    case Tags.CALENDAR_BODY:
+                        cv.put(Events.DESCRIPTION, getValue());
+                        break;
+                    case Tags.CALENDAR_CATEGORIES:
+                        categoriesParser(ops);
+                        break;
+                    case Tags.CALENDAR_TIME_ZONE:
+                        TimeZone tz = CalendarUtilities.parseTimeZone(getValue());
+                        if (tz != null) {
+                            cv.put(Events.EVENT_TIMEZONE, tz.getID());
+                        } else {
+                            cv.put(Events.EVENT_TIMEZONE, TimeZone.getDefault().getID());
+                        }
+                        break;
+                    case Tags.CALENDAR_START_TIME:
+                        long startTime = CalendarUtilities.parseDateTime(getValue());
+                        cv.put(Events.DTSTART, startTime);
+                        cv.put(Events.ORIGINAL_INSTANCE_TIME, startTime);
+                        break;
+                    case Tags.CALENDAR_END_TIME:
+                        cv.put(Events.DTEND, CalendarUtilities.parseDateTime(getValue()));
+                        break;
+                    case Tags.CALENDAR_EXCEPTIONS:
+                        exceptionsParser(ops, cv);
+                        break;
+                    case Tags.CALENDAR_LOCATION:
+                        cv.put(Events.EVENT_LOCATION, getValue());
+                        break;
+                    case Tags.CALENDAR_RECURRENCE:
+                        String rrule = recurrenceParser(ops);
+                        if (rrule != null) {
+                            cv.put(Events.RRULE, rrule);
+                        }
+                        break;
+                    case Tags.CALENDAR_ORGANIZER_EMAIL:
+                        organizerEmail = getValue();
+                        cv.put(Events.ORGANIZER, organizerEmail);
+                        break;
+                    case Tags.CALENDAR_SUBJECT:
+                        cv.put(Events.TITLE, getValue());
+                        break;
+                    case Tags.CALENDAR_SENSITIVITY:
+                        cv.put(Events.VISIBILITY, encodeVisibility(getValueInt()));
+                        break;
+                    case Tags.CALENDAR_UID:
+                        ops.newExtendedProperty("uid", getValue());
+                        break;
+                    case Tags.CALENDAR_ORGANIZER_NAME:
+                        organizerName = getValue();
+                        break;
+                    case Tags.CALENDAR_DTSTAMP:
+                        ops.newExtendedProperty("dtstamp", getValue());
+                        break;
+                    case Tags.CALENDAR_MEETING_STATUS:
+                        // TODO Try to fit this into Calendar scheme
+                        ops.newExtendedProperty("meeting_status", getValue());
+                        break;
+                    case Tags.CALENDAR_BUSY_STATUS:
+                        // TODO Try to fit this into Calendar scheme
+                        ops.newExtendedProperty("busy_status", getValue());
+                        break;
+                    case Tags.CALENDAR_REMINDER_MINS_BEFORE:
+                        ops.newReminder(getValueInt());
+                        cv.put(Events.HAS_ALARM, 1);
+                        break;
+                    default:
+                        skipTag();
+                }
+            }
+
+            // Put the real event in the proper place in the ops ArrayList
+            if (eventOffset >= 0) {
+                ops.set(eventOffset, ContentProviderOperation
+                        .newInsert(sEventsUri).withValues(cv).build());
+            }
+        }
+
+        private String recurrenceParser(CalendarOperations ops) throws IOException {
+            // Turn this information into an RRULE
+            int type = -1;
+            int occurrences = -1;
+            int interval = -1;
+            int dow = -1;
+            int dom = -1;
+            int wom = -1;
+            int moy = -1;
+            String until = null;
+
+            while (nextTag(Tags.CALENDAR_RECURRENCE) != END) {
+                switch (tag) {
+                    case Tags.CALENDAR_RECURRENCE_TYPE:
+                        type = getValueInt();
+                        break;
+                    case Tags.CALENDAR_RECURRENCE_INTERVAL:
+                        interval = getValueInt();
+                        break;
+                    case Tags.CALENDAR_RECURRENCE_OCCURRENCES:
+                        occurrences = getValueInt();
+                        break;
+                    case Tags.CALENDAR_RECURRENCE_DAYOFWEEK:
+                        dow = getValueInt();
+                        break;
+                    case Tags.CALENDAR_RECURRENCE_DAYOFMONTH:
+                        dom = getValueInt();
+                        break;
+                    case Tags.CALENDAR_RECURRENCE_WEEKOFMONTH:
+                        wom = getValueInt();
+                        break;
+                    case Tags.CALENDAR_RECURRENCE_MONTHOFYEAR:
+                        moy = getValueInt();
+                        break;
+                    case Tags.CALENDAR_RECURRENCE_UNTIL:
+                        until = getValue();
+                        break;
+                    default:
+                       skipTag();
+                }
+            }
+
+            return CalendarUtilities.rruleFromRecurrence(type, occurrences, interval,
+                    dow, dom, wom, moy, until);
+        }
+
+        private void exceptionParser(CalendarOperations ops, ContentValues parentCv)
+                throws IOException {
+            ContentValues cv = new ContentValues();
+            cv.put(Events.CALENDAR_ID, mCalendarId);
+            cv.put(Events._SYNC_ACCOUNT, mAccount.mEmailAddress);
+            cv.put(Events._SYNC_ACCOUNT_TYPE, Eas.ACCOUNT_MANAGER_TYPE);
+            cv.put(Events._SYNC_ID, parentCv.getAsString(Events._SYNC_ID));
+
+            // It appears that these values have to be copied from the parent if they are to appear
+            // Note that they can be overridden below
+            cv.put(Events.ORGANIZER, parentCv.getAsString(Events.ORGANIZER));
+            cv.put(Events.TITLE, parentCv.getAsString(Events.TITLE));
+            cv.put(Events.DESCRIPTION, parentCv.getAsBoolean(Events.DESCRIPTION));
+            cv.put(Events.ORIGINAL_ALL_DAY, parentCv.getAsInteger(Events.ALL_DAY));
+
+            // This column is the key that links the exception to the serverId
+            // TODO Make sure calendar knows this isn't globally unique!!
+            cv.put(Events.ORIGINAL_EVENT, parentCv.getAsString(Events._SYNC_ID));
+
+            while (nextTag(Tags.SYNC_APPLICATION_DATA) != END) {
+                switch (tag) {
+                    case Tags.CALENDAR_EXCEPTION_START_TIME:
+                        cv.put(Events.ORIGINAL_INSTANCE_TIME,
+                                CalendarUtilities.parseDateTime(getValue()));
+                        break;
+                    case Tags.CALENDAR_EXCEPTION_IS_DELETED:
+                        if (getValueInt() == 1) {
+                            cv.put(Events.STATUS, Events.STATUS_CANCELED);
+                        }
+                        break;
+                    case Tags.CALENDAR_ALL_DAY_EVENT:
+                        cv.put(Events.ALL_DAY, getValueInt());
+                        break;
+                    case Tags.BASE_BODY:
+                        cv.put(Events.DESCRIPTION, bodyParser());
+                        break;
+                    case Tags.CALENDAR_BODY:
+                        cv.put(Events.DESCRIPTION, getValue());
+                        break;
+                    case Tags.CALENDAR_START_TIME:
+                        cv.put(Events.DTSTART, CalendarUtilities.parseDateTime(getValue()));
+                        break;
+                    case Tags.CALENDAR_END_TIME:
+                        cv.put(Events.DTEND, CalendarUtilities.parseDateTime(getValue()));
+                        break;
+                    case Tags.CALENDAR_LOCATION:
+                        cv.put(Events.EVENT_LOCATION, getValue());
+                        break;
+                    case Tags.CALENDAR_RECURRENCE:
+                        String rrule = recurrenceParser(ops);
+                        if (rrule != null) {
+                            cv.put(Events.RRULE, rrule);
+                        }
+                        break;
+                    case Tags.CALENDAR_SUBJECT:
+                        cv.put(Events.TITLE, getValue());
+                        break;
+                    case Tags.CALENDAR_SENSITIVITY:
+                        cv.put(Events.VISIBILITY, encodeVisibility(getValueInt()));
+                        break;
+
+                        // TODO How to handle these items that are linked to event id!
+
+//                    case Tags.CALENDAR_DTSTAMP:
+//                        ops.newExtendedProperty("dtstamp", getValue());
+//                        break;
+//                    case Tags.CALENDAR_BUSY_STATUS:
+//                        // TODO Try to fit this into Calendar scheme
+//                        ops.newExtendedProperty("busy_status", getValue());
+//                        break;
+//                    case Tags.CALENDAR_REMINDER_MINS_BEFORE:
+//                        ops.newReminder(getValueInt());
+//                        break;
+
+                        // Not yet handled
+                    default:
+                        skipTag();
+                }
+            }
+
+            if (!cv.containsKey(Events.DTSTART)) {
+                cv.put(Events.DTSTART, parentCv.getAsLong(Events.DTSTART));
+            }
+            if (!cv.containsKey(Events.DTEND)) {
+                cv.put(Events.DTEND, parentCv.getAsLong(Events.DTEND));
+            }
+
+            ops.newException(cv);
+        }
+
+        private int encodeVisibility(int easVisibility) {
+            int visibility = 0;
+            switch(easVisibility) {
+                case 0:
+                    visibility = Events.VISIBILITY_DEFAULT;
+                    break;
+                case 1:
+                    visibility = Events.VISIBILITY_PUBLIC;
+                    break;
+                case 2:
+                    visibility = Events.VISIBILITY_PRIVATE;
+                    break;
+                case 3:
+                    visibility = Events.VISIBILITY_CONFIDENTIAL;
+                    break;
+            }
+            return visibility;
+        }
+
+        private void exceptionsParser(CalendarOperations ops, ContentValues cv)
+                throws IOException {
+            while (nextTag(Tags.CALENDAR_EXCEPTIONS) != END) {
+                switch (tag) {
+                    case Tags.CALENDAR_EXCEPTION:
+                        exceptionParser(ops, cv);
+                        break;
+                    default:
+                        skipTag();
+                }
+            }
+        }
+
+        private void categoriesParser(CalendarOperations ops) throws IOException {
+            while (nextTag(Tags.CALENDAR_CATEGORIES) != END) {
+                switch (tag) {
+                    case Tags.CALENDAR_CATEGORY:
+                        // TODO Handle categories
+                    default:
+                        skipTag();
+                }
+            }
+        }
+
+        private String attendeesParser(CalendarOperations ops, String organizerName,
+                String organizerEmail, long eventId) throws IOException {
+            String body = null;
+            // First, handle the organizer (who IS an attendee on device, but NOT in EAS)
+            if (organizerName != null || organizerEmail != null) {
+                ContentValues cv = new ContentValues();
+                if (organizerName != null) {
+                    cv.put(Attendees.ATTENDEE_NAME, organizerName);
+                }
+                if (organizerEmail != null) {
+                    cv.put(Attendees.ATTENDEE_EMAIL, organizerEmail);
+                }
+                cv.put(Attendees.ATTENDEE_RELATIONSHIP, Attendees.RELATIONSHIP_ORGANIZER);
+                cv.put(Attendees.ATTENDEE_TYPE, Attendees.TYPE_REQUIRED);
+                if (eventId < 0) {
+                    ops.newAttendee(cv);
+                } else {
+                    ops.updatedAttendee(cv, eventId);
+                }
+            }
+            while (nextTag(Tags.CALENDAR_ATTENDEES) != END) {
+                switch (tag) {
+                    case Tags.CALENDAR_ATTENDEE:
+                        attendeeParser(ops, eventId);
+                        break;
+                    default:
+                        skipTag();
+                }
+            }
+            return body;
+        }
+
+        private void attendeeParser(CalendarOperations ops, long eventId) throws IOException {
+            ContentValues cv = new ContentValues();
+            while (nextTag(Tags.CALENDAR_ATTENDEE) != END) {
+                switch (tag) {
+                    case Tags.CALENDAR_ATTENDEE_EMAIL:
+                        cv.put(Attendees.ATTENDEE_EMAIL, getValue());
+                        break;
+                    case Tags.CALENDAR_ATTENDEE_NAME:
+                        cv.put(Attendees.ATTENDEE_NAME, getValue());
+                        break;
+                    case Tags.CALENDAR_ATTENDEE_STATUS:
+                        int status = getValueInt();
+                        cv.put(Attendees.ATTENDEE_STATUS,
+                                (status == 2) ? Attendees.ATTENDEE_STATUS_TENTATIVE :
+                                (status == 3) ? Attendees.ATTENDEE_STATUS_ACCEPTED :
+                                (status == 4) ? Attendees.ATTENDEE_STATUS_DECLINED :
+                                (status == 5) ? Attendees.ATTENDEE_STATUS_INVITED :
+                                    Attendees.ATTENDEE_STATUS_NONE);
+                        break;
+                    case Tags.CALENDAR_ATTENDEE_TYPE:
+                        int type = Attendees.TYPE_NONE;
+                        // EAS types: 1 = req'd, 2 = opt, 3 = resource
+                        switch (getValueInt()) {
+                            case 1:
+                                type = Attendees.TYPE_REQUIRED;
+                                break;
+                            case 2:
+                                type = Attendees.TYPE_OPTIONAL;
+                                break;
+                        }
+                        cv.put(Attendees.ATTENDEE_TYPE, type);
+                        break;
+                    default:
+                        skipTag();
+                }
+            }
+            cv.put(Attendees.ATTENDEE_RELATIONSHIP, Attendees.RELATIONSHIP_ATTENDEE);
+            if (eventId < 0) {
+                ops.newAttendee(cv);
+            } else {
+                ops.updatedAttendee(cv, eventId);
+            }
+        }
+
+        private String bodyParser() throws IOException {
+            String body = null;
+            while (nextTag(Tags.BASE_BODY) != END) {
+                switch (tag) {
+                    case Tags.BASE_DATA:
+                        body = getValue();
+                        break;
+                    default:
+                        skipTag();
+                }
+            }
+
+            // Remove \r's from any body text
+            return body.replace("\r\n", "\n");
+        }
+
+        public void addParser(CalendarOperations ops) throws IOException {
+            String serverId = null;
+            while (nextTag(Tags.SYNC_ADD) != END) {
+                switch (tag) {
+                    case Tags.SYNC_SERVER_ID: // same as
+                        serverId = getValue();
+                        break;
+                    case Tags.SYNC_APPLICATION_DATA:
+                        addEvent(ops, serverId, false);
+                        break;
+                    default:
+                        skipTag();
+                }
+            }
+        }
+
+        private Cursor getServerIdCursor(String serverId) {
+            mBindArgument[0] = serverId;
+            return mContentResolver.query(mAccountUri, ID_PROJECTION, SERVER_ID_SELECTION,
+                    mBindArgument, null);
+        }
+
+        private Cursor getClientIdCursor(String clientId) {
+            mBindArgument[0] = clientId;
+            return mContentResolver.query(mAccountUri, ID_PROJECTION, CLIENT_ID_SELECTION,
+                    mBindArgument, null);
+        }
+
+        public void deleteParser(CalendarOperations ops) throws IOException {
+            while (nextTag(Tags.SYNC_DELETE) != END) {
+                switch (tag) {
+                    case Tags.SYNC_SERVER_ID:
+                        String serverId = getValue();
+                        // Find the event with the given serverId
+                        Cursor c = getServerIdCursor(serverId);
+                        try {
+                            if (c.moveToFirst()) {
+                                userLog("Deleting ", serverId);
+                                ops.delete(c.getLong(0));
+                            }
+                        } finally {
+                            c.close();
+                        }
+                        break;
+                    default:
+                        skipTag();
+                }
+            }
+        }
+
+        class ServerChange {
+            long id;
+            boolean read;
+
+            ServerChange(long _id, boolean _read) {
+                id = _id;
+                read = _read;
+            }
+        }
+
+        /**
+         * A change is handled as a delete (including all exceptions) and an add
+         * This isn't as efficient as attempting to traverse the original and all of its exceptions,
+         * but changes happen infrequently and this code is both simpler and easier to maintain
+         * @param ops the array of pending ContactProviderOperations.
+         * @throws IOException
+         */
+        public void changeParser(CalendarOperations ops) throws IOException {
+            String serverId = null;
+            while (nextTag(Tags.SYNC_CHANGE) != END) {
+                switch (tag) {
+                    case Tags.SYNC_SERVER_ID:
+                        serverId = getValue();
+                        break;
+                    case Tags.SYNC_APPLICATION_DATA:
+                        addEvent(ops, serverId, true);
+                        break;
+                    default:
+                        skipTag();
+                }
+            }
+        }
+
+        @Override
+        public void commandsParser() throws IOException {
+            while (nextTag(Tags.SYNC_COMMANDS) != END) {
+                if (tag == Tags.SYNC_ADD) {
+                    addParser(mOps);
+                    incrementChangeCount();
+                } else if (tag == Tags.SYNC_DELETE) {
+                    deleteParser(mOps);
+                    incrementChangeCount();
+                } else if (tag == Tags.SYNC_CHANGE) {
+                    changeParser(mOps);
+                    incrementChangeCount();
+                } else
+                    skipTag();
+            }
+        }
+
+        @Override
+        public void commit() throws IOException {
+            userLog("Calendar SyncKey saved as: ", mMailbox.mSyncKey);
+            // Save the syncKey here, using the Helper provider by Contacts provider
+            //ops.add(SyncStateContract.Helpers.newSetOperation(SyncState.CONTENT_URI,
+            //        getAccountManagerAccount(), mMailbox.mSyncKey.getBytes()));
+
+            // Execute these all at once...
+            mOps.execute();
+
+            if (mOps.mResults != null) {
+                // Clear dirty flag if necessary...
+            }
+        }
+
+        public void addResponsesParser() throws IOException {
+            String serverId = null;
+            String clientId = null;
+            int status = -1;
+            ContentValues cv = new ContentValues();
+            while (nextTag(Tags.SYNC_ADD) != END) {
+                switch (tag) {
+                    case Tags.SYNC_SERVER_ID:
+                        serverId = getValue();
+                        break;
+                    case Tags.SYNC_CLIENT_ID:
+                        clientId = getValue();
+                        break;
+                    case Tags.SYNC_STATUS:
+                        status = getValueInt();
+                        if (status != 1) {
+                            userLog("Attempt to add event failed with status: " + status);
+                        }
+                        break;
+                    default:
+                        skipTag();
+                }
+            }
+
+            if (clientId == null) return;
+            if (serverId == null) {
+                // TODO Reconsider how to handle this
+                serverId = "FAIL:" + status;
+            }
+
+            Cursor c = getClientIdCursor(clientId);
+            try {
+                if (c.moveToFirst()) {
+                    cv.put(Events._SYNC_ID, serverId);
+                    mOps.add(ContentProviderOperation.newUpdate(
+                            ContentUris.withAppendedId(sEventsUri, c.getLong(0)))
+                                    .withValues(cv)
+                                    .build());
+                    userLog("New event " + clientId + " was given serverId: " + serverId);
+                }
+            } finally {
+                c.close();
+            }
+        }
+
+        public void changeResponsesParser() throws IOException {
+            String serverId = null;
+            String status = null;
+            while (nextTag(Tags.SYNC_CHANGE) != END) {
+                switch (tag) {
+                    case Tags.SYNC_SERVER_ID:
+                        serverId = getValue();
+                        break;
+                    case Tags.SYNC_STATUS:
+                        status = getValue();
+                        break;
+                    default:
+                        skipTag();
+                }
+            }
+            if (serverId != null && status != null) {
+                userLog("Changed event " + serverId + " failed with status: " + status);
+            }
+        }
+
+
+        @Override
+        public void responsesParser() throws IOException {
+            // Handle server responses here (for Add and Change)
+            while (nextTag(Tags.SYNC_RESPONSES) != END) {
+                if (tag == Tags.SYNC_ADD) {
+                    addResponsesParser();
+                } else if (tag == Tags.SYNC_CHANGE) {
+                    changeResponsesParser();
+                } else
+                    skipTag();
+            }
+        }
+    }
+
+    private class CalendarOperations extends ArrayList<ContentProviderOperation> {
+        private static final long serialVersionUID = 1L;
+        private int mCount = 0;
+        private ContentProviderResult[] mResults = null;
+        private int mEventStart = 0;
+
+        @Override
+        public boolean add(ContentProviderOperation op) {
+            super.add(op);
+            mCount++;
+            return true;
+        }
+
+        public int newEvent(ContentProviderOperation op) {
+            mEventStart = mCount;
+            add(op);
+            return mEventStart;
+        }
+
+        public void newAttendee(ContentValues cv) {
+            add(ContentProviderOperation
+                    .newInsert(sAttendeesUri)
+                    .withValues(cv)
+                    .withValueBackReference(Attendees.EVENT_ID, mEventStart)
+                    .build());
+        }
+
+        public void updatedAttendee(ContentValues cv, long id) {
+            cv.put(Attendees.EVENT_ID, id);
+            add(ContentProviderOperation.newInsert(sAttendeesUri).withValues(cv).build());
+        }
+
+        public void newException(ContentValues cv) {
+            add(ContentProviderOperation.newInsert(sEventsUri).withValues(cv).build());
+        }
+
+        public void newExtendedProperty(String name, String value) {
+            add(ContentProviderOperation
+                    .newInsert(sExtendedPropertiesUri)
+                    .withValue(ExtendedProperties.NAME, name)
+                    .withValue(ExtendedProperties.VALUE, value)
+                    .withValueBackReference(ExtendedProperties.EVENT_ID, mEventStart)
+                    .build());
+        }
+
+        public void newReminder(int mins) {
+            add(ContentProviderOperation
+                    .newInsert(sRemindersUri)
+                    .withValue(Reminders.MINUTES, mins)
+                    .withValue(Reminders.METHOD, Reminders.METHOD_DEFAULT)
+                    .withValueBackReference(ExtendedProperties.EVENT_ID, mEventStart)
+                    .build());
+        }
+
+        public void delete(long id) {
+            add(ContentProviderOperation
+                    .newDelete(ContentUris.withAppendedId(sEventsUri, id)).build());
+        }
+
+        public void execute() {
+            synchronized (mService.getSynchronizer()) {
+                if (!mService.isStopped()) {
+                    try {
+                        if (!isEmpty()) {
+                            mService.userLog("Executing ", size(), " CPO's");
+                            mResults = mContext.getContentResolver().applyBatch(
+                                    Calendar.AUTHORITY, this);
+                        }
+                    } catch (RemoteException e) {
+                        // There is nothing sensible to be done here
+                        Log.e(TAG, "problem inserting event during server update", e);
+                    } catch (OperationApplicationException e) {
+                        // There is nothing sensible to be done here
+                        Log.e(TAG, "problem inserting event during server update", e);
+                    }
+                }
+            }
+        }
+    }
+
+    private String decodeVisibility(int visibility) {
+        int easVisibility = 0;
+        switch(visibility) {
+            case Events.VISIBILITY_DEFAULT:
+                easVisibility = 0;
+                break;
+            case Events.VISIBILITY_PUBLIC:
+                easVisibility = 1;
+                break;
+            case Events.VISIBILITY_PRIVATE:
+                easVisibility = 2;
+                break;
+            case Events.VISIBILITY_CONFIDENTIAL:
+                easVisibility = 3;
+                break;
+        }
+        return Integer.toString(easVisibility);
+    }
+
+    @Override
+    public boolean sendLocalChanges(Serializer s) throws IOException {
+        ContentResolver cr = mService.mContentResolver;
+        Uri uri = Events.CONTENT_URI.buildUpon()
+                .appendQueryParameter(Calendar.CALLER_IS_SYNCADAPTER, "true")
+                .build();
+
+        if (getSyncKey().equals("0")) {
+            return false;
+        }
+
+        try {
+            // TODO This just handles NEW events at the moment
+            // Cheap way to handle changes would be to delete/add
+            EntityIterator ei = EventsEntity.newEntityIterator(
+                    cr.query(uri, null, Events._SYNC_ID + " ISNULL", null, null), cr);
+            ContentValues cidValues = new ContentValues();
+            try {
+                boolean first = true;
+                while (ei.hasNext()) {
+                    Entity entity = ei.next();
+                    String clientId = null;
+                    // For each of these entities, create the change commands
+                    ContentValues entityValues = entity.getEntityValues();
+                    String serverId = entityValues.getAsString(Events._SYNC_ID);
+
+                    // Ignore exceptions (will have Events.ORIGINAL_EVENT)
+
+                    if (first) {
+                        s.start(Tags.SYNC_COMMANDS);
+                        userLog("Sending Calendar changes to the server");
+                        first = false;
+                    }
+                    if (serverId == null) {
+                        // This is a new event; create a clientId
+                        clientId = "new_" + mMailbox.mId + '_' + System.currentTimeMillis();
+                        userLog("Creating new event with clientId: ", clientId);
+                        s.start(Tags.SYNC_ADD).data(Tags.SYNC_CLIENT_ID, clientId);
+                        // And save it in the Event as the local id
+                        cidValues.put(Events._SYNC_LOCAL_ID, clientId);
+                        // TODO sync adapter!
+                        cr.update(ContentUris.
+                                withAppendedId(uri,
+                                        entityValues.getAsLong(Events._ID)),
+                                        cidValues, null, null);
+                    } else {
+                        if (entityValues.getAsInteger(Events.DELETED) == 1) {
+                            userLog("Deleting event with serverId: ", serverId);
+                            s.start(Tags.SYNC_DELETE).data(Tags.SYNC_SERVER_ID, serverId).end();
+                            mDeletedIdList.add(entityValues.getAsLong(Events._ID));
+                            continue;
+                        }
+                        userLog("Upsync change to event with serverId: " + serverId);
+                        s.start(Tags.SYNC_CHANGE).data(Tags.SYNC_SERVER_ID, serverId);
+                    }
+                    s.start(Tags.SYNC_APPLICATION_DATA);
+
+                    // Serialize for EAS here
+                    // Set uid with the client id we created
+                    // 1) Serialize the top-level event
+                    // 2) Serialize attendees and reminders from subvalues
+                    // 3) Look for exceptions and serialize with the top-level event
+                    if (entityValues.containsKey(Events.ALL_DAY)) {
+                        s.data(Tags.CALENDAR_ALL_DAY_EVENT,
+                                entityValues.getAsInteger(Events.ALL_DAY).toString());
+                    }
+                    if (entityValues.containsKey(Events.DTSTART)) {
+                        long startTime = entityValues.getAsLong(Events.DTSTART);
+                        s.data(Tags.CALENDAR_START_TIME,
+                                CalendarUtilities.millisToEasDateTime(startTime));
+                    }
+                    if (entityValues.containsKey(Events.DTEND)) {
+                        long endTime = entityValues.getAsLong(Events.DTEND);
+                        s.data(Tags.CALENDAR_END_TIME,
+                                CalendarUtilities.millisToEasDateTime(endTime));
+                    }
+                    s.data(Tags.CALENDAR_DTSTAMP,
+                            CalendarUtilities.millisToEasDateTime(System.currentTimeMillis()));
+
+                    // Our clientId (for new calendar items) is used for UID
+                    if (clientId != null) {
+                        s.data(Tags.CALENDAR_UID, clientId);
+                    }
+
+                    if (entityValues.containsKey(Events.EVENT_TIMEZONE)) {
+                        String timeZoneName = entityValues.getAsString(Events.EVENT_TIMEZONE);
+                        String x = CalendarUtilities.timeZoneToTZIString(timeZoneName);
+                        s.data(Tags.CALENDAR_TIME_ZONE, x);
+                    }
+                    if (entityValues.containsKey(Events.EVENT_LOCATION)) {
+                        s.data(Tags.CALENDAR_LOCATION,
+                                entityValues.getAsString(Events.EVENT_LOCATION));
+                    }
+                    if (entityValues.containsKey(Events.TITLE)) {
+                        s.data(Tags.CALENDAR_SUBJECT, entityValues.getAsString(Events.TITLE));
+                    }
+                    if (entityValues.containsKey(Events.DESCRIPTION)) {
+                        String desc = entityValues.getAsString(Events.DESCRIPTION);
+                        if (mService.mProtocolVersionDouble >= 12.0) {
+                            s.start(Tags.BASE_BODY);
+                            s.data(Tags.BASE_TYPE, "1");
+                            s.data(Tags.BASE_DATA, desc);
+                            s.end();
+                        } else {
+                            s.data(Tags.CALENDAR_BODY, desc);
+                        }
+                    }
+                    if (entityValues.containsKey(Events.ORGANIZER)) {
+                        s.data(Tags.CALENDAR_ORGANIZER_EMAIL,
+                                entityValues.getAsString(Events.ORGANIZER));
+                    }
+                    if (entityValues.containsKey(Events.VISIBILITY)) {
+                        s.data(Tags.CALENDAR_SENSITIVITY,
+                                decodeVisibility(entityValues.getAsInteger(Events.VISIBILITY)));
+                    }
+
+                    // Handle associated data EXCEPT for attendees, which have to be grouped
+                    ArrayList<NamedContentValues> subValues = entity.getSubValues();
+                    for (NamedContentValues ncv: subValues) {
+                        Uri ncvUri = ncv.uri;
+                        ContentValues ncvValues = ncv.values;
+                        if (ncvUri.equals(ExtendedProperties.CONTENT_URI)) {
+                            if (ncvValues.containsKey("uid")) {
+                                s.data(Tags.CALENDAR_UID, ncvValues.getAsString("uid"));
+                            }
+                            if (ncvValues.containsKey("dtstamp")) {
+                                s.data(Tags.CALENDAR_DTSTAMP, ncvValues.getAsString("dtstamp"));
+                            }
+                        } else if (ncvUri.equals(Reminders.CONTENT_URI)) {
+                            if (ncvValues.containsKey(Reminders.MINUTES)) {
+                                s.data(Tags.CALENDAR_REMINDER_MINS_BEFORE,
+                                        ncvValues.getAsString(Reminders.MINUTES));
+                            }
+                        }
+                    }
+
+                    // Handle attendee data here; keep track of organizer and stream it afterward
+                    boolean hasAttendees = false;
+                    String organizerName = null;
+                    for (NamedContentValues ncv: subValues) {
+                        Uri ncvUri = ncv.uri;
+                        ContentValues ncvValues = ncv.values;
+                        if (ncvUri.equals(Attendees.CONTENT_URI)) {
+                            if (ncvValues.containsKey(Attendees.ATTENDEE_RELATIONSHIP)) {
+                                int relationship =
+                                    ncvValues.getAsInteger(Attendees.ATTENDEE_RELATIONSHIP);
+                                // Organizer isn't among attendees in EAS
+                                if (relationship == Attendees.RELATIONSHIP_ORGANIZER) {
+                                    if (ncvValues.containsKey(Attendees.ATTENDEE_NAME)) {
+                                        // Remember this; we can't insert it into the stream in
+                                        // the middle of attendees
+                                        organizerName =
+                                            ncvValues.getAsString(Attendees.ATTENDEE_NAME);
+                                    }
+                                    continue;
+                                }
+                                if (!hasAttendees) {
+                                    s.start(Tags.CALENDAR_ATTENDEES);
+                                    hasAttendees = true;
+                                }
+                                s.start(Tags.CALENDAR_ATTENDEE);
+                                if (ncvValues.containsKey(Attendees.ATTENDEE_NAME)) {
+                                    s.data(Tags.CALENDAR_ATTENDEE_NAME,
+                                            ncvValues.getAsString(Attendees.ATTENDEE_NAME));
+                                }
+                                if (ncvValues.containsKey(Attendees.ATTENDEE_EMAIL)) {
+                                    s.data(Tags.CALENDAR_ATTENDEE_EMAIL,
+                                            ncvValues.getAsString(Attendees.ATTENDEE_EMAIL));
+                                }
+                                s.data(Tags.CALENDAR_ATTENDEE_TYPE, "1"); // Required
+                                s.end(); // Attendee
+                            }
+                            // If there's no relationship, we can't create this for EAS
+                        }
+                    }
+                    if (hasAttendees) {
+                        s.end();  // Attendees
+                    }
+                    if (organizerName != null) {
+                        s.data(Tags.CALENDAR_ORGANIZER_NAME, organizerName);
+                    }
+//                    case Tags.CALENDAR_CATEGORIES:
+//                        categoriesParser(ops);
+//                        break;
+//                    case Tags.CALENDAR_EXCEPTIONS:
+//                        exceptionsParser(ops, cv);
+//                        break;
+//                    case Tags.CALENDAR_RECURRENCE:
+//                        String rrule = recurrenceParser(ops);
+//                        if (rrule != null) {
+//                            cv.put(Events.RRULE, rrule);
+//                        }
+//                        break;
+//                    case Tags.CALENDAR_MEETING_STATUS:
+//                        // TODO Try to fit this into Calendar scheme
+//                        ops.newExtendedProperty("meeting_status", getValue());
+//                        break;
+//                    case Tags.CALENDAR_BUSY_STATUS:
+//                        // TODO Try to fit this into Calendar scheme
+//                        ops.newExtendedProperty("busy_status", getValue());
+//                        break;
+
+                    s.end().end(); // ApplicationData & Change
+                    mUpdatedIdList.add(entityValues.getAsLong(Events._ID));
+                }
+                if (!first) {
+                    s.end(); // Commands
+                }
+            } finally {
+                ei.close();
+            }
+        } catch (RemoteException e) {
+            Log.e(TAG, "Could not read dirty events.");
+        }
+
         return false;
     }
 }
diff --git a/src/com/android/exchange/adapter/FolderSyncParser.java b/src/com/android/exchange/adapter/FolderSyncParser.java
index 337e329..bd3aaf7 100644
--- a/src/com/android/exchange/adapter/FolderSyncParser.java
+++ b/src/com/android/exchange/adapter/FolderSyncParser.java
@@ -33,7 +33,10 @@
 import android.content.ContentValues;
 import android.content.OperationApplicationException;
 import android.database.Cursor;
+import android.net.Uri;
 import android.os.RemoteException;
+import android.provider.Calendar.Calendars;
+import android.text.format.Time;
 
 import java.io.IOException;
 import java.io.InputStream;
@@ -239,7 +242,28 @@
                     break;
                 case CALENDAR_TYPE:
                     m.mType = Mailbox.TYPE_CALENDAR;
-                    // For now, no sync, since it's not yet implemented
+                    m.mSyncInterval = mAccount.mSyncInterval;
+
+                    // Create a Calendar object
+                    ContentValues cv = new ContentValues();
+                    // TODO How will this change if the user changes his account display name?
+                    cv.put(Calendars.DISPLAY_NAME, mAccount.mDisplayName);
+                    cv.put(Calendars._SYNC_ACCOUNT, mAccount.mEmailAddress);
+                    cv.put(Calendars._SYNC_ACCOUNT_TYPE, Eas.ACCOUNT_MANAGER_TYPE);
+                    cv.put(Calendars.SYNC_EVENTS, 1);
+                    cv.put(Calendars.SELECTED, 1);
+                    cv.put(Calendars.HIDDEN, 0);
+                    // TODO Find out how to set color!!
+                    cv.put(Calendars.COLOR, -14069085 /* blue */);
+                    cv.put(Calendars.TIMEZONE, Time.getCurrentTimezone());
+                    cv.put(Calendars.ACCESS_LEVEL, Calendars.OWNER_ACCESS);
+                    cv.put(Calendars.OWNER_ACCOUNT, mAccount.mEmailAddress);
+
+                    Uri uri = mService.mContentResolver.insert(Calendars.CONTENT_URI, cv);
+                    // We save the id of the calendar into mSyncStatus
+                    if (uri != null) {
+                        m.mSyncStatus = uri.getPathSegments().get(1);
+                    }
                     break;
             }
 
diff --git a/src/com/android/exchange/adapter/Parser.java b/src/com/android/exchange/adapter/Parser.java
index 01051c0..bd767b3 100644
--- a/src/com/android/exchange/adapter/Parser.java
+++ b/src/com/android/exchange/adapter/Parser.java
@@ -38,8 +38,6 @@
  */
 public abstract class Parser {
 
-    private static final String TAG = "EasParser";
-
     // The following constants are Wbxml standard
     public static final int START_DOCUMENT = 0;
     public static final int DONE = 1;
@@ -52,6 +50,7 @@
     private static final int EOF_BYTE = -1;
     private boolean logging = false;
     private boolean capture = false;
+    private String logTag = "EAS Parser";
 
     private ArrayList<Integer> captureArray;
 
@@ -158,6 +157,16 @@
     }
 
     /**
+     * Set the tag used for logging.  When debugging is on, every token is logged (Log.v) to
+     * the console.
+     *
+     * @param val the logging tag
+     */
+    public void setLoggingTag(String val) {
+        logTag = val;
+    }
+
+    /**
      * Turns on data capture; this is used to create test streams that represent "live" data and
      * can be used against the various parsers.
      */
@@ -313,9 +322,9 @@
         if (cr > 0) {
             str = str.substring(0, cr);
         }
-        Log.v(TAG, str);
+        Log.v(logTag, str);
         if (Eas.FILE_LOG) {
-            FileLogger.log(TAG, str);
+            FileLogger.log(logTag, str);
         }
     }
 
diff --git a/src/com/android/exchange/utility/CalendarUtilities.java b/src/com/android/exchange/utility/CalendarUtilities.java
new file mode 100644
index 0000000..1007d60
--- /dev/null
+++ b/src/com/android/exchange/utility/CalendarUtilities.java
@@ -0,0 +1,429 @@
+/*
+ * Copyright (C) 2010 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.exchange.utility;
+
+import com.android.exchange.Eas;
+
+import org.bouncycastle.util.encoders.Base64;
+
+import android.util.Log;
+
+import java.util.Calendar;
+import java.util.Date;
+import java.util.GregorianCalendar;
+import java.util.HashMap;
+import java.util.TimeZone;
+
+public class CalendarUtilities {
+    // NOTE: Most definitions in this class are have package visibility for testing purposes
+    private static final String TAG = "CalendarUtility";
+
+    // Time related convenience constants, in milliseconds
+    static final int SECONDS = 1000;
+    static final int MINUTES = SECONDS*60;
+    static final int HOURS = MINUTES*60;
+
+    // NOTE All Microsoft data structures are little endian
+
+    // The following constants relate to standard Microsoft data sizes
+    // For documentation, see http://msdn.microsoft.com/en-us/library/aa505945.aspx
+    static final int MSFT_LONG_SIZE = 4;
+    static final int MSFT_WCHAR_SIZE = 2;
+    static final int MSFT_WORD_SIZE = 2;
+
+    // The following constants relate to Microsoft's SYSTEMTIME structure
+    // For documentation, see: http://msdn.microsoft.com/en-us/library/ms724950(VS.85).aspx?ppud=4
+
+    static final int MSFT_SYSTEMTIME_YEAR = 0 * MSFT_WORD_SIZE;
+    static final int MSFT_SYSTEMTIME_MONTH = 1 * MSFT_WORD_SIZE;
+    static final int MSFT_SYSTEMTIME_DAY_OF_WEEK = 2 * MSFT_WORD_SIZE;
+    static final int MSFT_SYSTEMTIME_DAY = 3 * MSFT_WORD_SIZE;
+    static final int MSFT_SYSTEMTIME_HOUR = 4 * MSFT_WORD_SIZE;
+    static final int MSFT_SYSTEMTIME_MINUTE = 5 * MSFT_WORD_SIZE;
+    //static final int MSFT_SYSTEMTIME_SECONDS = 6 * MSFT_WORD_SIZE;
+    //static final int MSFT_SYSTEMTIME_MILLIS = 7 * MSFT_WORD_SIZE;
+    static final int MSFT_SYSTEMTIME_SIZE = 8*MSFT_WORD_SIZE;
+
+    // The following constants relate to Microsoft's TIME_ZONE_INFORMATION structure
+    // For documentation, see http://msdn.microsoft.com/en-us/library/ms725481(VS.85).aspx
+    static final int MSFT_TIME_ZONE_BIAS_OFFSET = 0;
+    static final int MSFT_TIME_ZONE_STANDARD_NAME_OFFSET =
+        MSFT_TIME_ZONE_BIAS_OFFSET + MSFT_LONG_SIZE;
+    static final int MSFT_TIME_ZONE_STANDARD_DATE_OFFSET =
+        MSFT_TIME_ZONE_STANDARD_NAME_OFFSET + (MSFT_WCHAR_SIZE*32);
+    static final int MSFT_TIME_ZONE_STANDARD_BIAS_OFFSET =
+        MSFT_TIME_ZONE_STANDARD_DATE_OFFSET + MSFT_SYSTEMTIME_SIZE;
+    static final int MSFT_TIME_ZONE_DAYLIGHT_NAME_OFFSET =
+        MSFT_TIME_ZONE_STANDARD_BIAS_OFFSET + MSFT_LONG_SIZE;
+    static final int MSFT_TIME_ZONE_DAYLIGHT_DATE_OFFSET =
+        MSFT_TIME_ZONE_DAYLIGHT_NAME_OFFSET + (MSFT_WCHAR_SIZE*32);
+    static final int MSFT_TIME_ZONE_DAYLIGHT_BIAS_OFFSET =
+        MSFT_TIME_ZONE_DAYLIGHT_DATE_OFFSET + MSFT_SYSTEMTIME_SIZE;
+    static final int MSFT_TIME_ZONE_SIZE =
+        MSFT_TIME_ZONE_DAYLIGHT_BIAS_OFFSET + MSFT_LONG_SIZE;
+
+    // TimeZone cache; we parse/decode as little as possible, because the process is quite slow
+    private static HashMap<String, TimeZone> sTimeZoneCache = new HashMap<String, TimeZone>();
+
+    // There is no type 4 (thus, the "")
+    static final String[] sTypeToFreq =
+        new String[] {"DAILY", "WEEKLY", "MONTHLY", "MONTHLY", "", "YEARLY", "YEARLY"};
+
+    static final String[] sDayTokens =
+        new String[] {"SU", "MO", "TU", "WE", "TH", "FR", "SA"};
+
+    static final String[] sTwoCharacterNumbers =
+        new String[] {"00", "01", "02", "03", "04", "05", "06", "07", "08", "09", "10", "11", "12"};
+
+    // Return a 4-byte long from a byte array (little endian)
+    static int getLong(byte[] bytes, int offset) {
+        return (bytes[offset++] & 0xFF) | ((bytes[offset++] & 0xFF) << 8) |
+        ((bytes[offset++] & 0xFF) << 16) | ((bytes[offset] & 0xFF) << 24);
+    }
+
+    // Put a 4-byte long into a byte array (little endian)
+    static void setLong(byte[] bytes, int offset, int value) {
+        bytes[offset++] = (byte) (value & 0xFF);
+        bytes[offset++] = (byte) ((value >> 8) & 0xFF);
+        bytes[offset++] = (byte) ((value >> 16) & 0xFF);
+        bytes[offset] = (byte) ((value >> 24) & 0xFF);
+    }
+
+    // Return a 2-byte word from a byte array (little endian)
+    static int getWord(byte[] bytes, int offset) {
+        return (bytes[offset++] & 0xFF) | ((bytes[offset] & 0xFF) << 8);
+    }
+
+    // Put a 2-byte word into a byte array (little endian)
+    static void setWord(byte[] bytes, int offset, int value) {
+        bytes[offset++] = (byte) (value & 0xFF);
+        bytes[offset] = (byte) ((value >> 8) & 0xFF);
+    }
+
+    // Internal structure for storing a time zone date from a SYSTEMTIME structure
+    // This date represents either the start or the end time for DST
+    static class TimeZoneDate {
+        String year;
+        int month;
+        int dayOfWeek;
+        int day;
+        int time;
+        int hour;
+        int minute;
+    }
+
+    // Build a TimeZoneDate structure from a SYSTEMTIME within a byte array at a given offset
+    static TimeZoneDate getTimeZoneDateFromSystemTime(byte[] bytes, int offset) {
+        TimeZoneDate tzd = new TimeZoneDate();
+
+        // MSFT year is an int; TimeZone is a String
+        int num = getWord(bytes, offset + MSFT_SYSTEMTIME_YEAR);
+        tzd.year = Integer.toString(num);
+
+        // MSFT month = 0 means no daylight time
+        // MSFT months are 1 based; TimeZone is 0 based
+        num = getWord(bytes, offset + MSFT_SYSTEMTIME_MONTH);
+        if (num == 0) {
+            return null;
+        } else {
+            tzd.month = num -1;
+        }
+
+        // MSFT day of week starts w/ Sunday = 0; TimeZone starts w/ Sunday = 1
+        tzd.dayOfWeek = getWord(bytes, offset + MSFT_SYSTEMTIME_DAY_OF_WEEK) + 1;
+
+        // Get the "day" in TimeZone format
+        num = getWord(bytes, offset + MSFT_SYSTEMTIME_DAY);
+        // 5 means "last" in MSFT land; for TimeZone, it's -1
+        if (num == 5) {
+            tzd.day = -1;
+        } else {
+            tzd.day = num;
+        }
+
+        // Turn hours/minutes into ms from midnight (per TimeZone)
+        int hour = getWord(bytes, offset + MSFT_SYSTEMTIME_HOUR);
+        tzd.hour = hour;
+        int minute = getWord(bytes, offset + MSFT_SYSTEMTIME_MINUTE);
+        tzd.minute = minute;
+        tzd.time = (hour*HOURS) + (minute*MINUTES);
+
+        return tzd;
+    }
+
+    // Return a String from within a byte array at the given offset with max characters
+    // Unused for now, but might be helpful for debugging
+    //    String getString(byte[] bytes, int offset, int max) {
+    //    	StringBuilder sb = new StringBuilder();
+    //    	while (max-- > 0) {
+    //    		int b = bytes[offset];
+    //    		if (b == 0) break;
+    //    		sb.append((char)b);
+    //    		offset += 2;
+    //    	}
+    //    	return sb.toString();
+    //    }
+
+    /**
+     * Build a GregorianCalendar, based on a time zone and TimeZoneDate.
+     * @param timeZone the time zone we're checking
+     * @param tzd the TimeZoneDate we're interested in
+     * @return a GregorianCalendar with the given time zone and date
+     */
+    static GregorianCalendar getCheckCalendar(TimeZone timeZone, TimeZoneDate tzd) {
+        GregorianCalendar testCalendar = new GregorianCalendar(timeZone);
+        testCalendar.set(GregorianCalendar.YEAR, 2009);
+        testCalendar.set(GregorianCalendar.MONTH, tzd.month);
+        testCalendar.set(GregorianCalendar.DAY_OF_WEEK, tzd.dayOfWeek);
+        testCalendar.set(GregorianCalendar.DAY_OF_WEEK_IN_MONTH, tzd.day);
+        testCalendar.set(GregorianCalendar.HOUR_OF_DAY, tzd.hour);
+        testCalendar.set(GregorianCalendar.MINUTE, tzd.minute);
+        return testCalendar;
+    }
+
+    /**
+     * Given a String as directly read from EAS, returns a TimeZone corresponding to that String
+     * @param timeZoneString the String read from the server
+     * @return the TimeZone, or TimeZone.getDefault() if not found
+     */
+    static public TimeZone parseTimeZone(String timeZoneString) {
+        // If we have this time zone cached, use that value and return
+        TimeZone timeZone = sTimeZoneCache.get(timeZoneString);
+        if (timeZone != null) {
+            if (Eas.USER_LOG) {
+                Log.d(TAG, "TimeZone " + timeZone.getID() + " in cache: " + timeZone.getDisplayName());
+            }
+            return timeZone;
+        }
+
+        // First, we need to decode the base64 string
+        byte[] timeZoneBytes = Base64.decode(timeZoneString);
+
+        // Then, we get the bias (similar to a rawOffset); for TimeZone, we need ms
+        // but EAS gives us minutes, so do the conversion.  Note that EAS is the bias that's added
+        // to the time zone to reach UTC; our library uses the time from UTC to our time zone, so
+        // we need to change the sign
+        int bias = -1 * getLong(timeZoneBytes, MSFT_TIME_ZONE_BIAS_OFFSET) * MINUTES;
+
+        // Get all of the time zones with the bias as a rawOffset; if there aren't any, we return
+        // the default time zone
+        String[] zoneIds = TimeZone.getAvailableIDs(bias);
+        if (zoneIds.length > 0) {
+            // Try to find an existing TimeZone from the data provided by EAS
+            // We start by pulling out the date that standard time begins
+            TimeZoneDate dstEnd =
+                getTimeZoneDateFromSystemTime(timeZoneBytes, MSFT_TIME_ZONE_STANDARD_DATE_OFFSET);
+            if (dstEnd == null) {
+                // In this case, there is no daylight savings time, so the only interesting data
+                // is the offset, and we know that all of the zoneId's match; we'll take the first
+                timeZone = TimeZone.getTimeZone(zoneIds[0]);
+                String dn = timeZone.getDisplayName();
+                sTimeZoneCache.put(timeZoneString, timeZone);
+                if (Eas.USER_LOG) {
+                    Log.d(TAG, "TimeZone without DST found by offset: " + dn);
+                }
+                return timeZone;
+            } else {
+                TimeZoneDate dstStart =	getTimeZoneDateFromSystemTime(timeZoneBytes,
+                        MSFT_TIME_ZONE_DAYLIGHT_DATE_OFFSET);
+                // See comment above for bias...
+                long dstSavings =
+                    -1 * getLong(timeZoneBytes, MSFT_TIME_ZONE_DAYLIGHT_BIAS_OFFSET) * 60*SECONDS;
+
+                // We'll go through each time zone to find one with the same DST transitions and
+                // savings length
+                for (String zoneId: zoneIds) {
+                    // Get the TimeZone using the zoneId
+                    timeZone = TimeZone.getTimeZone(zoneId);
+
+                    // Our strategy here is to check just before and just after the transitions
+                    // and see whether the check for daylight time matches the expectation
+                    // If both transitions match, then we have a match for the offset and start/end
+                    // of dst.  That's the best we can do for now, since there's no other info
+                    // provided by EAS (i.e. we can't get dynamic transitions, etc.)
+
+                    // Check start DST transition
+                    GregorianCalendar testCalendar = getCheckCalendar(timeZone, dstStart);
+                    testCalendar.add(GregorianCalendar.MINUTE, -1);
+                    Date before = testCalendar.getTime();
+                    testCalendar.add(GregorianCalendar.MINUTE, 2);
+                    Date after = testCalendar.getTime();
+                    if (timeZone.inDaylightTime(before)) continue;
+                    if (!timeZone.inDaylightTime(after)) continue;
+
+                    // Check end DST transition
+                    testCalendar = getCheckCalendar(timeZone, dstEnd);
+                    testCalendar.add(GregorianCalendar.HOUR, -2);
+                    before = testCalendar.getTime();
+                    testCalendar.add(GregorianCalendar.HOUR, 2);
+                    after = testCalendar.getTime();
+                    if (!timeZone.inDaylightTime(before)) continue;
+                    if (timeZone.inDaylightTime(after)) continue;
+
+                    // Check that the savings are the same
+                    if (dstSavings != timeZone.getDSTSavings()) continue;
+
+                    // If we're here, it's the right time zone, modulo dynamic DST
+                    String dn = timeZone.getDisplayName();
+                    sTimeZoneCache.put(timeZoneString, timeZone);
+                    if (Eas.USER_LOG) {
+                        Log.d(TAG, "TimeZone found by rules: " + dn);
+                    }
+                    return timeZone;
+                }
+            }
+        }
+        // If we don't find a match, we just return the current TimeZone.  In theory, this
+        // shouldn't be happening...
+        Log.w(TAG, "TimeZone not found with bias = " + bias + ", using default.");
+        return TimeZone.getDefault();
+    }
+
+    /**
+     * Generate a Base64 representation of a MSFT TIME_ZONE_INFORMATION structure from a TimeZone
+     * ID that might be found in an Event.  For now, we'll just use the standard bias, and we'll
+     * tackle DST later
+     * @param name the name of the TimeZone
+     * @return the Base64 String representing a Microsoft TIME_ZONE_INFORMATION element
+     */
+    static public String timeZoneToTZIString(String name) {
+        // TODO Handle DST (ugh)
+        TimeZone tz = TimeZone.getTimeZone(name);
+        byte[] tziBytes = new byte[MSFT_TIME_ZONE_SIZE];
+
+        int standardBias = - tz.getRawOffset();
+        standardBias /= 60*SECONDS;
+        setLong(tziBytes, MSFT_TIME_ZONE_BIAS_OFFSET, standardBias);
+
+        byte[] tziEncodedBytes = Base64.encode(tziBytes);
+        return new String(tziEncodedBytes);
+    }
+
+    /**
+     * Generate a time in milliseconds from a date string that represents a date/time in GMT
+     * @param DateTime string from Exchange server
+     * @return the time in milliseconds (since Jan 1, 1970)
+     */
+     static public long parseDateTime(String date) {
+        // Format for calendar date strings is 20090211T180303Z
+        GregorianCalendar cal = new GregorianCalendar(Integer.parseInt(date.substring(0, 4)),
+                Integer.parseInt(date.substring(4, 6)) - 1, Integer.parseInt(date.substring(6, 8)),
+                Integer.parseInt(date.substring(9, 11)), Integer.parseInt(date.substring(11, 13)),
+                Integer.parseInt(date.substring(13, 15)));
+        cal.setTimeZone(TimeZone.getTimeZone("GMT"));
+        return cal.getTimeInMillis();
+    }
+
+     static String formatTwo(int num) {
+         if (num <= 12) {
+             return sTwoCharacterNumbers[num];
+         } else
+             return Integer.toString(num);
+     }
+
+     static public String millisToEasDateTime(long millis) {
+         StringBuilder sb = new StringBuilder();
+         GregorianCalendar cal = new GregorianCalendar(TimeZone.getTimeZone("GMT"));
+         cal.setTimeInMillis(millis);
+         sb.append(cal.get(Calendar.YEAR));
+         sb.append(formatTwo(cal.get(Calendar.MONTH) + 1));
+         sb.append(formatTwo(cal.get(Calendar.DAY_OF_MONTH)));
+         sb.append('T');
+         sb.append(formatTwo(cal.get(Calendar.HOUR_OF_DAY)));
+         sb.append(formatTwo(cal.get(Calendar.MINUTE)));
+         sb.append(formatTwo(cal.get(Calendar.SECOND)));
+         sb.append('Z');
+         return sb.toString();
+     }
+
+     static void addByDay(StringBuilder rrule, int dow, int wom) {
+        rrule.append(";BYDAY=");
+        boolean addComma = false;
+        for (int i = 0; i < 7; i++) {
+            if ((dow & 1) == 1) {
+                if (addComma) {
+                    rrule.append(',');
+                }
+                if (wom > 0) {
+                    // 5 = last week -> -1
+                    // So -1SU = last sunday
+                    rrule.append(wom == 5 ? -1 : wom);
+                }
+                rrule.append(sDayTokens[i]);
+                addComma = true;
+            }
+            dow >>= 1;
+        }
+    }
+
+    static void addByMonthDay(StringBuilder rrule, int dom) {
+        // 127 means last day of the month
+        if (dom == 127) {
+            dom = -1;
+        }
+        rrule.append(";BYMONTHDAY=" + dom);
+    }
+
+    static public String rruleFromRecurrence(int type, int occurrences, int interval, int dow,
+            int dom, int wom, int moy, String until) {
+        StringBuilder rrule = new StringBuilder("FREQ=" + sTypeToFreq[type]);
+
+        // INTERVAL and COUNT
+        if (interval > 0) {
+            rrule.append(";INTERVAL=" + interval);
+        }
+        if (occurrences > 0) {
+            rrule.append(";COUNT=" + occurrences);
+        }
+
+        // Days, weeks, months, etc.
+        switch(type) {
+            case 0: // DAILY
+            case 1: // WEEKLY
+                if (dow > 0) addByDay(rrule, dow, -1);
+                break;
+            case 2: // MONTHLY
+                if (dom > 0) addByMonthDay(rrule, dom);
+                break;
+            case 3: // MONTHLY (on the nth day)
+                if (dow > 0) addByDay(rrule, dow, wom);
+                break;
+            case 5: // YEARLY
+                if (dom > 0) addByMonthDay(rrule, dom);
+                if (moy > 0) {
+                    // TODO MAKE SURE WE'RE 1 BASED
+                    rrule.append(";BYMONTH=" + moy);
+                }
+                break;
+            case 6: // YEARLY (on the nth day)
+                if (dow > 0) addByDay(rrule, dow, wom);
+                if (moy > 0) addByMonthDay(rrule, dow);
+                break;
+            default:
+                break;
+        }
+
+        // UNTIL comes last
+        // TODO Add UNTIL code
+        if (until != null) {
+            // *** until probably needs reformatting
+            //rrule.append(";UNTIL=" + until);
+        }
+
+        return rrule.toString();
+    }
+}
diff --git a/tests/src/com/android/exchange/EasSyncServiceTests.java b/tests/src/com/android/exchange/EasSyncServiceTests.java
index 45f4882..d4372a5 100644
--- a/tests/src/com/android/exchange/EasSyncServiceTests.java
+++ b/tests/src/com/android/exchange/EasSyncServiceTests.java
@@ -73,6 +73,4 @@
             }
         }
     }
-
-
 }
diff --git a/tests/src/com/android/exchange/adapter/CalendarSyncAdapterTests.java b/tests/src/com/android/exchange/adapter/CalendarSyncAdapterTests.java
new file mode 100644
index 0000000..7f380d5
--- /dev/null
+++ b/tests/src/com/android/exchange/adapter/CalendarSyncAdapterTests.java
@@ -0,0 +1,24 @@
+/*
+ * Copyright (C) 2010 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.exchange.adapter;
+
+public class CalendarSyncAdapterTests extends SyncAdapterTestCase {
+
+    public CalendarSyncAdapterTests() {
+        super();
+    }
+}
diff --git a/tests/src/com/android/exchange/adapter/EmailSyncAdapterTests.java b/tests/src/com/android/exchange/adapter/EmailSyncAdapterTests.java
index e378b03..5f363b8 100644
--- a/tests/src/com/android/exchange/adapter/EmailSyncAdapterTests.java
+++ b/tests/src/com/android/exchange/adapter/EmailSyncAdapterTests.java
@@ -1,5 +1,5 @@
 /*
- * Copyright (C) 2009 The Android Open Source Project
+ * Copyright (C) 2010 The Android Open Source Project
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -32,16 +32,14 @@
 import android.content.ContentUris;
 import android.content.ContentValues;
 import android.content.Context;
-import android.test.ProviderTestCase2;
 
 import java.io.ByteArrayInputStream;
 import java.io.IOException;
-import java.io.InputStream;
 import java.util.ArrayList;
 import java.util.GregorianCalendar;
 import java.util.TimeZone;
 
-public class EmailSyncAdapterTests extends ProviderTestCase2<EmailProvider> {
+public class EmailSyncAdapterTests extends SyncAdapterTestCase {
 
     EmailProvider mProvider;
     Context mMockContext;
@@ -52,54 +50,7 @@
     EasEmailSyncParser mSyncParser;
 
     public EmailSyncAdapterTests() {
-        super(EmailProvider.class, EmailProvider.EMAIL_AUTHORITY);
-    }
-
-    @Override
-    public void setUp() throws Exception {
-        super.setUp();
-        mMockContext = getMockContext();
-        mMockResolver = mMockContext.getContentResolver();
-    }
-
-    @Override
-    public void tearDown() throws Exception {
-        super.tearDown();
-    }
-
-    /**
-     * Create and return a short, simple InputStream that has at least four bytes, which is all
-     * that's required to initialize an EasParser (the parent class of EasEmailSyncParser)
-     * @return the InputStream
-     */
-    public InputStream getTestInputStream() {
-        return new ByteArrayInputStream(new byte[] {0, 0, 0, 0, 0});
-    }
-
-    EasSyncService getTestService() {
-        Account account = new Account();
-        account.mId = -1;
-        Mailbox mailbox = new Mailbox();
-        mailbox.mId = -1;
-        EasSyncService service = new EasSyncService();
-        service.mContext = mMockContext;
-        service.mMailbox = mailbox;
-        service.mAccount = account;
-        return service;
-    }
-
-    EasSyncService getTestService(Account account, Mailbox mailbox) {
-        EasSyncService service = new EasSyncService();
-        service.mContext = mMockContext;
-        service.mMailbox = mailbox;
-        service.mAccount = account;
-        return service;
-    }
-
-    EmailSyncAdapter getTestSyncAdapter() {
-        EasSyncService service = getTestService();
-        EmailSyncAdapter adapter = new EmailSyncAdapter(service.mMailbox, service);
-        return adapter;
+        super();
     }
 
     /**
diff --git a/tests/src/com/android/exchange/adapter/SyncAdapterTestCase.java b/tests/src/com/android/exchange/adapter/SyncAdapterTestCase.java
new file mode 100644
index 0000000..311b550
--- /dev/null
+++ b/tests/src/com/android/exchange/adapter/SyncAdapterTestCase.java
@@ -0,0 +1,93 @@
+/*
+ * Copyright (C) 2010 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.exchange.adapter;
+
+import com.android.email.provider.EmailProvider;
+import com.android.email.provider.EmailContent.Account;
+import com.android.email.provider.EmailContent.Mailbox;
+import com.android.exchange.EasSyncService;
+import com.android.exchange.adapter.EmailSyncAdapter.EasEmailSyncParser;
+
+import android.content.ContentResolver;
+import android.content.Context;
+import android.test.ProviderTestCase2;
+
+import java.io.ByteArrayInputStream;
+import java.io.InputStream;
+
+public class SyncAdapterTestCase extends ProviderTestCase2<EmailProvider> {
+
+    EmailProvider mProvider;
+    Context mMockContext;
+    ContentResolver mMockResolver;
+    Mailbox mMailbox;
+    Account mAccount;
+    EmailSyncAdapter mSyncAdapter;
+    EasEmailSyncParser mSyncParser;
+
+    public SyncAdapterTestCase() {
+        super(EmailProvider.class, EmailProvider.EMAIL_AUTHORITY);
+    }
+
+    @Override
+    public void setUp() throws Exception {
+        super.setUp();
+        mMockContext = getMockContext();
+        mMockResolver = mMockContext.getContentResolver();
+    }
+
+    @Override
+    public void tearDown() throws Exception {
+        super.tearDown();
+    }
+
+    /**
+     * Create and return a short, simple InputStream that has at least four bytes, which is all
+     * that's required to initialize an EasParser (the parent class of EasEmailSyncParser)
+     * @return the InputStream
+     */
+    public InputStream getTestInputStream() {
+        return new ByteArrayInputStream(new byte[] {0, 0, 0, 0, 0});
+    }
+
+    EasSyncService getTestService() {
+        Account account = new Account();
+        account.mId = -1;
+        Mailbox mailbox = new Mailbox();
+        mailbox.mId = -1;
+        EasSyncService service = new EasSyncService();
+        service.mContext = mMockContext;
+        service.mMailbox = mailbox;
+        service.mAccount = account;
+        return service;
+    }
+
+    EasSyncService getTestService(Account account, Mailbox mailbox) {
+        EasSyncService service = new EasSyncService();
+        service.mContext = mMockContext;
+        service.mMailbox = mailbox;
+        service.mAccount = account;
+        return service;
+    }
+
+    EmailSyncAdapter getTestSyncAdapter() {
+        EasSyncService service = getTestService();
+        EmailSyncAdapter adapter = new EmailSyncAdapter(service.mMailbox, service);
+        return adapter;
+    }
+
+}
diff --git a/tests/src/com/android/exchange/utility/CalendarUtilitiesTests.java b/tests/src/com/android/exchange/utility/CalendarUtilitiesTests.java
new file mode 100644
index 0000000..524b72f
--- /dev/null
+++ b/tests/src/com/android/exchange/utility/CalendarUtilitiesTests.java
@@ -0,0 +1,85 @@
+/*
+ * Copyright (C) 2010 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.exchange.utility;
+
+import android.test.AndroidTestCase;
+
+import java.util.TimeZone;
+
+public class CalendarUtilitiesTests extends AndroidTestCase {
+
+    // Some prebuilt time zones, Base64 encoded (as they arrive from EAS)
+    private static final String ISRAEL_STANDARD_TIME =
+        "iP///ygARwBNAFQAKwAwADIAOgAwADAAKQAgAEoAZQByAHUAcwBhAGwAZQBtAAAAAAAAAAAAAAAAAAAAAAAA" +
+        "AAAAAAAAAAkAAAAFAAIAAAAAAAAAAAAAACgARwBNAFQAKwAwADIAOgAwADAAKQAgAEoAZQByAHUAcwBhAGwA" +
+        "ZQBtAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMABQAFAAIAAAAAAAAAxP///w==";
+    private static final String INDIA_STANDARD_TIME =
+        "tv7//0kAbgBkAGkAYQAgAFMAdABhAG4AZABhAHIAZAAgAFQAaQBtAGUAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" +
+        "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEkAbgBkAGkAYQAgAEQAYQB5AGwAaQBnAGgAdAAgAFQAaQBtAGUA" +
+        "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==";
+    private static final String PACIFIC_STANDARD_TIME =
+        "4AEAAFAAYQBjAGkAZgBpAGMAIABTAHQAYQBuAGQAYQByAGQAIABUAGkAbQBlAAAAAAAAAAAAAAAAAAAAAAAA" +
+        "AAAAAAAAAAsAAAABAAIAAAAAAAAAAAAAAFAAYQBjAGkAZgBpAGMAIABEAGEAeQBsAGkAZwBoAHQAIABUAGkA" +
+        "bQBlAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMAAAACAAIAAAAAAAAAxP///w==";
+
+    public void testGetSet() {
+        byte[] bytes = new byte[] {0, 1, 2, 3, 4, 5, 6, 7};
+
+        // First, check that getWord/Long are properly little endian
+        assertEquals(0x0100, CalendarUtilities.getWord(bytes, 0));
+        assertEquals(0x03020100, CalendarUtilities.getLong(bytes, 0));
+        assertEquals(0x07060504, CalendarUtilities.getLong(bytes, 4));
+
+        // Set some words and longs
+        CalendarUtilities.setWord(bytes, 0, 0xDEAD);
+        CalendarUtilities.setLong(bytes, 2, 0xBEEFBEEF);
+        CalendarUtilities.setWord(bytes, 6, 0xCEDE);
+
+        // Retrieve them
+        assertEquals(0xDEAD, CalendarUtilities.getWord(bytes, 0));
+        assertEquals(0xBEEFBEEF, CalendarUtilities.getLong(bytes, 2));
+        assertEquals(0xCEDE, CalendarUtilities.getWord(bytes, 6));
+    }
+
+    public void testParseTimeZoneEndToEnd() {
+        TimeZone tz = CalendarUtilities.parseTimeZone(PACIFIC_STANDARD_TIME);
+        assertEquals("Pacific Standard Time", tz.getDisplayName());
+        tz = CalendarUtilities.parseTimeZone(INDIA_STANDARD_TIME);
+        assertEquals("India Standard Time", tz.getDisplayName());
+        tz = CalendarUtilities.parseTimeZone(ISRAEL_STANDARD_TIME);
+        assertEquals("Israel Standard Time", tz.getDisplayName());
+    }
+
+// TODO In progress
+//    public void testParseTimeZone() {
+//        GregorianCalendar cal = getTestCalendar(parsedTimeZone, dstStart);
+//        cal.add(GregorianCalendar.MINUTE, -1);
+//        Date b = cal.getTime();
+//        cal.add(GregorianCalendar.MINUTE, 2);
+//        Date a = cal.getTime();
+//        if (parsedTimeZone.inDaylightTime(b) || !parsedTimeZone.inDaylightTime(a)) {
+//            userLog("ERROR IN TIME ZONE CONTROL!");
+//        }
+//        cal = getTestCalendar(parsedTimeZone, dstEnd);
+//        cal.add(GregorianCalendar.HOUR, -2);
+//        b = cal.getTime();
+//        cal.add(GregorianCalendar.HOUR, 2);
+//        a = cal.getTime();
+//        if (!parsedTimeZone.inDaylightTime(b)) userLog("ERROR IN TIME ZONE CONTROL");
+//        if (parsedTimeZone.inDaylightTime(a)) userLog("ERROR IN TIME ZONE CONTROL!");
+//    }
+}