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