| /* |
| * 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.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.ExchangeService; |
| 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.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 = "CalendarUtility"; |
| |
| // 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<String, TimeZone> sTimeZoneCache = new HashMap<String, TimeZone>(); |
| // TZI string cache; we keep around our encoded TimeZoneInformation strings |
| private static HashMap<TimeZone, String> sTziStringCache = new HashMap<TimeZone, String>(); |
| |
| 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) { |
| ExchangeService.log(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) { |
| ExchangeService.log(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... |
| ExchangeService.alwaysLog("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) { |
| ExchangeService.log(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) { |
| ExchangeService.log(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) { |
| ExchangeService.log(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) { |
| // 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) { |
| s.data(Tags.CALENDAR_RECURRENCE_UNTIL, recurrenceUntilToEasUntil(until)); |
| } |
| } |
| |
| 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 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, Serializer s) |
| throws IOException { |
| if (Eas.USER_LOG) { |
| ExchangeService.log(TAG, "RRULE: " + rrule); |
| } |
| 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); |
| 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 { |
| 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 == "-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 { |
| String byDay = tokenFromRrule(rrule, "BYDAY="); |
| 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 if (freq.equals("YEARLY")) { |
| String byMonth = tokenFromRrule(rrule, "BYMONTH="); |
| String byMonthDay = tokenFromRrule(rrule, "BYMONTHDAY="); |
| String byDay = tokenFromRrule(rrule, "BYDAY="); |
| if (byMonth == null && byMonthDay == null) { |
| // Calculate the month and day from the startDate |
| GregorianCalendar cal = new GregorianCalendar(); |
| cal.setTimeInMillis(startTime); |
| cal.setTimeZone(TimeZone.getDefault()); |
| 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) { |
| StringBuilder rrule = new StringBuilder("FREQ=" + sTypeToFreq[type]); |
| // 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, account.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.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); |
| |
| 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_NONE; |
| 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); |
| |
| String dateTimeString; |
| int res; |
| long startTime = entityValues.getAsLong(Events.DTSTART); |
| if (allDayEvent) { |
| 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<Address> 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 the unique id of this Event, or null if it can be retrieved from the Event |
| * @param 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<NamedContentValues> subValues = entity.getSubValues(); |
| boolean isException = entityValues.containsKey(Events.ORIGINAL_SYNC_ID); |
| 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) { |
| 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 |
| 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<Address> toList = new ArrayList<Address>(); |
| for (NamedContentValues ncv: subValues) { |
| Uri ncvUri = ncv.uri; |
| ContentValues ncvValues = ncv.values; |
| if (ncvUri.equals(Attendees.CONTENT_URI)) { |
| Integer relationship = |
| ncvValues.getAsInteger(Attendees.ATTENDEE_RELATIONSHIP); |
| // If there's no relationship, we can't create this for EAS |
| // Similarly, we need an attendee email for each invitee |
| if (relationship != null && |
| ncvValues.containsKey(Attendees.ATTENDEE_EMAIL)) { |
| // Organizer isn't among attendees in EAS |
| if (relationship == Attendees.RELATIONSHIP_ORGANIZER) { |
| organizerName = ncvValues.getAsString(Attendees.ATTENDEE_NAME); |
| organizerEmail = ncvValues.getAsString(Attendees.ATTENDEE_EMAIL); |
| continue; |
| } |
| String attendeeEmail = ncvValues.getAsString(Attendees.ATTENDEE_EMAIL); |
| String attendeeName = ncvValues.getAsString(Attendees.ATTENDEE_NAME); |
| |
| // This shouldn't be possible, but allow for it |
| if (attendeeEmail == null) continue; |
| // 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.pack(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<Attachment>(); |
| 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) { |
| ContentResolver cr = context.getContentResolver(); |
| EntityIterator eventIterator = EventsEntity.newEntityIterator(cr.query( |
| ContentUris.withAppendedId(Events.CONTENT_URI, eventId), null, null, null, null), |
| 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); |
| } |
| } |