/* * 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.content.ContentResolver; import android.content.ContentUris; import android.content.ContentValues; import android.content.Context; import android.content.Entity; import android.content.Entity.NamedContentValues; import android.content.EntityIterator; import android.content.res.Resources; import android.database.Cursor; import android.net.Uri; import android.provider.CalendarContract.Attendees; import android.provider.CalendarContract.Calendars; import android.provider.CalendarContract.Events; import android.provider.CalendarContract.EventsEntity; import android.text.TextUtils; import android.text.format.Time; import android.util.Base64; import com.android.calendarcommon2.DateException; import com.android.calendarcommon2.Duration; import com.android.emailcommon.Logging; import com.android.emailcommon.mail.Address; import com.android.emailcommon.provider.Account; import com.android.emailcommon.provider.EmailContent; import com.android.emailcommon.provider.EmailContent.Attachment; import com.android.emailcommon.provider.EmailContent.Message; import com.android.emailcommon.provider.Mailbox; import com.android.emailcommon.service.AccountServiceProxy; import com.android.emailcommon.utility.Utility; import com.android.exchange.Eas; import com.android.exchange.R; import com.android.exchange.adapter.Serializer; import com.android.exchange.adapter.Tags; import com.android.mail.utils.LogUtils; import com.google.common.annotations.VisibleForTesting; import java.io.IOException; import java.text.DateFormat; import java.text.ParseException; import java.util.ArrayList; 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 = Eas.LOG_TAG; // Time related convenience constants, in milliseconds static final int SECONDS = 1000; static final int MINUTES = SECONDS*60; static final int HOURS = MINUTES*60; static final long DAYS = HOURS*24; // We want to find a time zone whose DST info is accurate to one minute static final int STANDARD_DST_PRECISION = MINUTES; // If we can't find one, we'll try a more lenient standard (this is better than guessing a // time zone, which is what we otherwise do). Note that this specifically addresses an issue // seen in some time zones sent by MS Exchange in which the start and end hour differ // for no apparent reason static final int LENIENT_DST_PRECISION = 4*HOURS; private static final String SYNC_VERSION = Events.SYNC_DATA4; // 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_STRING_SIZE = 32; 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*MSFT_TIME_ZONE_STRING_SIZE); 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*MSFT_TIME_ZONE_STRING_SIZE); 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 sTimeZoneCache = new HashMap(); // TZI string cache; we keep around our encoded TimeZoneInformation strings private static HashMap sTziStringCache = new HashMap(); private static final TimeZone UTC_TIMEZONE = TimeZone.getTimeZone("UTC"); // Default, Popup private static final String ALLOWED_REMINDER_TYPES = "0,1"; // None, required, optional private static final String ALLOWED_ATTENDEE_TYPES = "0,1,2"; // Busy, free, tentative private static final String ALLOWED_AVAILABILITIES = "0,1,2"; // 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"}; // Bits used in EAS recurrences for days of the week protected static final int EAS_SUNDAY = 1<<0; protected static final int EAS_MONDAY = 1<<1; protected static final int EAS_TUESDAY = 1<<2; protected static final int EAS_WEDNESDAY = 1<<3; protected static final int EAS_THURSDAY = 1<<4; protected static final int EAS_FRIDAY = 1<<5; protected static final int EAS_SATURDAY = 1<<6; protected static final int EAS_WEEKDAYS = EAS_MONDAY | EAS_TUESDAY | EAS_WEDNESDAY | EAS_THURSDAY | EAS_FRIDAY; protected static final int EAS_WEEKENDS = EAS_SATURDAY | EAS_SUNDAY; static final int sCurrentYear = new GregorianCalendar().get(Calendar.YEAR); static final TimeZone sGmtTimeZone = TimeZone.getTimeZone("GMT"); private static final String ICALENDAR_ATTENDEE = "ATTENDEE;ROLE=REQ-PARTICIPANT"; static final String ICALENDAR_ATTENDEE_CANCEL = ICALENDAR_ATTENDEE; static final String ICALENDAR_ATTENDEE_INVITE = ICALENDAR_ATTENDEE + ";PARTSTAT=NEEDS-ACTION;RSVP=TRUE"; static final String ICALENDAR_ATTENDEE_ACCEPT = ICALENDAR_ATTENDEE + ";PARTSTAT=ACCEPTED"; static final String ICALENDAR_ATTENDEE_DECLINE = ICALENDAR_ATTENDEE + ";PARTSTAT=DECLINED"; static final String ICALENDAR_ATTENDEE_TENTATIVE = ICALENDAR_ATTENDEE + ";PARTSTAT=TENTATIVE"; // Note that these constants apply to Calendar items // For future reference: MeetingRequest data can also include free/busy information, but the // constants for these four options in MeetingRequest data have different values! // See [MS-ASCAL] 2.2.2.8 for Calendar BusyStatus // See [MS-EMAIL] 2.2.2.34 for MeetingRequest BusyStatus public static final int BUSY_STATUS_FREE = 0; public static final int BUSY_STATUS_TENTATIVE = 1; public static final int BUSY_STATUS_BUSY = 2; public static final int BUSY_STATUS_OUT_OF_OFFICE = 3; // Note that these constants apply to Calendar items, and are used in EAS 14+ // See [MS-ASCAL] 2.2.2.22 for Calendar ResponseType public static final int RESPONSE_TYPE_NONE = 0; public static final int RESPONSE_TYPE_ORGANIZER = 1; public static final int RESPONSE_TYPE_TENTATIVE = 2; public static final int RESPONSE_TYPE_ACCEPTED = 3; public static final int RESPONSE_TYPE_DECLINED = 4; public static final int RESPONSE_TYPE_NOT_RESPONDED = 5; // 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); } static String getString(byte[] bytes, int offset, int size) { StringBuilder sb = new StringBuilder(); for (int i = 0; i < size; i++) { int ch = bytes[offset + i]; if (ch == 0) { break; } else { sb.append((char)ch); } } return sb.toString(); } // 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; } @VisibleForTesting static void clearTimeZoneCache() { sTimeZoneCache.clear(); } static void putRuleIntoTimeZoneInformation(byte[] bytes, int offset, RRule rrule, int hour, int minute) { // MSFT months are 1 based, same as RRule setWord(bytes, offset + MSFT_SYSTEMTIME_MONTH, rrule.month); // MSFT day of week starts w/ Sunday = 0; RRule starts w/ Sunday = 1 setWord(bytes, offset + MSFT_SYSTEMTIME_DAY_OF_WEEK, rrule.dayOfWeek - 1); // 5 means "last" in MSFT land; for RRule, it's -1 setWord(bytes, offset + MSFT_SYSTEMTIME_DAY, rrule.week < 0 ? 5 : rrule.week); // Turn hours/minutes into ms from midnight (per TimeZone) setWord(bytes, offset + MSFT_SYSTEMTIME_HOUR, hour); setWord(bytes, offset + MSFT_SYSTEMTIME_MINUTE, minute); } // Write a transition time into SYSTEMTIME data (via an offset into a byte array) static void putTransitionMillisIntoSystemTime(byte[] bytes, int offset, long millis) { GregorianCalendar cal = new GregorianCalendar(TimeZone.getDefault()); // Round to the next highest minute; we always write seconds as zero cal.setTimeInMillis(millis + 30*SECONDS); // MSFT months are 1 based; TimeZone is 0 based setWord(bytes, offset + MSFT_SYSTEMTIME_MONTH, cal.get(Calendar.MONTH) + 1); // MSFT day of week starts w/ Sunday = 0; TimeZone starts w/ Sunday = 1 setWord(bytes, offset + MSFT_SYSTEMTIME_DAY_OF_WEEK, cal.get(Calendar.DAY_OF_WEEK) - 1); // Get the "day" in TimeZone format int wom = cal.get(Calendar.DAY_OF_WEEK_IN_MONTH); // 5 means "last" in MSFT land; for TimeZone, it's -1 setWord(bytes, offset + MSFT_SYSTEMTIME_DAY, wom < 0 ? 5 : wom); // Turn hours/minutes into ms from midnight (per TimeZone) setWord(bytes, offset + MSFT_SYSTEMTIME_HOUR, getTrueTransitionHour(cal)); setWord(bytes, offset + MSFT_SYSTEMTIME_MINUTE, getTrueTransitionMinute(cal)); } // 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; } /** * 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 long getMillisAtTimeZoneDateTransition(TimeZone timeZone, TimeZoneDate tzd) { GregorianCalendar testCalendar = new GregorianCalendar(timeZone); testCalendar.set(GregorianCalendar.YEAR, sCurrentYear); 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); testCalendar.set(GregorianCalendar.SECOND, 0); return testCalendar.getTimeInMillis(); } /** * Return a GregorianCalendar representing the first standard/daylight transition between a * start time and an end time in the given time zone * @param tz a TimeZone the time zone in which we're looking for transitions * @param startTime the start time for the test * @param endTime the end time for the test * @param startInDaylightTime whether daylight time is in effect at the startTime * @return a GregorianCalendar representing the transition or null if none */ static GregorianCalendar findTransitionDate(TimeZone tz, long startTime, long endTime, boolean startInDaylightTime) { long startingEndTime = endTime; Date date = null; // We'll keep splitting the difference until we're within a minute while ((endTime - startTime) > MINUTES) { long checkTime = ((startTime + endTime) / 2) + 1; date = new Date(checkTime); boolean inDaylightTime = tz.inDaylightTime(date); if (inDaylightTime != startInDaylightTime) { endTime = checkTime; } else { startTime = checkTime; } } // If these are the same, we're really messed up; return null if (endTime == startingEndTime) { return null; } // Set up our calendar and return it GregorianCalendar calendar = new GregorianCalendar(tz); calendar.setTimeInMillis(startTime); return calendar; } /** * Return a Base64 representation of a MSFT TIME_ZONE_INFORMATION structure from a TimeZone * that might be found in an Event; use cached result, if possible * @param tz the TimeZone * @return the Base64 String representing a Microsoft TIME_ZONE_INFORMATION element */ static public String timeZoneToTziString(TimeZone tz) { String tziString = sTziStringCache.get(tz); if (tziString != null) { if (Eas.USER_LOG) { LogUtils.d(TAG, "TZI string for " + tz.getDisplayName() + " found in cache."); } return tziString; } tziString = timeZoneToTziStringImpl(tz); sTziStringCache.put(tz, tziString); return tziString; } /** * A class for storing RRULE information. The RRULE members can be accessed individually or * an RRULE string can be created with toString() */ static class RRule { static final int RRULE_NONE = 0; static final int RRULE_DAY_WEEK = 1; static final int RRULE_DATE = 2; int type; int dayOfWeek; int week; int month; int date; /** * Create an RRULE based on month and date * @param _month the month (1 = JAN, 12 = DEC) * @param _date the date in the month (1-31) */ RRule(int _month, int _date) { type = RRULE_DATE; month = _month; date = _date; } /** * Create an RRULE based on month, day of week, and week # * @param _month the month (1 = JAN, 12 = DEC) * @param _dayOfWeek the day of the week (1 = SU, 7 = SA) * @param _week the week in the month (1-5 or -1 for last) */ RRule(int _month, int _dayOfWeek, int _week) { type = RRULE_DAY_WEEK; month = _month; dayOfWeek = _dayOfWeek; week = _week; } @Override public String toString() { if (type == RRULE_DAY_WEEK) { return "FREQ=YEARLY;BYMONTH=" + month + ";BYDAY=" + week + sDayTokens[dayOfWeek - 1]; } else { return "FREQ=YEARLY;BYMONTH=" + month + ";BYMONTHDAY=" + date; } } } /** * Generate an RRULE string for an array of GregorianCalendars, if possible. For now, we are * only looking for rules based on the same date in a month or a specific instance of a day of * the week in a month (e.g. 2nd Tuesday or last Friday). Indeed, these are the only kinds of * rules used in the current tzinfo database. * @param calendars an array of GregorianCalendar, set to a series of transition times in * consecutive years starting with the current year * @return an RRULE or null if none could be inferred from the calendars */ static RRule inferRRuleFromCalendars(GregorianCalendar[] calendars) { // Let's see if we can make a rule about these GregorianCalendar calendar = calendars[0]; if (calendar == null) return null; int month = calendar.get(Calendar.MONTH); int date = calendar.get(Calendar.DAY_OF_MONTH); int dayOfWeek = calendar.get(Calendar.DAY_OF_WEEK); int week = calendar.get(Calendar.DAY_OF_WEEK_IN_MONTH); int maxWeek = calendar.getActualMaximum(Calendar.DAY_OF_WEEK_IN_MONTH); boolean dateRule = false; boolean dayOfWeekRule = false; for (int i = 1; i < calendars.length; i++) { GregorianCalendar cal = calendars[i]; if (cal == null) return null; // If it's not the same month, there's no rule if (cal.get(Calendar.MONTH) != month) { return null; } else if (dayOfWeek == cal.get(Calendar.DAY_OF_WEEK)) { // Ok, it seems to be the same day of the week if (dateRule) { return null; } dayOfWeekRule = true; int thisWeek = cal.get(Calendar.DAY_OF_WEEK_IN_MONTH); if (week != thisWeek) { if (week < 0 || week == maxWeek) { int thisMaxWeek = cal.getActualMaximum(Calendar.DAY_OF_WEEK_IN_MONTH); if (thisWeek == thisMaxWeek) { // We'll use -1 (i.e. last) week week = -1; continue; } } return null; } } else if (date == cal.get(Calendar.DAY_OF_MONTH)) { // Maybe the same day of the month? if (dayOfWeekRule) { return null; } dateRule = true; } else { return null; } } if (dateRule) { return new RRule(month + 1, date); } // sDayTokens is 0 based (SU = 0); Calendar days of week are 1 based (SU = 1) // iCalendar months are 1 based; Calendar months are 0 based // So we adjust these when building the string return new RRule(month + 1, dayOfWeek, week); } /** * Generate an rfc2445 utcOffset from minutes offset from GMT * These look like +0800 or -0100 * @param offsetMinutes minutes offset from GMT (east is positive, west is negative * @return a utcOffset */ static String utcOffsetString(int offsetMinutes) { StringBuilder sb = new StringBuilder(); int hours = offsetMinutes / 60; if (hours < 0) { sb.append('-'); hours = 0 - hours; } else { sb.append('+'); } int minutes = offsetMinutes % 60; if (hours < 10) { sb.append('0'); } sb.append(hours); if (minutes < 10) { sb.append('0'); } sb.append(minutes); return sb.toString(); } /** * Fill the passed in GregorianCalendars arrays with DST transition information for this and * the following years (based on the length of the arrays) * @param tz the time zone * @param toDaylightCalendars an array of GregorianCalendars, one for each year, representing * the transition to daylight time * @param toStandardCalendars an array of GregorianCalendars, one for each year, representing * the transition to standard time * @return true if transitions could be found for all years, false otherwise */ static boolean getDSTCalendars(TimeZone tz, GregorianCalendar[] toDaylightCalendars, GregorianCalendar[] toStandardCalendars) { // We'll use the length of the arrays to determine how many years to check int maxYears = toDaylightCalendars.length; if (toStandardCalendars.length != maxYears) { return false; } // Get the transitions for this year and the next few years for (int i = 0; i < maxYears; i++) { GregorianCalendar cal = new GregorianCalendar(tz); cal.set(sCurrentYear + i, Calendar.JANUARY, 1, 0, 0, 0); long startTime = cal.getTimeInMillis(); // Calculate end of year; no need to be insanely precise long endOfYearTime = startTime + (365*DAYS) + (DAYS>>2); Date date = new Date(startTime); boolean startInDaylightTime = tz.inDaylightTime(date); // Find the first transition, and store cal = findTransitionDate(tz, startTime, endOfYearTime, startInDaylightTime); if (cal == null) { return false; } else if (startInDaylightTime) { toStandardCalendars[i] = cal; } else { toDaylightCalendars[i] = cal; } // Find the second transition, and store cal = findTransitionDate(tz, startTime, endOfYearTime, !startInDaylightTime); if (cal == null) { return false; } else if (startInDaylightTime) { toDaylightCalendars[i] = cal; } else { toStandardCalendars[i] = cal; } } return true; } /** * Write out the STANDARD block of VTIMEZONE and end the VTIMEZONE * @param writer the SimpleIcsWriter we're using * @param tz the time zone * @param offsetString the offset string in VTIMEZONE format (e.g. +0800) * @throws IOException */ static private void writeNoDST(SimpleIcsWriter writer, TimeZone tz, String offsetString) throws IOException { writer.writeTag("BEGIN", "STANDARD"); writer.writeTag("TZOFFSETFROM", offsetString); writer.writeTag("TZOFFSETTO", offsetString); // Might as well use start of epoch for start date writer.writeTag("DTSTART", millisToEasDateTime(0L)); writer.writeTag("END", "STANDARD"); writer.writeTag("END", "VTIMEZONE"); } /** Write a VTIMEZONE block for a given TimeZone into a SimpleIcsWriter * @param tz the TimeZone to be used in the conversion * @param writer the SimpleIcsWriter to be used * @throws IOException */ static void timeZoneToVTimezone(TimeZone tz, SimpleIcsWriter writer) throws IOException { // We'll use these regardless of whether there's DST in this time zone or not int rawOffsetMinutes = tz.getRawOffset() / MINUTES; String standardOffsetString = utcOffsetString(rawOffsetMinutes); // Preamble for all of our VTIMEZONEs writer.writeTag("BEGIN", "VTIMEZONE"); writer.writeTag("TZID", tz.getID()); writer.writeTag("X-LIC-LOCATION", tz.getDisplayName()); // Simplest case is no daylight time if (!tz.useDaylightTime()) { writeNoDST(writer, tz, standardOffsetString); return; } int maxYears = 3; GregorianCalendar[] toDaylightCalendars = new GregorianCalendar[maxYears]; GregorianCalendar[] toStandardCalendars = new GregorianCalendar[maxYears]; if (!getDSTCalendars(tz, toDaylightCalendars, toStandardCalendars)) { writeNoDST(writer, tz, standardOffsetString); return; } // Try to find a rule to cover these yeras RRule daylightRule = inferRRuleFromCalendars(toDaylightCalendars); RRule standardRule = inferRRuleFromCalendars(toStandardCalendars); String daylightOffsetString = utcOffsetString(rawOffsetMinutes + (tz.getDSTSavings() / MINUTES)); // We'll use RRULE's if we found both // Otherwise we write the first as DTSTART and the others as RDATE boolean hasRule = daylightRule != null && standardRule != null; // Write the DAYLIGHT block writer.writeTag("BEGIN", "DAYLIGHT"); writer.writeTag("TZOFFSETFROM", standardOffsetString); writer.writeTag("TZOFFSETTO", daylightOffsetString); writer.writeTag("DTSTART", transitionMillisToVCalendarTime( toDaylightCalendars[0].getTimeInMillis(), tz, true)); if (hasRule) { writer.writeTag("RRULE", daylightRule.toString()); } else { for (int i = 1; i < maxYears; i++) { writer.writeTag("RDATE", transitionMillisToVCalendarTime( toDaylightCalendars[i].getTimeInMillis(), tz, true)); } } writer.writeTag("END", "DAYLIGHT"); // Write the STANDARD block writer.writeTag("BEGIN", "STANDARD"); writer.writeTag("TZOFFSETFROM", daylightOffsetString); writer.writeTag("TZOFFSETTO", standardOffsetString); writer.writeTag("DTSTART", transitionMillisToVCalendarTime( toStandardCalendars[0].getTimeInMillis(), tz, false)); if (hasRule) { writer.writeTag("RRULE", standardRule.toString()); } else { for (int i = 1; i < maxYears; i++) { writer.writeTag("RDATE", transitionMillisToVCalendarTime( toStandardCalendars[i].getTimeInMillis(), tz, true)); } } writer.writeTag("END", "STANDARD"); // And we're done writer.writeTag("END", "VTIMEZONE"); } /** * Find the next transition to occur (i.e. after the current date/time) * @param transitions calendars representing transitions to/from DST * @return millis for the first transition after the current date/time */ static long findNextTransition(long startingMillis, GregorianCalendar[] transitions) { for (GregorianCalendar transition: transitions) { long transitionMillis = transition.getTimeInMillis(); if (transitionMillis > startingMillis) { return transitionMillis; } } return 0; } /** * Calculate the Base64 representation of a MSFT TIME_ZONE_INFORMATION structure from a TimeZone * that might be found in an Event. Since the internal representation of the TimeZone is hidden * from us we'll find the DST transitions and build the structure from that information * @param tz the TimeZone * @return the Base64 String representing a Microsoft TIME_ZONE_INFORMATION element */ static String timeZoneToTziStringImpl(TimeZone tz) { String tziString; byte[] tziBytes = new byte[MSFT_TIME_ZONE_SIZE]; int standardBias = - tz.getRawOffset(); standardBias /= 60*SECONDS; setLong(tziBytes, MSFT_TIME_ZONE_BIAS_OFFSET, standardBias); // If this time zone has daylight savings time, we need to do more work if (tz.useDaylightTime()) { GregorianCalendar[] toDaylightCalendars = new GregorianCalendar[3]; GregorianCalendar[] toStandardCalendars = new GregorianCalendar[3]; // See if we can get transitions for a few years; if not, we can't generate DST info // for this time zone if (getDSTCalendars(tz, toDaylightCalendars, toStandardCalendars)) { // Try to find a rule to cover these years RRule daylightRule = inferRRuleFromCalendars(toDaylightCalendars); RRule standardRule = inferRRuleFromCalendars(toStandardCalendars); if ((daylightRule != null) && (daylightRule.type == RRule.RRULE_DAY_WEEK) && (standardRule != null) && (standardRule.type == RRule.RRULE_DAY_WEEK)) { // We need both rules and they have to be DAY/WEEK type // Write month, day of week, week, hour, minute putRuleIntoTimeZoneInformation(tziBytes, MSFT_TIME_ZONE_STANDARD_DATE_OFFSET, standardRule, getTrueTransitionHour(toStandardCalendars[0]), getTrueTransitionMinute(toStandardCalendars[0])); putRuleIntoTimeZoneInformation(tziBytes, MSFT_TIME_ZONE_DAYLIGHT_DATE_OFFSET, daylightRule, getTrueTransitionHour(toDaylightCalendars[0]), getTrueTransitionMinute(toDaylightCalendars[0])); } else { // If there's no rule, we'll use the first transition to standard/to daylight // And indicate that it's just for this year... long now = System.currentTimeMillis(); long standardTransition = findNextTransition(now, toStandardCalendars); long daylightTransition = findNextTransition(now, toDaylightCalendars); // If we can't find transitions, we can't do DST if (standardTransition != 0 && daylightTransition != 0) { putTransitionMillisIntoSystemTime(tziBytes, MSFT_TIME_ZONE_STANDARD_DATE_OFFSET, standardTransition); putTransitionMillisIntoSystemTime(tziBytes, MSFT_TIME_ZONE_DAYLIGHT_DATE_OFFSET, daylightTransition); } } } int dstOffset = tz.getDSTSavings(); setLong(tziBytes, MSFT_TIME_ZONE_DAYLIGHT_BIAS_OFFSET, - dstOffset / MINUTES); } byte[] tziEncodedBytes = Base64.encode(tziBytes, Base64.NO_WRAP); tziString = new String(tziEncodedBytes); return tziString; } /** * Given a String as directly read from EAS, returns a TimeZone corresponding to that String * @param timeZoneString the String read from the server * @param precision the number of milliseconds of precision in TimeZone determination * @return the TimeZone, or TimeZone.getDefault() if not found */ @VisibleForTesting static TimeZone tziStringToTimeZone(String timeZoneString, int precision) { // If we have this time zone cached, use that value and return TimeZone timeZone = sTimeZoneCache.get(timeZoneString); if (timeZone != null) { if (Eas.USER_LOG) { LogUtils.d(TAG, " Using cached TimeZone " + timeZone.getID()); } } else { timeZone = tziStringToTimeZoneImpl(timeZoneString, precision); if (timeZone == null) { // If we don't find a match, we just return the current TimeZone. In theory, this // shouldn't be happening... LogUtils.d(TAG, "TimeZone not found using default: " + timeZoneString); timeZone = TimeZone.getDefault(); } sTimeZoneCache.put(timeZoneString, timeZone); } return timeZone; } /** * The standard entry to EAS time zone conversion, using one minute as the precision */ static public TimeZone tziStringToTimeZone(String timeZoneString) { return tziStringToTimeZone(timeZoneString, MINUTES); } static private boolean hasTimeZoneId(String[] timeZoneIds, String id) { for (String timeZoneId: timeZoneIds) { if (id.equals(timeZoneId)) { return true; } } return false; } /** * Given a String as directly read from EAS, tries to find a TimeZone in the database of all * time zones that corresponds to that String. If the test time zone string includes DST and * we don't find a match, and we're using standard precision, we try again with lenient * precision, which is a bit better than guessing * @param timeZoneString the String read from the server * @return the TimeZone, or null if not found */ static TimeZone tziStringToTimeZoneImpl(String timeZoneString, int precision) { TimeZone timeZone = null; // First, we need to decode the base64 string byte[] timeZoneBytes = Base64.decode(timeZoneString, Base64.DEFAULT); // 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) { // If the default time zone is a match TimeZone defaultTimeZone = TimeZone.getDefault(); if (!defaultTimeZone.useDaylightTime() && hasTimeZoneId(zoneIds, defaultTimeZone.getID())) { if (Eas.USER_LOG) { LogUtils.d(TAG, "TimeZone without DST found to be default: " + defaultTimeZone.getID()); } return defaultTimeZone; } // In this case, there is no daylight savings time, so the only interesting data // for possible matches is the offset and DST availability; we'll take the first // match for those for (String zoneId: zoneIds) { timeZone = TimeZone.getTimeZone(zoneId); if (!timeZone.useDaylightTime()) { if (Eas.USER_LOG) { LogUtils.d(TAG, "TimeZone without DST found by offset: " + timeZone.getID()); } return timeZone; } } // None found, return null return null; } 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) * MINUTES; // 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 one minute before and after DST start transition long millisAtTransition = getMillisAtTimeZoneDateTransition(timeZone, dstStart); Date before = new Date(millisAtTransition - precision); Date after = new Date(millisAtTransition + precision); if (timeZone.inDaylightTime(before)) continue; if (!timeZone.inDaylightTime(after)) continue; // Check one minute before and after DST end transition millisAtTransition = getMillisAtTimeZoneDateTransition(timeZone, dstEnd); // Note that we need to subtract an extra hour here, because we end up with // gaining an hour in the transition BACK to standard time before = new Date(millisAtTransition - (dstSavings + precision)); after = new Date(millisAtTransition + precision); if (!timeZone.inDaylightTime(before)) continue; if (timeZone.inDaylightTime(after)) continue; // Check that the savings are the same if (dstSavings != timeZone.getDSTSavings()) continue; return timeZone; } boolean lenient = false; boolean name = false; if ((dstStart.hour != dstEnd.hour) && (precision == STANDARD_DST_PRECISION)) { timeZone = tziStringToTimeZoneImpl(timeZoneString, LENIENT_DST_PRECISION); lenient = true; } else { // We can't find a time zone match, so our last attempt is to see if there's // a valid time zone name in the TZI; if not we'll just take the first TZ with // a matching offset (which is likely wrong, but ... what else is there to do) String tzName = getString(timeZoneBytes, MSFT_TIME_ZONE_STANDARD_NAME_OFFSET, MSFT_TIME_ZONE_STRING_SIZE); if (!tzName.isEmpty()) { TimeZone tz = TimeZone.getTimeZone(tzName); if (tz != null) { timeZone = tz; name = true; } else { timeZone = TimeZone.getTimeZone(zoneIds[0]); } } else { timeZone = TimeZone.getTimeZone(zoneIds[0]); } } if (Eas.USER_LOG) { LogUtils.d(TAG, "No TimeZone with correct DST settings; using " + (name ? "name" : (lenient ? "lenient" : "first")) + ": " + timeZone.getID()); } return timeZone; } } return null; } static public String convertEmailDateTimeToCalendarDateTime(String date) { // Format for email date strings is 2010-02-23T16:00:00.000Z // Format for calendar date strings is 20100223T160000Z return date.substring(0, 4) + date.substring(5, 7) + date.substring(8, 13) + date.substring(14, 16) + date.substring(17, 19) + 'Z'; } static String formatTwo(int num) { if (num <= 12) { return sTwoCharacterNumbers[num]; } else return Integer.toString(num); } /** * Generate an EAS formatted date/time string based on GMT. See below for details. */ static public String millisToEasDateTime(long millis) { return millisToEasDateTime(millis, sGmtTimeZone, true); } /** * Generate a birthday string from a GregorianCalendar set appropriately; the format of this * string is YYYY-MM-DD * @param cal the calendar * @return the birthday string */ static public String calendarToBirthdayString(GregorianCalendar cal) { StringBuilder sb = new StringBuilder(); sb.append(cal.get(Calendar.YEAR)); sb.append('-'); sb.append(formatTwo(cal.get(Calendar.MONTH) + 1)); sb.append('-'); sb.append(formatTwo(cal.get(Calendar.DAY_OF_MONTH))); return sb.toString(); } /** * Generate an EAS formatted local date/time string from a time and a time zone. If the final * argument is false, only a date will be returned (e.g. 20100331) * @param millis a time in milliseconds * @param tz a time zone * @param withTime if the time is to be included in the string * @return an EAS formatted string indicating the date (and time) in the given time zone */ static public String millisToEasDateTime(long millis, TimeZone tz, boolean withTime) { StringBuilder sb = new StringBuilder(); GregorianCalendar cal = new GregorianCalendar(tz); 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))); if (withTime) { 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))); if (tz == sGmtTimeZone) { sb.append('Z'); } } return sb.toString(); } /** * Return the true minute at which a transition occurs * Our transition time should be the in the minute BEFORE the transition * If this minute is 59, set minute to 0 and increment the hour * NOTE: We don't want to add a minute and retrieve minute/hour from the Calendar, because * Calendar time will itself be influenced by the transition! So adding 1 minute to * 01:59 (assume PST->PDT) will become 03:00, which isn't what we want (we want 02:00) * * @param calendar the calendar holding the transition date/time * @return the true minute of the transition */ static int getTrueTransitionMinute(GregorianCalendar calendar) { int minute = calendar.get(Calendar.MINUTE); if (minute == 59) { minute = 0; } return minute; } /** * Return the true hour at which a transition occurs * See description for getTrueTransitionMinute, above * @param calendar the calendar holding the transition date/time * @return the true hour of the transition */ static int getTrueTransitionHour(GregorianCalendar calendar) { int hour = calendar.get(Calendar.HOUR_OF_DAY); hour++; if (hour == 24) { hour = 0; } return hour; } /** * Generate a date/time string suitable for VTIMEZONE from a transition time in millis * The format is YYYYMMDDTHHMMSS * @param millis a transition time in milliseconds * @param tz a time zone * @param dst whether we're entering daylight time */ static String transitionMillisToVCalendarTime(long millis, TimeZone tz, boolean dst) { StringBuilder sb = new StringBuilder(); GregorianCalendar cal = new GregorianCalendar(tz); 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(getTrueTransitionHour(cal))); sb.append(formatTwo(getTrueTransitionMinute(cal))); sb.append(formatTwo(0)); return sb.toString(); } /** * Returns a UTC calendar with year/month/day from local calendar and h/m/s/ms = 0 * @param time the time in seconds of an all-day event in local time * @return the time in seconds in UTC */ static public long getUtcAllDayCalendarTime(long time, TimeZone localTimeZone) { return transposeAllDayTime(time, localTimeZone, UTC_TIMEZONE); } /** * Returns a local calendar with year/month/day from UTC calendar and h/m/s/ms = 0 * @param time the time in seconds of an all-day event in UTC * @return the time in seconds in local time */ static public long getLocalAllDayCalendarTime(long time, TimeZone localTimeZone) { return transposeAllDayTime(time, UTC_TIMEZONE, localTimeZone); } static private long transposeAllDayTime(long time, TimeZone fromTimeZone, TimeZone toTimeZone) { GregorianCalendar fromCalendar = new GregorianCalendar(fromTimeZone); fromCalendar.setTimeInMillis(time); GregorianCalendar toCalendar = new GregorianCalendar(toTimeZone); // Set this calendar with correct year, month, and day, but zero hour, minute, and seconds toCalendar.set(fromCalendar.get(GregorianCalendar.YEAR), fromCalendar.get(GregorianCalendar.MONTH), fromCalendar.get(GregorianCalendar.DATE), 0, 0, 0); toCalendar.set(GregorianCalendar.MILLISECOND, 0); return toCalendar.getTimeInMillis(); } 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 addBySetpos(StringBuilder rrule, int dow, int wom) { // Indicate the days, but don't use wom in this case (it's used in the BYSETPOS); addByDay(rrule, dow, 0); rrule.append(";BYSETPOS="); rrule.append(wom == 5 ? "-1" : wom); } static void addByMonthDay(StringBuilder rrule, int dom) { // 127 means last day of the month if (dom == 127) { dom = -1; } rrule.append(";BYMONTHDAY=" + dom); } /** * Generate the String version of the EAS integer for a given BYDAY value in an rrule * @param dow the BYDAY value of the rrule * @return the String version of the EAS value of these days */ static String generateEasDayOfWeek(String dow) { int bits = 0; int bit = 1; for (String token: sDayTokens) { // If we can find the day in the dow String, add the bit to our bits value if (dow.indexOf(token) >= 0) { bits |= bit; } bit <<= 1; } return Integer.toString(bits); } /** * Extract the value of a token in an RRULE string * @param rrule an RRULE string * @param token a token to look for in the RRULE * @return the value of that token */ static String tokenFromRrule(String rrule, String token) { int start = rrule.indexOf(token); if (start < 0) return null; int len = rrule.length(); start += token.length(); int end = start; char c; do { c = rrule.charAt(end++); if ((c == ';') || (end == len)) { if (end == len) end++; return rrule.substring(start, end -1); } } while (true); } /** * Reformat an RRULE style UNTIL to an EAS style until */ @VisibleForTesting static String recurrenceUntilToEasUntil(String until) throws ParseException { // Get a calendar in our local time zone GregorianCalendar localCalendar = new GregorianCalendar(TimeZone.getDefault()); // Set the time per GMT time in the 'until' localCalendar.setTimeInMillis(Utility.parseDateTimeToMillis(until)); StringBuilder sb = new StringBuilder(); // Build a string with local year/month/date sb.append(localCalendar.get(Calendar.YEAR)); sb.append(formatTwo(localCalendar.get(Calendar.MONTH) + 1)); sb.append(formatTwo(localCalendar.get(Calendar.DAY_OF_MONTH))); // EAS ignores the time in 'until'; go figure sb.append("T000000Z"); return sb.toString(); } /** * Convenience method to add "count", "interval", and "until" to an EAS calendar stream * According to EAS docs, OCCURRENCES must always come before INTERVAL */ static private void addCountIntervalAndUntil(String rrule, Serializer s) throws IOException { String count = tokenFromRrule(rrule, "COUNT="); if (count != null) { s.data(Tags.CALENDAR_RECURRENCE_OCCURRENCES, count); } String interval = tokenFromRrule(rrule, "INTERVAL="); if (interval != null) { s.data(Tags.CALENDAR_RECURRENCE_INTERVAL, interval); } String until = tokenFromRrule(rrule, "UNTIL="); if (until != null) { try { s.data(Tags.CALENDAR_RECURRENCE_UNTIL, recurrenceUntilToEasUntil(until)); } catch (ParseException e) { LogUtils.w(TAG, "Parse error for CALENDAR_RECURRENCE_UNTIL tag.", e); } } } static private void addByDay(String byDay, Serializer s) throws IOException { // This can be 1WE (1st Wednesday) or -1FR (last Friday) int weekOfMonth = byDay.charAt(0); String bareByDay; if (weekOfMonth == '-') { // -1 is the only legal case (last week) Use "5" for EAS weekOfMonth = 5; bareByDay = byDay.substring(2); } else { weekOfMonth = weekOfMonth - '0'; bareByDay = byDay.substring(1); } s.data(Tags.CALENDAR_RECURRENCE_WEEKOFMONTH, Integer.toString(weekOfMonth)); s.data(Tags.CALENDAR_RECURRENCE_DAYOFWEEK, generateEasDayOfWeek(bareByDay)); } static private void addByDaySetpos(String byDay, String bySetpos, Serializer s) throws IOException { int weekOfMonth = bySetpos.charAt(0); if (weekOfMonth == '-') { // -1 is the only legal case (last week) Use "5" for EAS weekOfMonth = 5; } else { weekOfMonth = weekOfMonth - '0'; } s.data(Tags.CALENDAR_RECURRENCE_WEEKOFMONTH, Integer.toString(weekOfMonth)); s.data(Tags.CALENDAR_RECURRENCE_DAYOFWEEK, generateEasDayOfWeek(byDay)); } /** * Write recurrence information to EAS based on the RRULE in CalendarProvider * * @param rrule the RRULE, from CalendarProvider * @param startTime, the DTSTART of this Event * @param timeZone the time zone of the Event * @param s the Serializer we're using to write WBXML data * * @throws IOException */ // NOTE: For the moment, we're only parsing recurrence types that are supported by the // Calendar app UI, which is a subset of possible recurrence types // This code must be updated when the Calendar adds new functionality static public void recurrenceFromRrule(String rrule, long startTime, TimeZone timeZone, Serializer s) throws IOException { if (Eas.USER_LOG) { LogUtils.d(TAG, "RRULE: " + rrule); } final String freq = tokenFromRrule(rrule, "FREQ="); // If there's no FREQ=X, then we don't write a recurrence // Note that we duplicate s.start(Tags.CALENDAR_RECURRENCE); s.end(); to prevent the // possibility of writing out a partial recurrence stanza if (freq != null) { if (freq.equals("DAILY")) { s.start(Tags.CALENDAR_RECURRENCE); s.data(Tags.CALENDAR_RECURRENCE_TYPE, "0"); addCountIntervalAndUntil(rrule, s); s.end(); } else if (freq.equals("WEEKLY")) { s.start(Tags.CALENDAR_RECURRENCE); s.data(Tags.CALENDAR_RECURRENCE_TYPE, "1"); // Requires a day of week (whereas RRULE does not) addCountIntervalAndUntil(rrule, s); final String byDay = tokenFromRrule(rrule, "BYDAY="); if (byDay != null) { s.data(Tags.CALENDAR_RECURRENCE_DAYOFWEEK, generateEasDayOfWeek(byDay)); // Find week number (1-4 and 5 for last) if (byDay.startsWith("-1")) { s.data(Tags.CALENDAR_RECURRENCE_WEEKOFMONTH, "5"); } else { final char c = byDay.charAt(0); if (c >= '1' && c <= '4') { s.data(Tags.CALENDAR_RECURRENCE_WEEKOFMONTH, byDay.substring(0, 1)); } } } s.end(); } else if (freq.equals("MONTHLY")) { String byMonthDay = tokenFromRrule(rrule, "BYMONTHDAY="); if (byMonthDay != null) { s.start(Tags.CALENDAR_RECURRENCE); // Special case for last day of month if (byMonthDay.equals("-1")) { s.data(Tags.CALENDAR_RECURRENCE_TYPE, "3"); addCountIntervalAndUntil(rrule, s); s.data(Tags.CALENDAR_RECURRENCE_DAYOFWEEK, "127"); } else { // The nth day of the month s.data(Tags.CALENDAR_RECURRENCE_TYPE, "2"); addCountIntervalAndUntil(rrule, s); s.data(Tags.CALENDAR_RECURRENCE_DAYOFMONTH, byMonthDay); } s.end(); } else { final String byDay = tokenFromRrule(rrule, "BYDAY="); final String bySetpos = tokenFromRrule(rrule, "BYSETPOS="); if (byDay != null) { s.start(Tags.CALENDAR_RECURRENCE); s.data(Tags.CALENDAR_RECURRENCE_TYPE, "3"); addCountIntervalAndUntil(rrule, s); if (bySetpos != null) { addByDaySetpos(byDay, bySetpos, s); } else { addByDay(byDay, s); } s.end(); } else { // Neither BYDAY or BYMONTHDAY implies it's BYMONTHDAY based on DTSTART // Calculate the day from the startDate s.start(Tags.CALENDAR_RECURRENCE); final GregorianCalendar cal = new GregorianCalendar(); cal.setTimeInMillis(startTime); cal.setTimeZone(timeZone); byMonthDay = Integer.toString(cal.get(Calendar.DAY_OF_MONTH)); s.data(Tags.CALENDAR_RECURRENCE_TYPE, "2"); addCountIntervalAndUntil(rrule, s); s.data(Tags.CALENDAR_RECURRENCE_DAYOFMONTH, byMonthDay); s.end(); } } } else if (freq.equals("YEARLY")) { String byMonth = tokenFromRrule(rrule, "BYMONTH="); String byMonthDay = tokenFromRrule(rrule, "BYMONTHDAY="); final String byDay = tokenFromRrule(rrule, "BYDAY="); if (byMonth == null && byMonthDay == null) { // Calculate the month and day from the startDate final GregorianCalendar cal = new GregorianCalendar(); cal.setTimeInMillis(startTime); cal.setTimeZone(timeZone); byMonth = Integer.toString(cal.get(Calendar.MONTH) + 1); byMonthDay = Integer.toString(cal.get(Calendar.DAY_OF_MONTH)); } if (byMonth != null && (byMonthDay != null || byDay != null)) { s.start(Tags.CALENDAR_RECURRENCE); s.data(Tags.CALENDAR_RECURRENCE_TYPE, byDay == null ? "5" : "6"); addCountIntervalAndUntil(rrule, s); s.data(Tags.CALENDAR_RECURRENCE_MONTHOFYEAR, byMonth); // Note that both byMonthDay and byDay can't be true in a valid RRULE if (byMonthDay != null) { s.data(Tags.CALENDAR_RECURRENCE_DAYOFMONTH, byMonthDay); } else { addByDay(byDay, s); } s.end(); } } } } /** * Build an RRULE String from EAS recurrence information * @param type the type of recurrence * @param occurrences how many recurrences (instances) * @param interval the interval between recurrences * @param dow day of the week * @param dom day of the month * @param wom week of the month * @param moy month of the year * @param until the last recurrence time * @return a valid RRULE String */ static public String rruleFromRecurrence(int type, int occurrences, int interval, int dow, int dom, int wom, int moy, String until) { if (type < 0 || type >= sTypeToFreq.length) { return null; } final String typeStr = sTypeToFreq[type]; // Type array is sparse (eg, no type 4), so catch invalid (empty) types if (TextUtils.isEmpty(typeStr)) { return null; } StringBuilder rrule = new StringBuilder("FREQ=" + typeStr); // INTERVAL and COUNT if (occurrences > 0) { rrule.append(";COUNT=" + occurrences); } if (interval > 0) { rrule.append(";INTERVAL=" + interval); } // Days, weeks, months, etc. switch(type) { case 0: // DAILY case 1: // WEEKLY if (dow > 0) addByDay(rrule, dow, wom); break; case 2: // MONTHLY if (dom > 0) addByMonthDay(rrule, dom); break; case 3: // MONTHLY (on the nth day) // 127 is a special case meaning "last day of the month" if (dow == 127) { rrule.append(";BYMONTHDAY=-1"); // week 5 and dow = weekdays -> last weekday (need BYSETPOS) } else if ((wom == 5 || wom == 1) && (dow == EAS_WEEKDAYS || dow == EAS_WEEKENDS)) { addBySetpos(rrule, dow, wom); } else if (dow > 0) addByDay(rrule, dow, wom); break; case 5: // YEARLY (specific day) if (dom > 0) addByMonthDay(rrule, dom); if (moy > 0) { rrule.append(";BYMONTH=" + moy); } break; case 6: // YEARLY if (dow > 0) addByDay(rrule, dow, wom); if (dom > 0) addByMonthDay(rrule, dom); if (moy > 0) { rrule.append(";BYMONTH=" + moy); } break; default: break; } // UNTIL comes last if (until != null) { rrule.append(";UNTIL=" + until); } if (Eas.USER_LOG) { LogUtils.d(Logging.LOG_TAG, "Created rrule: " + rrule); } return rrule.toString(); } /** * Create a Calendar in CalendarProvider to which synced Events will be linked * @param context * @param contentResolver * @param account the account being synced * @param mailbox the Exchange mailbox for the calendar * @return the unique id of the Calendar */ static public long createCalendar(final Context context, final ContentResolver contentResolver, final Account account, final Mailbox mailbox) { // Create a Calendar object ContentValues cv = new ContentValues(); // TODO How will this change if the user changes his account display name? cv.put(Calendars.CALENDAR_DISPLAY_NAME, mailbox.mDisplayName); cv.put(Calendars.ACCOUNT_NAME, account.mEmailAddress); cv.put(Calendars.ACCOUNT_TYPE, Eas.EXCHANGE_ACCOUNT_MANAGER_TYPE); cv.put(Calendars.SYNC_EVENTS, 1); cv.put(Calendars._SYNC_ID, mailbox.mServerId); cv.put(Calendars.VISIBLE, 1); // Don't show attendee status if we're the organizer cv.put(Calendars.CAN_ORGANIZER_RESPOND, 0); cv.put(Calendars.CAN_MODIFY_TIME_ZONE, 0); cv.put(Calendars.MAX_REMINDERS, 1); cv.put(Calendars.ALLOWED_REMINDERS, ALLOWED_REMINDER_TYPES); cv.put(Calendars.ALLOWED_ATTENDEE_TYPES, ALLOWED_ATTENDEE_TYPES); cv.put(Calendars.ALLOWED_AVAILABILITY, ALLOWED_AVAILABILITIES); // TODO Coordinate account colors w/ Calendar, if possible int color = new AccountServiceProxy(context).getAccountColor(account.mId); cv.put(Calendars.CALENDAR_COLOR, color); cv.put(Calendars.CALENDAR_TIME_ZONE, Time.getCurrentTimezone()); cv.put(Calendars.CALENDAR_ACCESS_LEVEL, Calendars.CAL_ACCESS_OWNER); cv.put(Calendars.OWNER_ACCOUNT, account.mEmailAddress); if (TextUtils.equals(mailbox.mDisplayName, account.mEmailAddress)) { cv.put(Calendars.IS_PRIMARY, 1); } else { cv.put(Calendars.IS_PRIMARY, 0); } Uri uri = contentResolver.insert(asSyncAdapter(Calendars.CONTENT_URI, account.mEmailAddress, Eas.EXCHANGE_ACCOUNT_MANAGER_TYPE), cv); // We save the id of the calendar into mSyncStatus if (uri != null) { String stringId = uri.getPathSegments().get(1); mailbox.mSyncStatus = stringId; return Long.parseLong(stringId); } return -1; } static Uri asSyncAdapter(Uri uri, String account, String accountType) { return uri.buildUpon() .appendQueryParameter(android.provider.CalendarContract.CALLER_IS_SYNCADAPTER, "true") .appendQueryParameter(Calendars.ACCOUNT_NAME, account) .appendQueryParameter(Calendars.ACCOUNT_TYPE, accountType).build(); } /** * Return the uid for an event based on its globalObjId * @param globalObjId the base64 encoded String provided by EAS * @return the uid for the calendar event */ static public String getUidFromGlobalObjId(String globalObjId) { StringBuilder sb = new StringBuilder(); // First get the decoded base64 try { byte[] idBytes = Base64.decode(globalObjId, Base64.DEFAULT); String idString = new String(idBytes); // If the base64 decoded string contains the magic substring: "vCal-Uid", then // the actual uid is hidden within; the magic substring is never at the start of the // decoded base64 int index = idString.indexOf("vCal-Uid"); if (index > 0) { // The uid starts after "vCal-Uidxxxx", where xxxx are padding // characters. And it ends before the last character, which is ascii 0 return idString.substring(index + 12, idString.length() - 1); } else { // This is an EAS uid. Go through the bytes and write out the hex // values as characters; this is what we'll need to pass back to EAS // when responding to the invitation for (byte b: idBytes) { Utility.byteToHex(sb, b); } return sb.toString(); } } catch (RuntimeException e) { // In the worst of cases (bad format, etc.), we can always return the input return globalObjId; } } /** * Get a selfAttendeeStatus from a busy status * The default here is NONE (i.e. we don't know the status) * Note that a busy status of FREE must mean NONE as well, since it can't mean declined * (there would be no event) * @param busyStatus the busy status, from EAS * @return the corresponding value for selfAttendeeStatus */ static public int attendeeStatusFromBusyStatus(int busyStatus) { int attendeeStatus; switch (busyStatus) { case BUSY_STATUS_BUSY: attendeeStatus = Attendees.ATTENDEE_STATUS_ACCEPTED; break; case BUSY_STATUS_TENTATIVE: attendeeStatus = Attendees.ATTENDEE_STATUS_TENTATIVE; break; case BUSY_STATUS_FREE: case BUSY_STATUS_OUT_OF_OFFICE: default: attendeeStatus = Attendees.ATTENDEE_STATUS_NONE; } return attendeeStatus; } /** * Get a selfAttendeeStatus from a response type (EAS 14+) * The default here is NONE (i.e. we don't know the status), though in theory this can't happen * @param busyStatus the response status, from EAS * @return the corresponding value for selfAttendeeStatus */ static public int attendeeStatusFromResponseType(int responseType) { int attendeeStatus; switch (responseType) { case RESPONSE_TYPE_NOT_RESPONDED: attendeeStatus = Attendees.ATTENDEE_STATUS_INVITED; break; case RESPONSE_TYPE_ACCEPTED: attendeeStatus = Attendees.ATTENDEE_STATUS_ACCEPTED; break; case RESPONSE_TYPE_TENTATIVE: attendeeStatus = Attendees.ATTENDEE_STATUS_TENTATIVE; break; case RESPONSE_TYPE_DECLINED: attendeeStatus = Attendees.ATTENDEE_STATUS_DECLINED; break; default: attendeeStatus = Attendees.ATTENDEE_STATUS_NONE; } return attendeeStatus; } /** Get a busy status from a selfAttendeeStatus * The default here is BUSY * @param selfAttendeeStatus from CalendarProvider2 * @return the corresponding value of busy status */ static public int busyStatusFromAttendeeStatus(int selfAttendeeStatus) { int busyStatus; switch (selfAttendeeStatus) { case Attendees.ATTENDEE_STATUS_DECLINED: case Attendees.ATTENDEE_STATUS_NONE: case Attendees.ATTENDEE_STATUS_INVITED: busyStatus = BUSY_STATUS_FREE; break; case Attendees.ATTENDEE_STATUS_TENTATIVE: busyStatus = BUSY_STATUS_TENTATIVE; break; case Attendees.ATTENDEE_STATUS_ACCEPTED: default: busyStatus = BUSY_STATUS_BUSY; break; } return busyStatus; } /** Get a busy status from event availability * The default here is TENTATIVE * @param availability from CalendarProvider2 * @return the corresponding value of busy status */ static public int busyStatusFromAvailability(int availability) { int busyStatus; switch (availability) { case Events.AVAILABILITY_BUSY: busyStatus = BUSY_STATUS_BUSY; break; case Events.AVAILABILITY_FREE: busyStatus = BUSY_STATUS_FREE; break; case Events.AVAILABILITY_TENTATIVE: default: busyStatus = BUSY_STATUS_TENTATIVE; break; } return busyStatus; } /** Get an event availability from busy status * The default here is TENTATIVE * @param busyStatus from CalendarProvider2 * @return the corresponding availability value */ static public int availabilityFromBusyStatus(int busyStatus) { int availability; switch (busyStatus) { case BUSY_STATUS_BUSY: availability = Events.AVAILABILITY_BUSY; break; case BUSY_STATUS_FREE: availability = Events.AVAILABILITY_FREE; break; case BUSY_STATUS_TENTATIVE: default: availability = Events.AVAILABILITY_TENTATIVE; break; } return availability; } static public String buildMessageTextFromEntityValues(Context context, ContentValues entityValues, StringBuilder sb) { if (sb == null) { sb = new StringBuilder(); } Resources resources = context.getResources(); // 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 // For now, we'll just use the meeting_recurring string boolean allDayEvent = false; if (entityValues.containsKey(Events.ALL_DAY)) { Integer ade = entityValues.getAsInteger(Events.ALL_DAY); allDayEvent = (ade != null) && (ade == 1); } boolean recurringEvent = !entityValues.containsKey(Events.ORIGINAL_SYNC_ID) && entityValues.containsKey(Events.RRULE); if (entityValues.containsKey(Events.DTSTART)) { final String dateTimeString; final int res; final long startTime = entityValues.getAsLong(Events.DTSTART); if (allDayEvent) { final Date date = new Date(getLocalAllDayCalendarTime(startTime, TimeZone.getDefault())); dateTimeString = DateFormat.getDateInstance().format(date); res = recurringEvent ? R.string.meeting_allday_recurring : R.string.meeting_allday; } else { dateTimeString = DateFormat.getDateTimeInstance().format( new Date(startTime)); res = recurringEvent ? R.string.meeting_recurring : R.string.meeting_when; } sb.append(resources.getString(res, dateTimeString)); } String location = null; if (entityValues.containsKey(Events.EVENT_LOCATION)) { location = entityValues.getAsString(Events.EVENT_LOCATION); if (!TextUtils.isEmpty(location)) { 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(); } /** * Add an attendee to the ics attachment and the to list of the Message being composed * @param ics the ics attachment writer * @param toList the list of addressees for this email * @param attendeeName the name of the attendee * @param attendeeEmail the email address of the attendee * @param messageFlag the flag indicating the action to be indicated by the message * @param account the sending account of the email */ static private void addAttendeeToMessage(SimpleIcsWriter ics, ArrayList
toList, String attendeeName, String attendeeEmail, int messageFlag, Account account) { if ((messageFlag & Message.FLAG_OUTGOING_MEETING_REQUEST_MASK) != 0) { String icalTag = ICALENDAR_ATTENDEE_INVITE; if ((messageFlag & Message.FLAG_OUTGOING_MEETING_CANCEL) != 0) { icalTag = ICALENDAR_ATTENDEE_CANCEL; } if (attendeeName != null) { icalTag += ";CN=" + SimpleIcsWriter.quoteParamValue(attendeeName); } ics.writeTag(icalTag, "MAILTO:" + attendeeEmail); toList.add(attendeeName == null ? new Address(attendeeEmail) : new Address(attendeeEmail, attendeeName)); } else if (attendeeEmail.equalsIgnoreCase(account.mEmailAddress)) { String icalTag = null; switch (messageFlag) { case Message.FLAG_OUTGOING_MEETING_ACCEPT: icalTag = ICALENDAR_ATTENDEE_ACCEPT; break; case Message.FLAG_OUTGOING_MEETING_DECLINE: icalTag = ICALENDAR_ATTENDEE_DECLINE; break; case Message.FLAG_OUTGOING_MEETING_TENTATIVE: icalTag = ICALENDAR_ATTENDEE_TENTATIVE; break; } if (icalTag != null) { if (attendeeName != null) { icalTag += ";CN=" + SimpleIcsWriter.quoteParamValue(attendeeName); } ics.writeTag(icalTag, "MAILTO:" + attendeeEmail); } } } /** * Create a Message for an (Event) Entity * @param entity the Entity for the Event (as might be retrieved by CalendarProvider) * @param messageFlag the Message.FLAG_XXX constant indicating the type of email to be sent * @param uid the unique id of this Event, or null if it can be retrieved from the Event * @param account the user's account * @return a Message with many fields pre-filled (more later) */ static public Message createMessageForEntity(Context context, Entity entity, int messageFlag, String uid, Account account) { return createMessageForEntity(context, entity, messageFlag, uid, account, null /*specifiedAttendee*/); } static public EmailContent.Message createMessageForEntity(Context context, Entity entity, int messageFlag, String uid, Account account, String specifiedAttendee) { ContentValues entityValues = entity.getEntityValues(); ArrayList subValues = entity.getSubValues(); boolean isException = entityValues.containsKey(Events.ORIGINAL_INSTANCE_TIME); boolean isReply = false; EmailContent.Message msg = new EmailContent.Message(); msg.mFlags = messageFlag; msg.mTimeStamp = System.currentTimeMillis(); String method; if ((messageFlag & EmailContent.Message.FLAG_OUTGOING_MEETING_INVITE) != 0) { method = "REQUEST"; } else if ((messageFlag & EmailContent.Message.FLAG_OUTGOING_MEETING_CANCEL) != 0) { method = "CANCEL"; } else { method = "REPLY"; isReply = true; } try { // Create our iCalendar writer and start generating tags SimpleIcsWriter ics = new SimpleIcsWriter(); ics.writeTag("BEGIN", "VCALENDAR"); ics.writeTag("METHOD", method); ics.writeTag("PRODID", "AndroidEmail"); ics.writeTag("VERSION", "2.0"); // Our default vcalendar time zone is UTC, but this will change (below) if we're // sending a recurring event, in which case we use local time TimeZone vCalendarTimeZone = sGmtTimeZone; String vCalendarDateSuffix = ""; // Check for all day event boolean allDayEvent = false; if (entityValues.containsKey(Events.ALL_DAY)) { Integer ade = entityValues.getAsInteger(Events.ALL_DAY); allDayEvent = (ade != null) && (ade == 1); if (allDayEvent) { // Example: DTSTART;VALUE=DATE:20100331 (all day event) vCalendarDateSuffix = ";VALUE=DATE"; } } // If we're inviting people and the meeting is recurring, we need to send our time zone // information and make sure to send DTSTART/DTEND in local time (unless, of course, // this is an all-day event). Recurring, for this purpose, includes exceptions to // recurring events if (!isReply && !allDayEvent && (entityValues.containsKey(Events.RRULE) || entityValues.containsKey(Events.ORIGINAL_SYNC_ID))) { vCalendarTimeZone = TimeZone.getDefault(); // Write the VTIMEZONE block to the writer timeZoneToVTimezone(vCalendarTimeZone, ics); // Example: DTSTART;TZID=US/Pacific:20100331T124500 vCalendarDateSuffix = ";TZID=" + vCalendarTimeZone.getID(); } ics.writeTag("BEGIN", "VEVENT"); if (uid == null) { uid = entityValues.getAsString(Events.SYNC_DATA2); } if (uid != null) { ics.writeTag("UID", uid); } if (entityValues.containsKey("DTSTAMP")) { ics.writeTag("DTSTAMP", entityValues.getAsString("DTSTAMP")); } else { ics.writeTag("DTSTAMP", millisToEasDateTime(System.currentTimeMillis())); } long startTime = entityValues.getAsLong(Events.DTSTART); if (startTime != 0) { ics.writeTag("DTSTART" + vCalendarDateSuffix, millisToEasDateTime(startTime, vCalendarTimeZone, !allDayEvent)); } // If this is an Exception, we send the recurrence-id, which is just the original // instance time if (isException) { // isException indicates this key is present long originalTime = entityValues.getAsLong(Events.ORIGINAL_INSTANCE_TIME); ics.writeTag("RECURRENCE-ID" + vCalendarDateSuffix, millisToEasDateTime(originalTime, vCalendarTimeZone, !allDayEvent)); } if (!entityValues.containsKey(Events.DURATION)) { if (entityValues.containsKey(Events.DTEND)) { ics.writeTag("DTEND" + vCalendarDateSuffix, millisToEasDateTime( entityValues.getAsLong(Events.DTEND), vCalendarTimeZone, !allDayEvent)); } } else { // Convert this into millis and add it to DTSTART for DTEND // We'll use 1 hour as a default long durationMillis = HOURS; Duration duration = new Duration(); try { duration.parse(entityValues.getAsString(Events.DURATION)); durationMillis = duration.getMillis(); } catch (DateException e) { // We'll use the default in this case } ics.writeTag("DTEND" + vCalendarDateSuffix, millisToEasDateTime( startTime + durationMillis, vCalendarTimeZone, !allDayEvent)); } String location = null; if (entityValues.containsKey(Events.EVENT_LOCATION)) { location = entityValues.getAsString(Events.EVENT_LOCATION); ics.writeTag("LOCATION", location); } String sequence = entityValues.getAsString(SYNC_VERSION); if (sequence == null) { sequence = "0"; } // We'll use 0 to mean a meeting invitation int titleId = 0; switch (messageFlag) { case Message.FLAG_OUTGOING_MEETING_INVITE: if (!sequence.equals("0")) { titleId = R.string.meeting_updated; } break; case Message.FLAG_OUTGOING_MEETING_ACCEPT: titleId = R.string.meeting_accepted; break; case Message.FLAG_OUTGOING_MEETING_DECLINE: titleId = R.string.meeting_declined; break; case Message.FLAG_OUTGOING_MEETING_TENTATIVE: titleId = R.string.meeting_tentative; break; case Message.FLAG_OUTGOING_MEETING_CANCEL: titleId = R.string.meeting_canceled; break; } Resources resources = context.getResources(); String title = entityValues.getAsString(Events.TITLE); if (title == null) { title = ""; } ics.writeTag("SUMMARY", title); // For meeting invitations just use the title if (titleId == 0) { msg.mSubject = title; } else { // Otherwise, use the additional text msg.mSubject = resources.getString(titleId, title); } // Build the text for the message, starting with an initial line describing the // exception (if this is one) StringBuilder sb = new StringBuilder(); if (isException && !isReply) { // Add the line, depending on whether this is a cancellation or update // isException indicates this key is present 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 (text.length() > 0) { ics.writeTag("DESCRIPTION", text); } // And store the message text msg.mText = text; if (!isReply) { if (entityValues.containsKey(Events.ALL_DAY)) { Integer ade = entityValues.getAsInteger(Events.ALL_DAY); ics.writeTag("X-MICROSOFT-CDO-ALLDAYEVENT", ade == 0 ? "FALSE" : "TRUE"); } String rrule = entityValues.getAsString(Events.RRULE); if (rrule != null) { ics.writeTag("RRULE", rrule); } // If we decide to send alarm information in the meeting request ics file, // handle it here by looping through the subvalues } // Handle attendee data here; determine "to" list and add ATTENDEE tags to ics String organizerName = null; String organizerEmail = null; ArrayList
toList = new ArrayList
(); for (NamedContentValues ncv: subValues) { Uri ncvUri = ncv.uri; ContentValues ncvValues = ncv.values; if (ncvUri.equals(Attendees.CONTENT_URI)) { final Integer relationship = ncvValues.getAsInteger(Attendees.ATTENDEE_RELATIONSHIP); final String attendeeEmail = ncvValues.getAsString(Attendees.ATTENDEE_EMAIL); // If there's no relationship, we can't create this for EAS // Similarly, we need an attendee email for each invitee if (relationship != null && !TextUtils.isEmpty(attendeeEmail)) { // Organizer isn't among attendees in EAS if (relationship == Attendees.RELATIONSHIP_ORGANIZER) { organizerName = ncvValues.getAsString(Attendees.ATTENDEE_NAME); organizerEmail = attendeeEmail; continue; } String attendeeName = ncvValues.getAsString(Attendees.ATTENDEE_NAME); // If we only want to send to the specifiedAttendee, eliminate others here if ((specifiedAttendee != null) && !attendeeEmail.equalsIgnoreCase(specifiedAttendee)) { continue; } addAttendeeToMessage(ics, toList, attendeeName, attendeeEmail, messageFlag, account); } } } // Manually add the specifiedAttendee if he wasn't added in the Attendees loop if (toList.isEmpty() && (specifiedAttendee != null)) { addAttendeeToMessage(ics, toList, null, specifiedAttendee, messageFlag, account); } // Create the organizer tag for ical if (organizerEmail != null) { String icalTag = "ORGANIZER"; // We should be able to find this, assuming the Email is the user's email // TODO Find this in the account if (organizerName != null) { icalTag += ";CN=" + SimpleIcsWriter.quoteParamValue(organizerName); } ics.writeTag(icalTag, "MAILTO:" + organizerEmail); if (isReply) { toList.add(organizerName == null ? new Address(organizerEmail) : new Address(organizerEmail, organizerName)); } } // If we have no "to" list, we're done if (toList.isEmpty()) return null; // Write out the "to" list Address[] toArray = new Address[toList.size()]; int i = 0; for (Address address: toList) { toArray[i++] = address; } msg.mTo = Address.toHeader(toArray); ics.writeTag("CLASS", "PUBLIC"); 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", sequence); ics.writeTag("END", "VEVENT"); ics.writeTag("END", "VCALENDAR"); // Create the ics attachment using the "content" field Attachment att = new Attachment(); att.mContentBytes = ics.getBytes(); att.mMimeType = "text/calendar; method=" + method; att.mFileName = "invite.ics"; att.mSize = att.mContentBytes.length; // We don't send content-disposition with this attachment att.mFlags = Attachment.FLAG_ICS_ALTERNATIVE_PART; // Add the attachment to the message msg.mAttachments = new ArrayList(); msg.mAttachments.add(att); } catch (IOException e) { LogUtils.w(TAG, "IOException in createMessageForEntity"); return null; } // Return the new Message to caller return msg; } /** * Create a Message for an Event that can be retrieved from CalendarProvider * by its unique id * * @param cr a content resolver that can be used to query for the Event * @param eventId the unique id of the Event * @param messageFlag the Message.FLAG_XXX constant indicating the type of * email to be sent * @param the unique id of this Event, or null if it can be retrieved from * the Event * @param the user's account * @param requireAddressees if true (the default), no Message is returned if * there aren't any addressees; if false, return the Message * regardless (addressees will be filled in later) * @return a Message with many fields pre-filled (more later) */ static public EmailContent.Message createMessageForEventId(Context context, long eventId, int messageFlag, String uid, Account account) { return createMessageForEventId(context, eventId, messageFlag, uid, account, null /* specifiedAttendee */); } static public EmailContent.Message createMessageForEventId(Context context, long eventId, int messageFlag, String uid, Account account, String specifiedAttendee) { final ContentResolver cr = context.getContentResolver(); final Cursor cursor = cr.query(ContentUris.withAppendedId( Events.CONTENT_URI, eventId), null, null, null, null); if (cursor == null) { return null; } final EntityIterator eventIterator = EventsEntity.newEntityIterator(cursor, cr); try { while (eventIterator.hasNext()) { Entity entity = eventIterator.next(); return createMessageForEntity(context, entity, messageFlag, uid, account, specifiedAttendee); } } finally { eventIterator.close(); } return null; } /** * Return a boolean value for an integer ContentValues column * @param values a ContentValues object * @param columnName the name of a column to be found in the ContentValues * @return a boolean representation of the value of columnName in values; null and 0 = false, * other integers = true */ static public boolean getIntegerValueAsBoolean(ContentValues values, String columnName) { Integer intValue = values.getAsInteger(columnName); return (intValue != null && intValue != 0); } }