Send email related to event exceptions

* We weren't sending out appropriate emails for exceptions and other
  event updates
* Write exception specific ics file code in CalendarUtilities (in
  the existing ics file creator)
* Send appropriate Update: subject for updated events/exceptions
* Compose simple message text consisting of:
  When: <time>
  Where: <location>
* Prepend message text for exceptions to indicate that the message
  relates to a particular instance of the event:
  This event has been canceled for: <date>
  The details of this event have been changed for: <date>
* New strings were added in CL#44141
* Updated CalendarUtilities tests

Bug: 2501270
Change-Id: I920de8120bc56d5bd565cbde26ff4807be41579f
diff --git a/src/com/android/exchange/adapter/CalendarSyncAdapter.java b/src/com/android/exchange/adapter/CalendarSyncAdapter.java
index 99f57f3..804e200 100644
--- a/src/com/android/exchange/adapter/CalendarSyncAdapter.java
+++ b/src/com/android/exchange/adapter/CalendarSyncAdapter.java
@@ -73,19 +73,18 @@
     // there's no original event when finding an item by _SYNC_ID
     private static final String SERVER_ID = Events._SYNC_ID + "=? AND " +
         Events.ORIGINAL_EVENT + " ISNULL";
-    private static final String DIRTY_TOP_LEVEL_IN_CALENDAR =
-        Events._SYNC_DIRTY + "=1 AND " + Events.ORIGINAL_EVENT + " ISNULL AND " +
-        Events.CALENDAR_ID + "=?";
+    private static final String DIRTY_OR_MARKED_TOP_LEVEL_IN_CALENDAR =
+        "(" + Events._SYNC_DIRTY + "=1 OR " + Events._SYNC_MARK + "= 1) AND " +
+        Events.ORIGINAL_EVENT + " ISNULL AND " + Events.CALENDAR_ID + "=?";
     private static final String DIRTY_EXCEPTION_IN_CALENDAR =
         Events._SYNC_DIRTY + "=1 AND " + Events.ORIGINAL_EVENT + " NOTNULL AND " +
         Events.CALENDAR_ID + "=?";
-    private static final String DIRTY_IN_CALENDAR =
-        Events._SYNC_DIRTY + "=1 AND " + Events.CALENDAR_ID + "=?";
     private static final String CLIENT_ID_SELECTION = Events._SYNC_DATA + "=?";
     private static final String ATTENDEES_EXCEPT_ORGANIZER = Attendees.EVENT_ID + "=? AND " +
         Attendees.ATTENDEE_RELATIONSHIP + "!=" + Attendees.RELATIONSHIP_ORGANIZER;
     private static final String[] ID_PROJECTION = new String[] {Events._ID};
-    private static final String[] ORIGINAL_EVENT_PROJECTION = new String[] {Events.ORIGINAL_EVENT};
+    private static final String[] ORIGINAL_EVENT_PROJECTION =
+        new String[] {Events.ORIGINAL_EVENT, Events._ID};
 
     public static final String CALENDAR_SELECTION =
         Calendars._SYNC_ACCOUNT + "=? AND " + Calendars._SYNC_ACCOUNT_TYPE + "=?";
@@ -836,10 +835,11 @@
             mOps.execute();
 
             if (mOps.mResults != null) {
-                // Clear dirty flags for updates sent to server
+                // Clear dirty and mark flags for updates sent to server
                 if (!mUploadedIdList.isEmpty())  {
                     ContentValues cv = new ContentValues();
                     cv.put(Events._SYNC_DIRTY, 0);
+                    cv.put(Events._SYNC_MARK, 0);
                     for (long eventId: mUploadedIdList) {
                         mContentResolver.update(ContentUris.withAppendedId(sEventsUri, eventId), cv,
                                 null, null);
@@ -1045,6 +1045,12 @@
         return Integer.toString(easVisibility);
     }
 
+    private int getInt(ContentValues cv, String column) {
+        Integer i = cv.getAsInteger(column);
+        if (i == null) return 0;
+        return i;
+    }
+
     private boolean sendEvent(Entity entity, String clientId, Serializer s)
             throws IOException {
         // Serialize for EAS here
@@ -1263,9 +1269,6 @@
     @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;
@@ -1274,24 +1277,37 @@
         try {
             // We've got to handle exceptions as part of the parent when changes occur, so we need
             // to find new/changed exceptions and mark the parent dirty
+            ArrayList<Long> orphanedExceptions = new ArrayList<Long>();
             Cursor c = cr.query(Events.CONTENT_URI, ORIGINAL_EVENT_PROJECTION,
                     DIRTY_EXCEPTION_IN_CALENDAR, mCalendarIdArgument, null);
             try {
                 ContentValues cv = new ContentValues();
-                cv.put(Events._SYNC_DIRTY, 1);
-                // Mark the parent dirty in this loop
+                // We use _sync_mark here to distinguish dirty parents from parents with dirty
+                // exceptions
+                cv.put(Events._SYNC_MARK, 1);
                 while (c.moveToNext()) {
+                    // Mark the parents of dirty exceptions
                     String serverId = c.getString(0);
-                    cr.update(asSyncAdapter(Events.CONTENT_URI), cv, SERVER_ID,
-                             new String[] {serverId});
+                    int cnt = cr.update(sEventsUri, cv, SERVER_ID, new String[] {serverId});
+                    // Keep track of any orphaned exceptions
+                    if (cnt == 0) {
+                        orphanedExceptions.add(c.getLong(1));
+                    }
                 }
             } finally {
                 c.close();
             }
 
-            // Now we can go through dirty top-level events and send them back to the server
+           // Delete any orphaned exceptions
+            for (long orphan: orphanedExceptions) {
+                userLog(TAG, "Deleted orphaned exception: " + orphan);
+                cr.delete(ContentUris.withAppendedId(sEventsUri, orphan), null, null);
+            }
+            orphanedExceptions.clear();
+
+            // Now we can go through dirty/marked top-level events and send them back to the server
             EntityIterator eventIterator = EventsEntity.newEntityIterator(
-                    cr.query(uri, null, DIRTY_TOP_LEVEL_IN_CALENDAR,
+                    cr.query(sEventsUri, null, DIRTY_OR_MARKED_TOP_LEVEL_IN_CALENDAR,
                             mCalendarIdArgument, null), cr);
             ContentValues cidValues = new ContentValues();
             try {
@@ -1332,7 +1348,8 @@
                         // And save it in the Event as the local id
                         cidValues.put(Events._SYNC_DATA, clientId);
                         cidValues.put(Events._SYNC_VERSION, "0");
-                        cr.update(ContentUris.withAppendedId(uri, eventId), cidValues, null, null);
+                        cr.update(ContentUris.withAppendedId(sEventsUri, eventId), cidValues,
+                                null, null);
                     } else {
                         if (entityValues.getAsInteger(Events.DELETED) == 1) {
                             userLog("Deleting event with serverId: ", serverId);
@@ -1364,7 +1381,8 @@
                         cidValues.put(Events._SYNC_VERSION, version);
                         // Also save in entityValues so that we send it this time around
                         entityValues.put(Events._SYNC_VERSION, version);
-                        cr.update(ContentUris.withAppendedId(uri, eventId), cidValues, null, null);
+                        cr.update(ContentUris.withAppendedId(sEventsUri, eventId), cidValues,
+                                null, null);
                         s.start(Tags.SYNC_CHANGE).data(Tags.SYNC_SERVER_ID, serverId);
                     }
                     s.start(Tags.SYNC_APPLICATION_DATA);
@@ -1373,18 +1391,57 @@
 
                     // Now, the hard part; find exceptions for this event
                     if (serverId != null) {
-                        EntityIterator exceptionIterator = EventsEntity.newEntityIterator(
-                                cr.query(uri, null, Events.ORIGINAL_EVENT + "=?",
+                        EntityIterator exIterator = EventsEntity.newEntityIterator(
+                                cr.query(sEventsUri, null, Events.ORIGINAL_EVENT + "=?",
                                         new String[] {serverId}, null), cr);
                         boolean exFirst = true;
-                        while (exceptionIterator.hasNext()) {
-                            Entity exceptionEntity = exceptionIterator.next();
+                        while (exIterator.hasNext()) {
+                            Entity exEntity = exIterator.next();
                             if (exFirst) {
                                 s.start(Tags.CALENDAR_EXCEPTIONS);
                                 exFirst = false;
                             }
                             s.start(Tags.CALENDAR_EXCEPTION);
-                            sendEvent(exceptionEntity, null, s);
+                            sendEvent(exEntity, null, s);
+                            ContentValues exValues = exEntity.getEntityValues();
+                            if (getInt(exValues, Events._SYNC_DIRTY) == 1) {
+                                // This is a new/updated exception, so we've got to notify our
+                                // attendees about it
+                                long exEventId = exValues.getAsLong(Events._ID);
+                                int flag;
+                                if (getInt(exValues, Events.STATUS) == Events.STATUS_CANCELED) {
+                                    // Add the eventId of the exception to the proper list, so that
+                                    // the dirty bit is cleared or the event is deleted after the
+                                    // sync has completed
+                                    mDeletedIdList.add(exEventId);
+                                    flag = Message.FLAG_OUTGOING_MEETING_CANCEL;
+                                } else {
+                                    mUploadedIdList.add(exEventId);
+                                    flag = Message.FLAG_OUTGOING_MEETING_INVITE;
+                                }
+
+                                // Copy subvalues into the exception; otherwise, we won't see the
+                                // attendees when preparing the message
+                                for (NamedContentValues ncv: entity.getSubValues()) {
+                                    exEntity.addSubValue(ncv.uri, ncv.values);
+                                }
+                                // Copy version so the ics attachment shows the proper sequence #
+                                exValues.put(Events._SYNC_VERSION,
+                                        entityValues.getAsString(Events._SYNC_VERSION));
+                                // Copy location so that it's included in the outgoing email
+                                if (entityValues.containsKey(Events.EVENT_LOCATION)) {
+                                    exValues.put(Events.EVENT_LOCATION,
+                                        entityValues.getAsString(Events.EVENT_LOCATION));
+                                }
+
+                                Message msg =
+                                    CalendarUtilities.createMessageForEntity(mContext,
+                                            exEntity, flag, clientId, mAccount);
+                                if (msg != null) {
+                                    userLog("Sending exception update to " + msg.mTo);
+                                    EasOutboxService.sendMessage(mContext, mAccount.mId, msg);
+                                }
+                            }
                             s.end(); // EXCEPTION
                         }
                         if (!exFirst) {
@@ -1395,9 +1452,13 @@
                     s.end().end(); // ApplicationData & Change
                     mUploadedIdList.add(eventId);
 
-                    // Send the meeting invite if there are attendees and we're the organizer
+                    // Send the meeting invite if there are attendees and we're the organizer AND
+                    // if the Event itself is dirty (we might be syncing only because an exception
+                    // is dirty, in which case we DON'T send email about the Event
                     boolean selfOrganizer = organizerEmail.equalsIgnoreCase(mAccount.mEmailAddress);
-                    if (hasAttendees && selfOrganizer) {
+
+                    if (hasAttendees && selfOrganizer &&
+                            (getInt(entityValues, Events._SYNC_DIRTY) == 1)) {
                         EmailContent.Message msg =
                             CalendarUtilities.createMessageForEventId(mContext, eventId,
                                     EmailContent.Message.FLAG_OUTGOING_MEETING_INVITE, clientId,
@@ -1435,8 +1496,8 @@
                                 cidValues.clear();
                                 cidValues.put(Events.SYNC_ADAPTER_DATA,
                                         Integer.toString(currentStatus));
-                                cr.update(ContentUris.withAppendedId(uri, eventId), cidValues, null,
-                                        null);
+                                cr.update(ContentUris.withAppendedId(sEventsUri, eventId),
+                                        cidValues, null, null);
                                 // Send mail to the organizer advising of the new status
                                 EmailContent.Message msg =
                                     CalendarUtilities.createMessageForEventId(mContext, eventId,
diff --git a/src/com/android/exchange/utility/CalendarUtilities.java b/src/com/android/exchange/utility/CalendarUtilities.java
index 4b8fe46..8b27eae 100644
--- a/src/com/android/exchange/utility/CalendarUtilities.java
+++ b/src/com/android/exchange/utility/CalendarUtilities.java
@@ -36,6 +36,7 @@
 import android.content.Entity;
 import android.content.EntityIterator;
 import android.content.Entity.NamedContentValues;
+import android.content.res.Resources;
 import android.net.Uri;
 import android.os.RemoteException;
 import android.provider.Calendar.Attendees;
@@ -48,6 +49,7 @@
 import android.util.base64.Base64;
 
 import java.io.IOException;
+import java.text.DateFormat;
 import java.text.ParseException;
 import java.util.ArrayList;
 import java.util.Calendar;
@@ -1139,6 +1141,37 @@
         return -1;
     }
 
+    static public String buildMessageTextFromEntityValues(Context context,
+            ContentValues entityValues, StringBuilder sb) {
+        if (sb == null) {
+            sb = new StringBuilder();
+        }
+        Resources resources = context.getResources();
+        Date date = new Date(entityValues.getAsLong(Events.DTSTART));
+        String dateTimeString = DateFormat.getDateTimeInstance().format(date);
+        // TODO: Add more detail to message text
+        // Right now, we're using.. When: Tuesday, March 5th at 2:00pm
+        // What we're missing is the duration and any recurrence information.  So this should be
+        // more like... When: Tuesdays, starting March 5th from 2:00pm - 3:00pm
+        // This would require code to build complex strings, and it will have to wait
+        sb.append(resources.getString(R.string.meeting_when, dateTimeString));
+        String location = null;
+        if (entityValues.containsKey(Events.EVENT_LOCATION)) {
+            location = entityValues.getAsString(Events.EVENT_LOCATION);
+            if (location != null) {
+                sb.append("\n");
+                sb.append(resources.getString(R.string.meeting_where, location));
+            }
+        }
+        // If there's a description for this event, append it
+        String desc = entityValues.getAsString(Events.DESCRIPTION);
+        if (desc != null) {
+            sb.append("\n--\n");
+            sb.append(desc);
+        }
+        return sb.toString();
+    }
+
     /**
      * Create a Message for an (Event) Entity
      * @param entity the Entity for the Event (as might be retrieved by CalendarProvider)
@@ -1149,10 +1182,9 @@
      */
     static public EmailContent.Message createMessageForEntity(Context context, Entity entity,
             int messageFlag, String uid, Account account) {
-        // TODO Handle exceptions; will be a nightmare
-        // TODO Cries out for unit test
         ContentValues entityValues = entity.getEntityValues();
         ArrayList<NamedContentValues> subValues = entity.getSubValues();
+        boolean isException = entityValues.containsKey(Events.ORIGINAL_EVENT);
 
         EmailContent.Message msg = new EmailContent.Message();
         msg.mFlags = messageFlag;
@@ -1208,6 +1240,14 @@
                         CalendarUtilities.millisToEasDateTime(startTime, vCalendarTimeZone));
             }
 
+            // If this is an Exception, we send the recurrence-id, which is just the original
+            // instance time
+            if (isException) {
+                long originalTime = entityValues.getAsLong(Events.ORIGINAL_INSTANCE_TIME);
+                ics.writeTag("RECURRENCE-ID" + vCalendarTimeZoneSuffix,
+                        CalendarUtilities.millisToEasDateTime(originalTime, vCalendarTimeZone));
+            }
+
             if (!entityValues.containsKey(Events.DURATION)) {
                 if (entityValues.containsKey(Events.DTEND)) {
                     ics.writeTag("DTEND" + vCalendarTimeZoneSuffix,
@@ -1229,14 +1269,25 @@
                                 startTime + durationMillis, vCalendarTimeZone));
             }
 
+            String location = null;
             if (entityValues.containsKey(Events.EVENT_LOCATION)) {
-                ics.writeTag("LOCATION", entityValues.getAsString(Events.EVENT_LOCATION));
+                location = entityValues.getAsString(Events.EVENT_LOCATION);
+                ics.writeTag("LOCATION", location);
+            }
+
+            String sequence = entityValues.getAsString(Events._SYNC_VERSION);
+            if (sequence == null) {
+                sequence = "0";
             }
 
             int titleId = 0;
             switch (messageFlag) {
                 case Message.FLAG_OUTGOING_MEETING_INVITE:
-                    titleId = R.string.meeting_invitation;
+                    if (sequence.equals("0")) {
+                        titleId = R.string.meeting_invitation;
+                    } else {
+                        titleId = R.string.meeting_updated;
+                    }
                     break;
                 case Message.FLAG_OUTGOING_MEETING_ACCEPT:
                     titleId = R.string.meeting_accepted;
@@ -1251,13 +1302,14 @@
                     titleId = R.string.meeting_canceled;
                     break;
             }
+            Resources resources = context.getResources();
             String title = entityValues.getAsString(Events.TITLE);
             if (title == null) {
                 title = "";
             }
             ics.writeTag("SUMMARY", title);
             if (titleId != 0) {
-                msg.mSubject = context.getResources().getString(titleId, title);
+                msg.mSubject = resources.getString(titleId, title);
             }
             if (method.equals("REQUEST")) {
                 if (entityValues.containsKey(Events.ALL_DAY)) {
@@ -1265,14 +1317,29 @@
                     ics.writeTag("X-MICROSOFT-CDO-ALLDAYEVENT", ade == 0 ? "FALSE" : "TRUE");
                 }
 
-                String desc = entityValues.getAsString(Events.DESCRIPTION);
-                if (desc != null) {
-                    // TODO Do we add some description of the event here?
-                    ics.writeTag("DESCRIPTION", desc);
-                    msg.mText = desc;
-                } else {
-                    msg.mText = "";
+                // Build the text for the message, starting with an initial line describing the
+                // exception
+                StringBuilder sb = new StringBuilder();
+                if (isException) {
+                    // Add the line, depending on whether this is a cancellation or update
+                    Date date = new Date(entityValues.getAsLong(Events.ORIGINAL_INSTANCE_TIME));
+                    String dateString = DateFormat.getDateInstance().format(date);
+                    if (titleId == R.string.meeting_canceled) {
+                        sb.append(resources.getString(R.string.exception_cancel, dateString));
+                    } else {
+                        sb.append(resources.getString(R.string.exception_updated, dateString));
+                    }
+                    sb.append("\n\n");
                 }
+                String text =
+                    CalendarUtilities.buildMessageTextFromEntityValues(context, entityValues, sb);
+
+                    // If we've got anything here, write it into the ics file
+                if (text.length() > 0) {
+                    ics.writeTag("DESCRIPTION", text);
+                }
+                // And store the message text
+                msg.mText = text;
 
                 String rrule = entityValues.getAsString(Events.RRULE);
                 if (rrule != null) {
@@ -1379,10 +1446,11 @@
             msg.mTo = Address.pack(toArray);
 
             ics.writeTag("CLASS", "PUBLIC");
-            ics.writeTag("STATUS", "CONFIRMED");
+            ics.writeTag("STATUS", (messageFlag == Message.FLAG_OUTGOING_MEETING_CANCEL) ?
+                    "CANCELLED" : "CONFIRMED");
             ics.writeTag("TRANSP", "OPAQUE"); // What Exchange uses
             ics.writeTag("PRIORITY", "5");  // 1 to 9, 5 = medium
-            ics.writeTag("SEQUENCE", entityValues.getAsString(Events._SYNC_VERSION));
+            ics.writeTag("SEQUENCE", sequence);
             ics.writeTag("END", "VEVENT");
             ics.writeTag("END", "VCALENDAR");
             ics.flush();
diff --git a/tests/src/com/android/exchange/utility/CalendarUtilitiesTests.java b/tests/src/com/android/exchange/utility/CalendarUtilitiesTests.java
index 34666c3..a558a3b 100644
--- a/tests/src/com/android/exchange/utility/CalendarUtilitiesTests.java
+++ b/tests/src/com/android/exchange/utility/CalendarUtilitiesTests.java
@@ -17,11 +17,11 @@
 package com.android.exchange.utility;
 
 import com.android.email.R;
+import com.android.email.Utility;
 import com.android.email.mail.Address;
 import com.android.email.provider.EmailContent.Account;
 import com.android.email.provider.EmailContent.Attachment;
 import com.android.email.provider.EmailContent.Message;
-import com.android.email.Utility;
 
 import android.content.ContentValues;
 import android.content.Entity;
@@ -184,6 +184,18 @@
         return entity;
     }
 
+    private Entity setupTestExceptionEntity(String organizer, String attendee, String title) {
+        Entity entity = setupTestEventEntity(organizer, attendee, title);
+        ContentValues entityValues = entity.getEntityValues();
+        entityValues.put(Events.ORIGINAL_EVENT, 69);
+        // April 12, 2010 is a Monday
+        entityValues.put(Events.RRULE, "FREQ=WEEKLY;BYDAY=MO");
+        // The exception will be on April 26th
+        entityValues.put(Events.ORIGINAL_INSTANCE_TIME,
+                Utility.parseEmailDateTimeToMillis("2010-04-26T18:30:00Z"));
+        return entity;
+    }
+
     public void testCreateMessageForEntity_Reply() {
         // Set up the "event"
         String attendee = "attendee@server.com";
@@ -261,7 +273,6 @@
         assertEquals("text/calendar; method=REQUEST", att.mMimeType);
         assertNotNull(att.mContent);
 
-
         // We'll check the contents of the ics file here
         BlockHash vcalendar = parseIcsContent(att.mContent);
         assertNotNull(vcalendar);
@@ -284,6 +295,87 @@
                 vevent.get("ATTENDEE;ROLE=REQ-PARTICIPANT;PARTSTAT=NEEDS-ACTION;RSVP=TRUE"));
     }
 
+    public void testCreateMessageForEntity_Exception_Cancel() throws IOException {
+        // Set up the "exception"...
+        String attendee = "attendee@server.com";
+        String organizer = "organizer@server.com";
+        String title = "Discuss Unit Tests";
+        Entity entity = setupTestExceptionEntity(organizer, attendee, title);
+        
+        ContentValues entityValues = entity.getEntityValues();
+        // Mark the Exception as dirty
+        entityValues.put(Events._SYNC_DIRTY, 1);
+        // And mark it canceled
+        entityValues.put(Events.STATUS, Events.STATUS_CANCELED);
+        // Give it an RRULE so that time zone will be included
+        entityValues.put(Events.RRULE, "FREQ=WEEKLY;BYDAY=MO");
+
+        // Create a dummy account for the attendee
+        Account account = new Account();
+        account.mEmailAddress = organizer;
+
+        // The uid is required, but can be anything
+        String uid = "31415926535";
+
+        // Create the outgoing message
+        Message msg = CalendarUtilities.createMessageForEntity(mContext, entity,
+                Message.FLAG_OUTGOING_MEETING_INVITE, uid, account);
+
+        // First, we should have a message
+        assertNotNull(msg);
+
+        // Now check some of the fields of the message
+        assertEquals(Address.pack(new Address[] {new Address(attendee)}), msg.mTo);
+        String accept = getContext().getResources().getString(R.string.meeting_invitation, title);
+        assertEquals(accept, msg.mSubject);
+
+        // And make sure we have an attachment
+        assertNotNull(msg.mAttachments);
+        assertEquals(1, msg.mAttachments.size());
+        Attachment att = msg.mAttachments.get(0);
+        // And that the attachment has the correct elements
+        assertEquals("invite.ics", att.mFileName);
+        assertEquals(Attachment.FLAG_SUPPRESS_DISPOSITION,
+                att.mFlags & Attachment.FLAG_SUPPRESS_DISPOSITION);
+        assertEquals("text/calendar; method=REQUEST", att.mMimeType);
+        assertNotNull(att.mContent);
+
+        // We'll check the contents of the ics file here
+        BlockHash vcalendar = parseIcsContent(att.mContent);
+        assertNotNull(vcalendar);
+
+        // We should have a VCALENDAR with a REQUEST method
+        assertEquals("VCALENDAR", vcalendar.name);
+        assertEquals("REQUEST", vcalendar.get("METHOD"));
+
+        // This is the time zone that should be used
+        TimeZone timeZone = TimeZone.getDefault();
+
+        // We should have two blocks under VCALENDAR (VTIMEZONE and VEVENT)
+        assertEquals(2, vcalendar.blocks.size());
+
+        BlockHash vtimezone = vcalendar.blocks.get(0);
+        // It should be a VTIMEZONE for timeZone
+        assertEquals("VTIMEZONE", vtimezone.name);
+        assertEquals(timeZone.getID(), vtimezone.get("TZID"));
+
+        BlockHash vevent = vcalendar.blocks.get(1);
+        // It's a VEVENT with the following fields
+        assertEquals("VEVENT", vevent.name);
+        assertEquals("MAILTO:" + organizer, vevent.get("ORGANIZER"));
+        assertEquals("Meeting Location", vevent.get("LOCATION"));
+        assertEquals("0", vevent.get("SEQUENCE"));
+        assertEquals("Discuss Unit Tests", vevent.get("SUMMARY"));
+        assertEquals(uid, vevent.get("UID"));
+        assertEquals("MAILTO:" + attendee,
+                vevent.get("ATTENDEE;ROLE=REQ-PARTICIPANT;PARTSTAT=NEEDS-ACTION;RSVP=TRUE"));
+        long originalTime = entityValues.getAsLong(Events.ORIGINAL_INSTANCE_TIME);
+        assertNotSame(0, originalTime);
+        // For an exception, RECURRENCE-ID is critical
+        assertEquals(CalendarUtilities.millisToEasDateTime(originalTime, timeZone),
+                vevent.get("RECURRENCE-ID" + ";TZID=" + timeZone.getID()));
+    }
+
     public void testUtcOffsetString() {
         assertEquals(CalendarUtilities.utcOffsetString(540), "+0900");
         assertEquals(CalendarUtilities.utcOffsetString(-480), "-0800");