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!");
+// }
+}