• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
2  * Copyright (C) 2010 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package com.android.exchange.utility;
18 
19 import android.content.ContentResolver;
20 import android.content.ContentUris;
21 import android.content.ContentValues;
22 import android.content.Context;
23 import android.content.Entity;
24 import android.content.Entity.NamedContentValues;
25 import android.content.EntityIterator;
26 import android.content.res.Resources;
27 import android.net.Uri;
28 import android.os.RemoteException;
29 import android.provider.CalendarContract.Attendees;
30 import android.provider.CalendarContract.Calendars;
31 import android.provider.CalendarContract.Events;
32 import android.provider.CalendarContract.EventsEntity;
33 import android.text.TextUtils;
34 import android.text.format.Time;
35 import android.util.Base64;
36 import android.util.Log;
37 
38 import com.android.calendarcommon2.DateException;
39 import com.android.calendarcommon2.Duration;
40 import com.android.emailcommon.Logging;
41 import com.android.emailcommon.mail.Address;
42 import com.android.emailcommon.provider.Account;
43 import com.android.emailcommon.provider.EmailContent;
44 import com.android.emailcommon.provider.EmailContent.Attachment;
45 import com.android.emailcommon.provider.EmailContent.Message;
46 import com.android.emailcommon.provider.Mailbox;
47 import com.android.emailcommon.service.AccountServiceProxy;
48 import com.android.emailcommon.utility.Utility;
49 import com.android.exchange.Eas;
50 import com.android.exchange.EasSyncService;
51 import com.android.exchange.ExchangeService;
52 import com.android.exchange.R;
53 import com.android.exchange.adapter.Serializer;
54 import com.android.exchange.adapter.Tags;
55 import com.android.internal.util.ArrayUtils;
56 import com.google.common.annotations.VisibleForTesting;
57 
58 import java.io.IOException;
59 import java.text.DateFormat;
60 import java.util.ArrayList;
61 import java.util.Calendar;
62 import java.util.Date;
63 import java.util.GregorianCalendar;
64 import java.util.HashMap;
65 import java.util.TimeZone;
66 
67 public class CalendarUtilities {
68 
69     // NOTE: Most definitions in this class are have package visibility for testing purposes
70     private static final String TAG = "CalendarUtility";
71 
72     // Time related convenience constants, in milliseconds
73     static final int SECONDS = 1000;
74     static final int MINUTES = SECONDS*60;
75     static final int HOURS = MINUTES*60;
76     static final long DAYS = HOURS*24;
77 
78     // We want to find a time zone whose DST info is accurate to one minute
79     static final int STANDARD_DST_PRECISION = MINUTES;
80     // If we can't find one, we'll try a more lenient standard (this is better than guessing a
81     // time zone, which is what we otherwise do).  Note that this specifically addresses an issue
82     // seen in some time zones sent by MS Exchange in which the start and end hour differ
83     // for no apparent reason
84     static final int LENIENT_DST_PRECISION = 4*HOURS;
85 
86     private static final String SYNC_VERSION = Events.SYNC_DATA4;
87     // NOTE All Microsoft data structures are little endian
88 
89     // The following constants relate to standard Microsoft data sizes
90     // For documentation, see http://msdn.microsoft.com/en-us/library/aa505945.aspx
91     static final int MSFT_LONG_SIZE = 4;
92     static final int MSFT_WCHAR_SIZE = 2;
93     static final int MSFT_WORD_SIZE = 2;
94 
95     // The following constants relate to Microsoft's SYSTEMTIME structure
96     // For documentation, see: http://msdn.microsoft.com/en-us/library/ms724950(VS.85).aspx?ppud=4
97 
98     static final int MSFT_SYSTEMTIME_YEAR = 0 * MSFT_WORD_SIZE;
99     static final int MSFT_SYSTEMTIME_MONTH = 1 * MSFT_WORD_SIZE;
100     static final int MSFT_SYSTEMTIME_DAY_OF_WEEK = 2 * MSFT_WORD_SIZE;
101     static final int MSFT_SYSTEMTIME_DAY = 3 * MSFT_WORD_SIZE;
102     static final int MSFT_SYSTEMTIME_HOUR = 4 * MSFT_WORD_SIZE;
103     static final int MSFT_SYSTEMTIME_MINUTE = 5 * MSFT_WORD_SIZE;
104     //static final int MSFT_SYSTEMTIME_SECONDS = 6 * MSFT_WORD_SIZE;
105     //static final int MSFT_SYSTEMTIME_MILLIS = 7 * MSFT_WORD_SIZE;
106     static final int MSFT_SYSTEMTIME_SIZE = 8*MSFT_WORD_SIZE;
107 
108     // The following constants relate to Microsoft's TIME_ZONE_INFORMATION structure
109     // For documentation, see http://msdn.microsoft.com/en-us/library/ms725481(VS.85).aspx
110     static final int MSFT_TIME_ZONE_STRING_SIZE = 32;
111 
112     static final int MSFT_TIME_ZONE_BIAS_OFFSET = 0;
113     static final int MSFT_TIME_ZONE_STANDARD_NAME_OFFSET =
114         MSFT_TIME_ZONE_BIAS_OFFSET + MSFT_LONG_SIZE;
115     static final int MSFT_TIME_ZONE_STANDARD_DATE_OFFSET =
116         MSFT_TIME_ZONE_STANDARD_NAME_OFFSET + (MSFT_WCHAR_SIZE*MSFT_TIME_ZONE_STRING_SIZE);
117     static final int MSFT_TIME_ZONE_STANDARD_BIAS_OFFSET =
118         MSFT_TIME_ZONE_STANDARD_DATE_OFFSET + MSFT_SYSTEMTIME_SIZE;
119     static final int MSFT_TIME_ZONE_DAYLIGHT_NAME_OFFSET =
120         MSFT_TIME_ZONE_STANDARD_BIAS_OFFSET + MSFT_LONG_SIZE;
121     static final int MSFT_TIME_ZONE_DAYLIGHT_DATE_OFFSET =
122         MSFT_TIME_ZONE_DAYLIGHT_NAME_OFFSET + (MSFT_WCHAR_SIZE*MSFT_TIME_ZONE_STRING_SIZE);
123     static final int MSFT_TIME_ZONE_DAYLIGHT_BIAS_OFFSET =
124         MSFT_TIME_ZONE_DAYLIGHT_DATE_OFFSET + MSFT_SYSTEMTIME_SIZE;
125     static final int MSFT_TIME_ZONE_SIZE =
126         MSFT_TIME_ZONE_DAYLIGHT_BIAS_OFFSET + MSFT_LONG_SIZE;
127 
128     // TimeZone cache; we parse/decode as little as possible, because the process is quite slow
129     private static HashMap<String, TimeZone> sTimeZoneCache = new HashMap<String, TimeZone>();
130     // TZI string cache; we keep around our encoded TimeZoneInformation strings
131     private static HashMap<TimeZone, String> sTziStringCache = new HashMap<TimeZone, String>();
132 
133     private static final TimeZone UTC_TIMEZONE = TimeZone.getTimeZone("UTC");
134     // Default, Popup
135     private static final String ALLOWED_REMINDER_TYPES = "0,1";
136     // None, required, optional
137     private static final String ALLOWED_ATTENDEE_TYPES = "0,1,2";
138     // Busy, free, tentative
139     private static final String ALLOWED_AVAILABILITIES = "0,1,2";
140 
141     // There is no type 4 (thus, the "")
142     static final String[] sTypeToFreq =
143         new String[] {"DAILY", "WEEKLY", "MONTHLY", "MONTHLY", "", "YEARLY", "YEARLY"};
144 
145     static final String[] sDayTokens =
146         new String[] {"SU", "MO", "TU", "WE", "TH", "FR", "SA"};
147 
148     static final String[] sTwoCharacterNumbers =
149         new String[] {"00", "01", "02", "03", "04", "05", "06", "07", "08", "09", "10", "11", "12"};
150 
151     // Bits used in EAS recurrences for days of the week
152     protected static final int EAS_SUNDAY = 1<<0;
153     protected static final int EAS_MONDAY = 1<<1;
154     protected static final int EAS_TUESDAY = 1<<2;
155     protected static final int EAS_WEDNESDAY = 1<<3;
156     protected static final int EAS_THURSDAY = 1<<4;
157     protected static final int EAS_FRIDAY = 1<<5;
158     protected static final int EAS_SATURDAY = 1<<6;
159     protected static final int EAS_WEEKDAYS =
160         EAS_MONDAY | EAS_TUESDAY | EAS_WEDNESDAY | EAS_THURSDAY | EAS_FRIDAY;
161     protected static final int EAS_WEEKENDS = EAS_SATURDAY | EAS_SUNDAY;
162 
163     static final int sCurrentYear = new GregorianCalendar().get(Calendar.YEAR);
164     static final TimeZone sGmtTimeZone = TimeZone.getTimeZone("GMT");
165 
166     private static final String ICALENDAR_ATTENDEE = "ATTENDEE;ROLE=REQ-PARTICIPANT";
167     static final String ICALENDAR_ATTENDEE_CANCEL = ICALENDAR_ATTENDEE;
168     static final String ICALENDAR_ATTENDEE_INVITE =
169         ICALENDAR_ATTENDEE + ";PARTSTAT=NEEDS-ACTION;RSVP=TRUE";
170     static final String ICALENDAR_ATTENDEE_ACCEPT =
171         ICALENDAR_ATTENDEE + ";PARTSTAT=ACCEPTED";
172     static final String ICALENDAR_ATTENDEE_DECLINE =
173         ICALENDAR_ATTENDEE + ";PARTSTAT=DECLINED";
174     static final String ICALENDAR_ATTENDEE_TENTATIVE =
175         ICALENDAR_ATTENDEE + ";PARTSTAT=TENTATIVE";
176 
177     // Note that these constants apply to Calendar items
178     // For future reference: MeetingRequest data can also include free/busy information, but the
179     // constants for these four options in MeetingRequest data have different values!
180     // See [MS-ASCAL] 2.2.2.8 for Calendar BusyStatus
181     // See [MS-EMAIL] 2.2.2.34 for MeetingRequest BusyStatus
182     public static final int BUSY_STATUS_FREE = 0;
183     public static final int BUSY_STATUS_TENTATIVE = 1;
184     public static final int BUSY_STATUS_BUSY = 2;
185     public static final int BUSY_STATUS_OUT_OF_OFFICE = 3;
186 
187     // Note that these constants apply to Calendar items, and are used in EAS 14+
188     // See [MS-ASCAL] 2.2.2.22 for Calendar ResponseType
189     public static final int RESPONSE_TYPE_NONE = 0;
190     public static final int RESPONSE_TYPE_ORGANIZER = 1;
191     public static final int RESPONSE_TYPE_TENTATIVE = 2;
192     public static final int RESPONSE_TYPE_ACCEPTED = 3;
193     public static final int RESPONSE_TYPE_DECLINED = 4;
194     public static final int RESPONSE_TYPE_NOT_RESPONDED = 5;
195 
196     // Return a 4-byte long from a byte array (little endian)
getLong(byte[] bytes, int offset)197     static int getLong(byte[] bytes, int offset) {
198         return (bytes[offset++] & 0xFF) | ((bytes[offset++] & 0xFF) << 8) |
199         ((bytes[offset++] & 0xFF) << 16) | ((bytes[offset] & 0xFF) << 24);
200     }
201 
202     // Put a 4-byte long into a byte array (little endian)
setLong(byte[] bytes, int offset, int value)203     static void setLong(byte[] bytes, int offset, int value) {
204         bytes[offset++] = (byte) (value & 0xFF);
205         bytes[offset++] = (byte) ((value >> 8) & 0xFF);
206         bytes[offset++] = (byte) ((value >> 16) & 0xFF);
207         bytes[offset] = (byte) ((value >> 24) & 0xFF);
208     }
209 
210     // Return a 2-byte word from a byte array (little endian)
getWord(byte[] bytes, int offset)211     static int getWord(byte[] bytes, int offset) {
212         return (bytes[offset++] & 0xFF) | ((bytes[offset] & 0xFF) << 8);
213     }
214 
215     // Put a 2-byte word into a byte array (little endian)
setWord(byte[] bytes, int offset, int value)216     static void setWord(byte[] bytes, int offset, int value) {
217         bytes[offset++] = (byte) (value & 0xFF);
218         bytes[offset] = (byte) ((value >> 8) & 0xFF);
219     }
220 
getString(byte[] bytes, int offset, int size)221     static String getString(byte[] bytes, int offset, int size) {
222         StringBuilder sb = new StringBuilder();
223         for (int i = 0; i < size; i++) {
224             int ch = bytes[offset + i];
225             if (ch == 0) {
226                 break;
227             } else {
228                 sb.append((char)ch);
229             }
230         }
231         return sb.toString();
232     }
233 
234     // Internal structure for storing a time zone date from a SYSTEMTIME structure
235     // This date represents either the start or the end time for DST
236     static class TimeZoneDate {
237         String year;
238         int month;
239         int dayOfWeek;
240         int day;
241         int time;
242         int hour;
243         int minute;
244     }
245 
246     @VisibleForTesting
clearTimeZoneCache()247     static void clearTimeZoneCache() {
248         sTimeZoneCache.clear();
249     }
250 
putRuleIntoTimeZoneInformation(byte[] bytes, int offset, RRule rrule, int hour, int minute)251     static void putRuleIntoTimeZoneInformation(byte[] bytes, int offset, RRule rrule, int hour,
252             int minute) {
253         // MSFT months are 1 based, same as RRule
254         setWord(bytes, offset + MSFT_SYSTEMTIME_MONTH, rrule.month);
255         // MSFT day of week starts w/ Sunday = 0; RRule starts w/ Sunday = 1
256         setWord(bytes, offset + MSFT_SYSTEMTIME_DAY_OF_WEEK, rrule.dayOfWeek - 1);
257         // 5 means "last" in MSFT land; for RRule, it's -1
258         setWord(bytes, offset + MSFT_SYSTEMTIME_DAY, rrule.week < 0 ? 5 : rrule.week);
259         // Turn hours/minutes into ms from midnight (per TimeZone)
260         setWord(bytes, offset + MSFT_SYSTEMTIME_HOUR, hour);
261         setWord(bytes, offset + MSFT_SYSTEMTIME_MINUTE, minute);
262     }
263 
264     // Write a transition time into SYSTEMTIME data (via an offset into a byte array)
putTransitionMillisIntoSystemTime(byte[] bytes, int offset, long millis)265     static void putTransitionMillisIntoSystemTime(byte[] bytes, int offset, long millis) {
266         GregorianCalendar cal = new GregorianCalendar(TimeZone.getDefault());
267         // Round to the next highest minute; we always write seconds as zero
268         cal.setTimeInMillis(millis + 30*SECONDS);
269 
270         // MSFT months are 1 based; TimeZone is 0 based
271         setWord(bytes, offset + MSFT_SYSTEMTIME_MONTH, cal.get(Calendar.MONTH) + 1);
272         // MSFT day of week starts w/ Sunday = 0; TimeZone starts w/ Sunday = 1
273         setWord(bytes, offset + MSFT_SYSTEMTIME_DAY_OF_WEEK, cal.get(Calendar.DAY_OF_WEEK) - 1);
274 
275         // Get the "day" in TimeZone format
276         int wom = cal.get(Calendar.DAY_OF_WEEK_IN_MONTH);
277         // 5 means "last" in MSFT land; for TimeZone, it's -1
278         setWord(bytes, offset + MSFT_SYSTEMTIME_DAY, wom < 0 ? 5 : wom);
279 
280         // Turn hours/minutes into ms from midnight (per TimeZone)
281         setWord(bytes, offset + MSFT_SYSTEMTIME_HOUR, getTrueTransitionHour(cal));
282         setWord(bytes, offset + MSFT_SYSTEMTIME_MINUTE, getTrueTransitionMinute(cal));
283      }
284 
285     // Build a TimeZoneDate structure from a SYSTEMTIME within a byte array at a given offset
getTimeZoneDateFromSystemTime(byte[] bytes, int offset)286     static TimeZoneDate getTimeZoneDateFromSystemTime(byte[] bytes, int offset) {
287         TimeZoneDate tzd = new TimeZoneDate();
288 
289         // MSFT year is an int; TimeZone is a String
290         int num = getWord(bytes, offset + MSFT_SYSTEMTIME_YEAR);
291         tzd.year = Integer.toString(num);
292 
293         // MSFT month = 0 means no daylight time
294         // MSFT months are 1 based; TimeZone is 0 based
295         num = getWord(bytes, offset + MSFT_SYSTEMTIME_MONTH);
296         if (num == 0) {
297             return null;
298         } else {
299             tzd.month = num -1;
300         }
301 
302         // MSFT day of week starts w/ Sunday = 0; TimeZone starts w/ Sunday = 1
303         tzd.dayOfWeek = getWord(bytes, offset + MSFT_SYSTEMTIME_DAY_OF_WEEK) + 1;
304 
305         // Get the "day" in TimeZone format
306         num = getWord(bytes, offset + MSFT_SYSTEMTIME_DAY);
307         // 5 means "last" in MSFT land; for TimeZone, it's -1
308         if (num == 5) {
309             tzd.day = -1;
310         } else {
311             tzd.day = num;
312         }
313 
314         // Turn hours/minutes into ms from midnight (per TimeZone)
315         int hour = getWord(bytes, offset + MSFT_SYSTEMTIME_HOUR);
316         tzd.hour = hour;
317         int minute = getWord(bytes, offset + MSFT_SYSTEMTIME_MINUTE);
318         tzd.minute = minute;
319         tzd.time = (hour*HOURS) + (minute*MINUTES);
320 
321         return tzd;
322     }
323 
324     /**
325      * Build a GregorianCalendar, based on a time zone and TimeZoneDate.
326      * @param timeZone the time zone we're checking
327      * @param tzd the TimeZoneDate we're interested in
328      * @return a GregorianCalendar with the given time zone and date
329      */
getMillisAtTimeZoneDateTransition(TimeZone timeZone, TimeZoneDate tzd)330     static long getMillisAtTimeZoneDateTransition(TimeZone timeZone, TimeZoneDate tzd) {
331         GregorianCalendar testCalendar = new GregorianCalendar(timeZone);
332         testCalendar.set(GregorianCalendar.YEAR, sCurrentYear);
333         testCalendar.set(GregorianCalendar.MONTH, tzd.month);
334         testCalendar.set(GregorianCalendar.DAY_OF_WEEK, tzd.dayOfWeek);
335         testCalendar.set(GregorianCalendar.DAY_OF_WEEK_IN_MONTH, tzd.day);
336         testCalendar.set(GregorianCalendar.HOUR_OF_DAY, tzd.hour);
337         testCalendar.set(GregorianCalendar.MINUTE, tzd.minute);
338         testCalendar.set(GregorianCalendar.SECOND, 0);
339         return testCalendar.getTimeInMillis();
340     }
341 
342     /**
343      * Return a GregorianCalendar representing the first standard/daylight transition between a
344      * start time and an end time in the given time zone
345      * @param tz a TimeZone the time zone in which we're looking for transitions
346      * @param startTime the start time for the test
347      * @param endTime the end time for the test
348      * @param startInDaylightTime whether daylight time is in effect at the startTime
349      * @return a GregorianCalendar representing the transition or null if none
350      */
findTransitionDate(TimeZone tz, long startTime, long endTime, boolean startInDaylightTime)351     static GregorianCalendar findTransitionDate(TimeZone tz, long startTime,
352             long endTime, boolean startInDaylightTime) {
353         long startingEndTime = endTime;
354         Date date = null;
355 
356         // We'll keep splitting the difference until we're within a minute
357         while ((endTime - startTime) > MINUTES) {
358             long checkTime = ((startTime + endTime) / 2) + 1;
359             date = new Date(checkTime);
360             boolean inDaylightTime = tz.inDaylightTime(date);
361             if (inDaylightTime != startInDaylightTime) {
362                 endTime = checkTime;
363             } else {
364                 startTime = checkTime;
365             }
366         }
367 
368         // If these are the same, we're really messed up; return null
369         if (endTime == startingEndTime) {
370             return null;
371         }
372 
373         // Set up our calendar and return it
374         GregorianCalendar calendar = new GregorianCalendar(tz);
375         calendar.setTimeInMillis(startTime);
376         return calendar;
377     }
378 
379     /**
380      * Return a Base64 representation of a MSFT TIME_ZONE_INFORMATION structure from a TimeZone
381      * that might be found in an Event; use cached result, if possible
382      * @param tz the TimeZone
383      * @return the Base64 String representing a Microsoft TIME_ZONE_INFORMATION element
384      */
timeZoneToTziString(TimeZone tz)385     static public String timeZoneToTziString(TimeZone tz) {
386         String tziString = sTziStringCache.get(tz);
387         if (tziString != null) {
388             if (Eas.USER_LOG) {
389                 ExchangeService.log(TAG, "TZI string for " + tz.getDisplayName() +
390                         " found in cache.");
391             }
392             return tziString;
393         }
394         tziString = timeZoneToTziStringImpl(tz);
395         sTziStringCache.put(tz, tziString);
396         return tziString;
397     }
398 
399     /**
400      * A class for storing RRULE information.  The RRULE members can be accessed individually or
401      * an RRULE string can be created with toString()
402      */
403     static class RRule {
404         static final int RRULE_NONE = 0;
405         static final int RRULE_DAY_WEEK = 1;
406         static final int RRULE_DATE = 2;
407 
408         int type;
409         int dayOfWeek;
410         int week;
411         int month;
412         int date;
413 
414         /**
415          * Create an RRULE based on month and date
416          * @param _month the month (1 = JAN, 12 = DEC)
417          * @param _date the date in the month (1-31)
418          */
RRule(int _month, int _date)419         RRule(int _month, int _date) {
420             type = RRULE_DATE;
421             month = _month;
422             date = _date;
423         }
424 
425         /**
426          * Create an RRULE based on month, day of week, and week #
427          * @param _month the month (1 = JAN, 12 = DEC)
428          * @param _dayOfWeek the day of the week (1 = SU, 7 = SA)
429          * @param _week the week in the month (1-5 or -1 for last)
430          */
RRule(int _month, int _dayOfWeek, int _week)431         RRule(int _month, int _dayOfWeek, int _week) {
432             type = RRULE_DAY_WEEK;
433             month = _month;
434             dayOfWeek = _dayOfWeek;
435             week = _week;
436         }
437 
438         @Override
toString()439         public String toString() {
440             if (type == RRULE_DAY_WEEK) {
441                 return "FREQ=YEARLY;BYMONTH=" + month + ";BYDAY=" + week +
442                     sDayTokens[dayOfWeek - 1];
443             } else {
444                 return "FREQ=YEARLY;BYMONTH=" + month + ";BYMONTHDAY=" + date;
445             }
446        }
447     }
448 
449     /**
450      * Generate an RRULE string for an array of GregorianCalendars, if possible.  For now, we are
451      * only looking for rules based on the same date in a month or a specific instance of a day of
452      * the week in a month (e.g. 2nd Tuesday or last Friday).  Indeed, these are the only kinds of
453      * rules used in the current tzinfo database.
454      * @param calendars an array of GregorianCalendar, set to a series of transition times in
455      * consecutive years starting with the current year
456      * @return an RRULE or null if none could be inferred from the calendars
457      */
inferRRuleFromCalendars(GregorianCalendar[] calendars)458     static RRule inferRRuleFromCalendars(GregorianCalendar[] calendars) {
459         // Let's see if we can make a rule about these
460         GregorianCalendar calendar = calendars[0];
461         if (calendar == null) return null;
462         int month = calendar.get(Calendar.MONTH);
463         int date = calendar.get(Calendar.DAY_OF_MONTH);
464         int dayOfWeek = calendar.get(Calendar.DAY_OF_WEEK);
465         int week = calendar.get(Calendar.DAY_OF_WEEK_IN_MONTH);
466         int maxWeek = calendar.getActualMaximum(Calendar.DAY_OF_WEEK_IN_MONTH);
467         boolean dateRule = false;
468         boolean dayOfWeekRule = false;
469         for (int i = 1; i < calendars.length; i++) {
470             GregorianCalendar cal = calendars[i];
471             if (cal == null) return null;
472             // If it's not the same month, there's no rule
473             if (cal.get(Calendar.MONTH) != month) {
474                 return null;
475             } else if (dayOfWeek == cal.get(Calendar.DAY_OF_WEEK)) {
476                 // Ok, it seems to be the same day of the week
477                 if (dateRule) {
478                     return null;
479                 }
480                 dayOfWeekRule = true;
481                 int thisWeek = cal.get(Calendar.DAY_OF_WEEK_IN_MONTH);
482                 if (week != thisWeek) {
483                     if (week < 0 || week == maxWeek) {
484                         int thisMaxWeek = cal.getActualMaximum(Calendar.DAY_OF_WEEK_IN_MONTH);
485                         if (thisWeek == thisMaxWeek) {
486                             // We'll use -1 (i.e. last) week
487                             week = -1;
488                             continue;
489                         }
490                     }
491                     return null;
492                 }
493             } else if (date == cal.get(Calendar.DAY_OF_MONTH)) {
494                 // Maybe the same day of the month?
495                 if (dayOfWeekRule) {
496                     return null;
497                 }
498                 dateRule = true;
499             } else {
500                 return null;
501             }
502         }
503 
504         if (dateRule) {
505             return new RRule(month + 1, date);
506         }
507         // sDayTokens is 0 based (SU = 0); Calendar days of week are 1 based (SU = 1)
508         // iCalendar months are 1 based; Calendar months are 0 based
509         // So we adjust these when building the string
510         return new RRule(month + 1, dayOfWeek, week);
511     }
512 
513     /**
514      * Generate an rfc2445 utcOffset from minutes offset from GMT
515      * These look like +0800 or -0100
516      * @param offsetMinutes minutes offset from GMT (east is positive, west is negative
517      * @return a utcOffset
518      */
utcOffsetString(int offsetMinutes)519     static String utcOffsetString(int offsetMinutes) {
520         StringBuilder sb = new StringBuilder();
521         int hours = offsetMinutes / 60;
522         if (hours < 0) {
523             sb.append('-');
524             hours = 0 - hours;
525         } else {
526             sb.append('+');
527         }
528         int minutes = offsetMinutes % 60;
529         if (hours < 10) {
530             sb.append('0');
531         }
532         sb.append(hours);
533         if (minutes < 10) {
534             sb.append('0');
535         }
536         sb.append(minutes);
537         return sb.toString();
538     }
539 
540     /**
541      * Fill the passed in GregorianCalendars arrays with DST transition information for this and
542      * the following years (based on the length of the arrays)
543      * @param tz the time zone
544      * @param toDaylightCalendars an array of GregorianCalendars, one for each year, representing
545      * the transition to daylight time
546      * @param toStandardCalendars an array of GregorianCalendars, one for each year, representing
547      * the transition to standard time
548      * @return true if transitions could be found for all years, false otherwise
549      */
getDSTCalendars(TimeZone tz, GregorianCalendar[] toDaylightCalendars, GregorianCalendar[] toStandardCalendars)550     static boolean getDSTCalendars(TimeZone tz, GregorianCalendar[] toDaylightCalendars,
551             GregorianCalendar[] toStandardCalendars) {
552         // We'll use the length of the arrays to determine how many years to check
553         int maxYears = toDaylightCalendars.length;
554         if (toStandardCalendars.length != maxYears) {
555             return false;
556         }
557         // Get the transitions for this year and the next few years
558         for (int i = 0; i < maxYears; i++) {
559             GregorianCalendar cal = new GregorianCalendar(tz);
560             cal.set(sCurrentYear + i, Calendar.JANUARY, 1, 0, 0, 0);
561             long startTime = cal.getTimeInMillis();
562             // Calculate end of year; no need to be insanely precise
563             long endOfYearTime = startTime + (365*DAYS) + (DAYS>>2);
564             Date date = new Date(startTime);
565             boolean startInDaylightTime = tz.inDaylightTime(date);
566             // Find the first transition, and store
567             cal = findTransitionDate(tz, startTime, endOfYearTime, startInDaylightTime);
568             if (cal == null) {
569                 return false;
570             } else if (startInDaylightTime) {
571                 toStandardCalendars[i] = cal;
572             } else {
573                 toDaylightCalendars[i] = cal;
574             }
575             // Find the second transition, and store
576             cal = findTransitionDate(tz, startTime, endOfYearTime, !startInDaylightTime);
577             if (cal == null) {
578                 return false;
579             } else if (startInDaylightTime) {
580                 toDaylightCalendars[i] = cal;
581             } else {
582                 toStandardCalendars[i] = cal;
583             }
584         }
585         return true;
586     }
587 
588     /**
589      * Write out the STANDARD block of VTIMEZONE and end the VTIMEZONE
590      * @param writer the SimpleIcsWriter we're using
591      * @param tz the time zone
592      * @param offsetString the offset string in VTIMEZONE format (e.g. +0800)
593      * @throws IOException
594      */
writeNoDST(SimpleIcsWriter writer, TimeZone tz, String offsetString)595     static private void writeNoDST(SimpleIcsWriter writer, TimeZone tz, String offsetString)
596             throws IOException {
597         writer.writeTag("BEGIN", "STANDARD");
598         writer.writeTag("TZOFFSETFROM", offsetString);
599         writer.writeTag("TZOFFSETTO", offsetString);
600         // Might as well use start of epoch for start date
601         writer.writeTag("DTSTART", millisToEasDateTime(0L));
602         writer.writeTag("END", "STANDARD");
603         writer.writeTag("END", "VTIMEZONE");
604     }
605 
606     /** Write a VTIMEZONE block for a given TimeZone into a SimpleIcsWriter
607      * @param tz the TimeZone to be used in the conversion
608      * @param writer the SimpleIcsWriter to be used
609      * @throws IOException
610      */
timeZoneToVTimezone(TimeZone tz, SimpleIcsWriter writer)611     static void timeZoneToVTimezone(TimeZone tz, SimpleIcsWriter writer)
612             throws IOException {
613         // We'll use these regardless of whether there's DST in this time zone or not
614         int rawOffsetMinutes = tz.getRawOffset() / MINUTES;
615         String standardOffsetString = utcOffsetString(rawOffsetMinutes);
616 
617         // Preamble for all of our VTIMEZONEs
618         writer.writeTag("BEGIN", "VTIMEZONE");
619         writer.writeTag("TZID", tz.getID());
620         writer.writeTag("X-LIC-LOCATION", tz.getDisplayName());
621 
622         // Simplest case is no daylight time
623         if (!tz.useDaylightTime()) {
624             writeNoDST(writer, tz, standardOffsetString);
625             return;
626         }
627 
628         int maxYears = 3;
629         GregorianCalendar[] toDaylightCalendars = new GregorianCalendar[maxYears];
630         GregorianCalendar[] toStandardCalendars = new GregorianCalendar[maxYears];
631         if (!getDSTCalendars(tz, toDaylightCalendars, toStandardCalendars)) {
632             writeNoDST(writer, tz, standardOffsetString);
633             return;
634         }
635         // Try to find a rule to cover these yeras
636         RRule daylightRule = inferRRuleFromCalendars(toDaylightCalendars);
637         RRule standardRule = inferRRuleFromCalendars(toStandardCalendars);
638         String daylightOffsetString =
639             utcOffsetString(rawOffsetMinutes + (tz.getDSTSavings() / MINUTES));
640         // We'll use RRULE's if we found both
641         // Otherwise we write the first as DTSTART and the others as RDATE
642         boolean hasRule = daylightRule != null && standardRule != null;
643 
644         // Write the DAYLIGHT block
645         writer.writeTag("BEGIN", "DAYLIGHT");
646         writer.writeTag("TZOFFSETFROM", standardOffsetString);
647         writer.writeTag("TZOFFSETTO", daylightOffsetString);
648         writer.writeTag("DTSTART",
649                 transitionMillisToVCalendarTime(
650                         toDaylightCalendars[0].getTimeInMillis(), tz, true));
651         if (hasRule) {
652             writer.writeTag("RRULE", daylightRule.toString());
653         } else {
654             for (int i = 1; i < maxYears; i++) {
655                 writer.writeTag("RDATE", transitionMillisToVCalendarTime(
656                         toDaylightCalendars[i].getTimeInMillis(), tz, true));
657             }
658         }
659         writer.writeTag("END", "DAYLIGHT");
660         // Write the STANDARD block
661         writer.writeTag("BEGIN", "STANDARD");
662         writer.writeTag("TZOFFSETFROM", daylightOffsetString);
663         writer.writeTag("TZOFFSETTO", standardOffsetString);
664         writer.writeTag("DTSTART",
665                 transitionMillisToVCalendarTime(
666                         toStandardCalendars[0].getTimeInMillis(), tz, false));
667         if (hasRule) {
668             writer.writeTag("RRULE", standardRule.toString());
669         } else {
670             for (int i = 1; i < maxYears; i++) {
671                 writer.writeTag("RDATE", transitionMillisToVCalendarTime(
672                         toStandardCalendars[i].getTimeInMillis(), tz, true));
673             }
674         }
675         writer.writeTag("END", "STANDARD");
676         // And we're done
677         writer.writeTag("END", "VTIMEZONE");
678     }
679 
680     /**
681      * Find the next transition to occur (i.e. after the current date/time)
682      * @param transitions calendars representing transitions to/from DST
683      * @return millis for the first transition after the current date/time
684      */
findNextTransition(long startingMillis, GregorianCalendar[] transitions)685     static long findNextTransition(long startingMillis, GregorianCalendar[] transitions) {
686         for (GregorianCalendar transition: transitions) {
687             long transitionMillis = transition.getTimeInMillis();
688             if (transitionMillis > startingMillis) {
689                 return transitionMillis;
690             }
691         }
692         return 0;
693     }
694 
695     /**
696      * Calculate the Base64 representation of a MSFT TIME_ZONE_INFORMATION structure from a TimeZone
697      * that might be found in an Event.  Since the internal representation of the TimeZone is hidden
698      * from us we'll find the DST transitions and build the structure from that information
699      * @param tz the TimeZone
700      * @return the Base64 String representing a Microsoft TIME_ZONE_INFORMATION element
701      */
timeZoneToTziStringImpl(TimeZone tz)702     static String timeZoneToTziStringImpl(TimeZone tz) {
703         String tziString;
704         byte[] tziBytes = new byte[MSFT_TIME_ZONE_SIZE];
705         int standardBias = - tz.getRawOffset();
706         standardBias /= 60*SECONDS;
707         setLong(tziBytes, MSFT_TIME_ZONE_BIAS_OFFSET, standardBias);
708         // If this time zone has daylight savings time, we need to do more work
709         if (tz.useDaylightTime()) {
710             GregorianCalendar[] toDaylightCalendars = new GregorianCalendar[3];
711             GregorianCalendar[] toStandardCalendars = new GregorianCalendar[3];
712             // See if we can get transitions for a few years; if not, we can't generate DST info
713             // for this time zone
714             if (getDSTCalendars(tz, toDaylightCalendars, toStandardCalendars)) {
715                 // Try to find a rule to cover these years
716                 RRule daylightRule = inferRRuleFromCalendars(toDaylightCalendars);
717                 RRule standardRule = inferRRuleFromCalendars(toStandardCalendars);
718                 if ((daylightRule != null) && (daylightRule.type == RRule.RRULE_DAY_WEEK) &&
719                         (standardRule != null) && (standardRule.type == RRule.RRULE_DAY_WEEK)) {
720                     // We need both rules and they have to be DAY/WEEK type
721                     // Write month, day of week, week, hour, minute
722                     putRuleIntoTimeZoneInformation(tziBytes, MSFT_TIME_ZONE_STANDARD_DATE_OFFSET,
723                             standardRule,
724                             getTrueTransitionHour(toStandardCalendars[0]),
725                             getTrueTransitionMinute(toStandardCalendars[0]));
726                     putRuleIntoTimeZoneInformation(tziBytes, MSFT_TIME_ZONE_DAYLIGHT_DATE_OFFSET,
727                             daylightRule,
728                             getTrueTransitionHour(toDaylightCalendars[0]),
729                             getTrueTransitionMinute(toDaylightCalendars[0]));
730                 } else {
731                     // If there's no rule, we'll use the first transition to standard/to daylight
732                     // And indicate that it's just for this year...
733                     long now = System.currentTimeMillis();
734                     long standardTransition = findNextTransition(now, toStandardCalendars);
735                     long daylightTransition = findNextTransition(now, toDaylightCalendars);
736                     // If we can't find transitions, we can't do DST
737                     if (standardTransition != 0 && daylightTransition != 0) {
738                         putTransitionMillisIntoSystemTime(tziBytes,
739                                 MSFT_TIME_ZONE_STANDARD_DATE_OFFSET, standardTransition);
740                         putTransitionMillisIntoSystemTime(tziBytes,
741                                 MSFT_TIME_ZONE_DAYLIGHT_DATE_OFFSET, daylightTransition);
742                     }
743                 }
744             }
745             int dstOffset = tz.getDSTSavings();
746             setLong(tziBytes, MSFT_TIME_ZONE_DAYLIGHT_BIAS_OFFSET, - dstOffset / MINUTES);
747         }
748         byte[] tziEncodedBytes = Base64.encode(tziBytes, Base64.NO_WRAP);
749         tziString = new String(tziEncodedBytes);
750         return tziString;
751     }
752 
753     /**
754      * Given a String as directly read from EAS, returns a TimeZone corresponding to that String
755      * @param timeZoneString the String read from the server
756      * @param precision the number of milliseconds of precision in TimeZone determination
757      * @return the TimeZone, or TimeZone.getDefault() if not found
758      */
759     @VisibleForTesting
tziStringToTimeZone(String timeZoneString, int precision)760     static TimeZone tziStringToTimeZone(String timeZoneString, int precision) {
761         // If we have this time zone cached, use that value and return
762         TimeZone timeZone = sTimeZoneCache.get(timeZoneString);
763         if (timeZone != null) {
764             if (Eas.USER_LOG) {
765                 ExchangeService.log(TAG, " Using cached TimeZone " + timeZone.getID());
766             }
767         } else {
768             timeZone = tziStringToTimeZoneImpl(timeZoneString, precision);
769             if (timeZone == null) {
770                 // If we don't find a match, we just return the current TimeZone.  In theory, this
771                 // shouldn't be happening...
772                 ExchangeService.alwaysLog("TimeZone not found using default: " + timeZoneString);
773                 timeZone = TimeZone.getDefault();
774             }
775             sTimeZoneCache.put(timeZoneString, timeZone);
776         }
777         return timeZone;
778     }
779 
780     /**
781      * The standard entry to EAS time zone conversion, using one minute as the precision
782      */
tziStringToTimeZone(String timeZoneString)783     static public TimeZone tziStringToTimeZone(String timeZoneString) {
784         return tziStringToTimeZone(timeZoneString, MINUTES);
785     }
786 
787     /**
788      * Given a String as directly read from EAS, tries to find a TimeZone in the database of all
789      * time zones that corresponds to that String.  If the test time zone string includes DST and
790      * we don't find a match, and we're using standard precision, we try again with lenient
791      * precision, which is a bit better than guessing
792      * @param timeZoneString the String read from the server
793      * @return the TimeZone, or null if not found
794      */
tziStringToTimeZoneImpl(String timeZoneString, int precision)795     static TimeZone tziStringToTimeZoneImpl(String timeZoneString, int precision) {
796         TimeZone timeZone = null;
797         // First, we need to decode the base64 string
798         byte[] timeZoneBytes = Base64.decode(timeZoneString, Base64.DEFAULT);
799 
800         // Then, we get the bias (similar to a rawOffset); for TimeZone, we need ms
801         // but EAS gives us minutes, so do the conversion.  Note that EAS is the bias that's added
802         // to the time zone to reach UTC; our library uses the time from UTC to our time zone, so
803         // we need to change the sign
804         int bias = -1 * getLong(timeZoneBytes, MSFT_TIME_ZONE_BIAS_OFFSET) * MINUTES;
805 
806         // Get all of the time zones with the bias as a rawOffset; if there aren't any, we return
807         // the default time zone
808         String[] zoneIds = TimeZone.getAvailableIDs(bias);
809         if (zoneIds.length > 0) {
810             // Try to find an existing TimeZone from the data provided by EAS
811             // We start by pulling out the date that standard time begins
812             TimeZoneDate dstEnd =
813                 getTimeZoneDateFromSystemTime(timeZoneBytes, MSFT_TIME_ZONE_STANDARD_DATE_OFFSET);
814             if (dstEnd == null) {
815                 // If the default time zone is a match
816                 TimeZone defaultTimeZone = TimeZone.getDefault();
817                 if (!defaultTimeZone.useDaylightTime() &&
818                         ArrayUtils.contains(zoneIds, defaultTimeZone.getID())) {
819                     if (Eas.USER_LOG) {
820                         ExchangeService.log(TAG, "TimeZone without DST found to be default: " +
821                                 defaultTimeZone.getID());
822                     }
823                     return defaultTimeZone;
824                 }
825                 // In this case, there is no daylight savings time, so the only interesting data
826                 // for possible matches is the offset and DST availability; we'll take the first
827                 // match for those
828                 for (String zoneId: zoneIds) {
829                     timeZone = TimeZone.getTimeZone(zoneId);
830                     if (!timeZone.useDaylightTime()) {
831                         if (Eas.USER_LOG) {
832                             ExchangeService.log(TAG, "TimeZone without DST found by offset: " +
833                                     timeZone.getID());
834                         }
835                         return timeZone;
836                     }
837                 }
838                 // None found, return null
839                 return null;
840             } else {
841                 TimeZoneDate dstStart = getTimeZoneDateFromSystemTime(timeZoneBytes,
842                         MSFT_TIME_ZONE_DAYLIGHT_DATE_OFFSET);
843                 // See comment above for bias...
844                 long dstSavings =
845                     -1 * getLong(timeZoneBytes, MSFT_TIME_ZONE_DAYLIGHT_BIAS_OFFSET) * MINUTES;
846 
847                 // We'll go through each time zone to find one with the same DST transitions and
848                 // savings length
849                 for (String zoneId: zoneIds) {
850                     // Get the TimeZone using the zoneId
851                     timeZone = TimeZone.getTimeZone(zoneId);
852 
853                     // Our strategy here is to check just before and just after the transitions
854                     // and see whether the check for daylight time matches the expectation
855                     // If both transitions match, then we have a match for the offset and start/end
856                     // of dst.  That's the best we can do for now, since there's no other info
857                     // provided by EAS (i.e. we can't get dynamic transitions, etc.)
858 
859                     // Check one minute before and after DST start transition
860                     long millisAtTransition = getMillisAtTimeZoneDateTransition(timeZone, dstStart);
861                     Date before = new Date(millisAtTransition - precision);
862                     Date after = new Date(millisAtTransition + precision);
863                     if (timeZone.inDaylightTime(before)) continue;
864                     if (!timeZone.inDaylightTime(after)) continue;
865 
866                     // Check one minute before and after DST end transition
867                     millisAtTransition = getMillisAtTimeZoneDateTransition(timeZone, dstEnd);
868                     // Note that we need to subtract an extra hour here, because we end up with
869                     // gaining an hour in the transition BACK to standard time
870                     before = new Date(millisAtTransition - (dstSavings + precision));
871                     after = new Date(millisAtTransition + precision);
872                     if (!timeZone.inDaylightTime(before)) continue;
873                     if (timeZone.inDaylightTime(after)) continue;
874 
875                     // Check that the savings are the same
876                     if (dstSavings != timeZone.getDSTSavings()) continue;
877                     return timeZone;
878                 }
879                 boolean lenient = false;
880                 boolean name = false;
881                 if ((dstStart.hour != dstEnd.hour) && (precision == STANDARD_DST_PRECISION)) {
882                     timeZone = tziStringToTimeZoneImpl(timeZoneString, LENIENT_DST_PRECISION);
883                     lenient = true;
884                 } else {
885                     // We can't find a time zone match, so our last attempt is to see if there's
886                     // a valid time zone name in the TZI; if not we'll just take the first TZ with
887                     // a matching offset (which is likely wrong, but ... what else is there to do)
888                     String tzName = getString(timeZoneBytes, MSFT_TIME_ZONE_STANDARD_NAME_OFFSET,
889                             MSFT_TIME_ZONE_STRING_SIZE);
890                     if (!tzName.isEmpty()) {
891                         TimeZone tz = TimeZone.getTimeZone(tzName);
892                         if (tz != null) {
893                             timeZone = tz;
894                             name = true;
895                         } else {
896                             timeZone = TimeZone.getTimeZone(zoneIds[0]);
897                         }
898                     } else {
899                         timeZone = TimeZone.getTimeZone(zoneIds[0]);
900                     }
901                 }
902                 if (Eas.USER_LOG) {
903                     ExchangeService.log(TAG,
904                             "No TimeZone with correct DST settings; using " +
905                             (name ? "name" : (lenient ? "lenient" : "first")) + ": " +
906                                     timeZone.getID());
907                 }
908                 return timeZone;
909             }
910         }
911         return null;
912     }
913 
convertEmailDateTimeToCalendarDateTime(String date)914     static public String convertEmailDateTimeToCalendarDateTime(String date) {
915         // Format for email date strings is 2010-02-23T16:00:00.000Z
916         // Format for calendar date strings is 20100223T160000Z
917        return date.substring(0, 4) + date.substring(5, 7) + date.substring(8, 13) +
918            date.substring(14, 16) + date.substring(17, 19) + 'Z';
919     }
920 
formatTwo(int num)921     static String formatTwo(int num) {
922         if (num <= 12) {
923             return sTwoCharacterNumbers[num];
924         } else
925             return Integer.toString(num);
926     }
927 
928     /**
929      * Generate an EAS formatted date/time string based on GMT. See below for details.
930      */
millisToEasDateTime(long millis)931     static public String millisToEasDateTime(long millis) {
932         return millisToEasDateTime(millis, sGmtTimeZone, true);
933     }
934 
935     /**
936      * Generate a birthday string from a GregorianCalendar set appropriately; the format of this
937      * string is YYYY-MM-DD
938      * @param cal the calendar
939      * @return the birthday string
940      */
calendarToBirthdayString(GregorianCalendar cal)941     static public String calendarToBirthdayString(GregorianCalendar cal) {
942         StringBuilder sb = new StringBuilder();
943         sb.append(cal.get(Calendar.YEAR));
944         sb.append('-');
945         sb.append(formatTwo(cal.get(Calendar.MONTH) + 1));
946         sb.append('-');
947         sb.append(formatTwo(cal.get(Calendar.DAY_OF_MONTH)));
948         return sb.toString();
949     }
950 
951     /**
952      * Generate an EAS formatted local date/time string from a time and a time zone. If the final
953      * argument is false, only a date will be returned (e.g. 20100331)
954      * @param millis a time in milliseconds
955      * @param tz a time zone
956      * @param withTime if the time is to be included in the string
957      * @return an EAS formatted string indicating the date (and time) in the given time zone
958      */
millisToEasDateTime(long millis, TimeZone tz, boolean withTime)959     static public String millisToEasDateTime(long millis, TimeZone tz, boolean withTime) {
960         StringBuilder sb = new StringBuilder();
961         GregorianCalendar cal = new GregorianCalendar(tz);
962         cal.setTimeInMillis(millis);
963         sb.append(cal.get(Calendar.YEAR));
964         sb.append(formatTwo(cal.get(Calendar.MONTH) + 1));
965         sb.append(formatTwo(cal.get(Calendar.DAY_OF_MONTH)));
966         if (withTime) {
967             sb.append('T');
968             sb.append(formatTwo(cal.get(Calendar.HOUR_OF_DAY)));
969             sb.append(formatTwo(cal.get(Calendar.MINUTE)));
970             sb.append(formatTwo(cal.get(Calendar.SECOND)));
971             if (tz == sGmtTimeZone) {
972                 sb.append('Z');
973             }
974         }
975         return sb.toString();
976     }
977 
978     /**
979      * Return the true minute at which a transition occurs
980      * Our transition time should be the in the minute BEFORE the transition
981      * If this minute is 59, set minute to 0 and increment the hour
982      * NOTE: We don't want to add a minute and retrieve minute/hour from the Calendar, because
983      * Calendar time will itself be influenced by the transition!  So adding 1 minute to
984      * 01:59 (assume PST->PDT) will become 03:00, which isn't what we want (we want 02:00)
985      *
986      * @param calendar the calendar holding the transition date/time
987      * @return the true minute of the transition
988      */
getTrueTransitionMinute(GregorianCalendar calendar)989     static int getTrueTransitionMinute(GregorianCalendar calendar) {
990         int minute = calendar.get(Calendar.MINUTE);
991         if (minute == 59) {
992             minute = 0;
993         }
994         return minute;
995     }
996 
997     /**
998      * Return the true hour at which a transition occurs
999      * See description for getTrueTransitionMinute, above
1000      * @param calendar the calendar holding the transition date/time
1001      * @return the true hour of the transition
1002      */
getTrueTransitionHour(GregorianCalendar calendar)1003     static int getTrueTransitionHour(GregorianCalendar calendar) {
1004         int hour = calendar.get(Calendar.HOUR_OF_DAY);
1005         hour++;
1006         if (hour == 24) {
1007             hour = 0;
1008         }
1009         return hour;
1010     }
1011 
1012     /**
1013      * Generate a date/time string suitable for VTIMEZONE from a transition time in millis
1014      * The format is YYYYMMDDTHHMMSS
1015      * @param millis a transition time in milliseconds
1016      * @param tz a time zone
1017      * @param dst whether we're entering daylight time
1018      */
transitionMillisToVCalendarTime(long millis, TimeZone tz, boolean dst)1019     static String transitionMillisToVCalendarTime(long millis, TimeZone tz, boolean dst) {
1020         StringBuilder sb = new StringBuilder();
1021         GregorianCalendar cal = new GregorianCalendar(tz);
1022         cal.setTimeInMillis(millis);
1023         sb.append(cal.get(Calendar.YEAR));
1024         sb.append(formatTwo(cal.get(Calendar.MONTH) + 1));
1025         sb.append(formatTwo(cal.get(Calendar.DAY_OF_MONTH)));
1026         sb.append('T');
1027         sb.append(formatTwo(getTrueTransitionHour(cal)));
1028         sb.append(formatTwo(getTrueTransitionMinute(cal)));
1029         sb.append(formatTwo(0));
1030         return sb.toString();
1031     }
1032 
1033     /**
1034      * Returns a UTC calendar with year/month/day from local calendar and h/m/s/ms = 0
1035      * @param time the time in seconds of an all-day event in local time
1036      * @return the time in seconds in UTC
1037      */
getUtcAllDayCalendarTime(long time, TimeZone localTimeZone)1038     static public long getUtcAllDayCalendarTime(long time, TimeZone localTimeZone) {
1039         return transposeAllDayTime(time, localTimeZone, UTC_TIMEZONE);
1040     }
1041 
1042     /**
1043      * Returns a local calendar with year/month/day from UTC calendar and h/m/s/ms = 0
1044      * @param time the time in seconds of an all-day event in UTC
1045      * @return the time in seconds in local time
1046      */
getLocalAllDayCalendarTime(long time, TimeZone localTimeZone)1047     static public long getLocalAllDayCalendarTime(long time, TimeZone localTimeZone) {
1048         return transposeAllDayTime(time, UTC_TIMEZONE, localTimeZone);
1049     }
1050 
transposeAllDayTime(long time, TimeZone fromTimeZone, TimeZone toTimeZone)1051     static private long transposeAllDayTime(long time, TimeZone fromTimeZone,
1052             TimeZone toTimeZone) {
1053         GregorianCalendar fromCalendar = new GregorianCalendar(fromTimeZone);
1054         fromCalendar.setTimeInMillis(time);
1055         GregorianCalendar toCalendar = new GregorianCalendar(toTimeZone);
1056         // Set this calendar with correct year, month, and day, but zero hour, minute, and seconds
1057         toCalendar.set(fromCalendar.get(GregorianCalendar.YEAR),
1058                 fromCalendar.get(GregorianCalendar.MONTH),
1059                 fromCalendar.get(GregorianCalendar.DATE), 0, 0, 0);
1060         toCalendar.set(GregorianCalendar.MILLISECOND, 0);
1061         return toCalendar.getTimeInMillis();
1062     }
1063 
addByDay(StringBuilder rrule, int dow, int wom)1064     static void addByDay(StringBuilder rrule, int dow, int wom) {
1065         rrule.append(";BYDAY=");
1066         boolean addComma = false;
1067         for (int i = 0; i < 7; i++) {
1068             if ((dow & 1) == 1) {
1069                 if (addComma) {
1070                     rrule.append(',');
1071                 }
1072                 if (wom > 0) {
1073                     // 5 = last week -> -1
1074                     // So -1SU = last sunday
1075                     rrule.append(wom == 5 ? -1 : wom);
1076                 }
1077                 rrule.append(sDayTokens[i]);
1078                 addComma = true;
1079             }
1080             dow >>= 1;
1081         }
1082     }
1083 
addBySetpos(StringBuilder rrule, int dow, int wom)1084     static void addBySetpos(StringBuilder rrule, int dow, int wom) {
1085         // Indicate the days, but don't use wom in this case (it's used in the BYSETPOS);
1086         addByDay(rrule, dow, 0);
1087         rrule.append(";BYSETPOS=");
1088         rrule.append(wom == 5 ? "-1" : wom);
1089     }
1090 
addByMonthDay(StringBuilder rrule, int dom)1091     static void addByMonthDay(StringBuilder rrule, int dom) {
1092         // 127 means last day of the month
1093         if (dom == 127) {
1094             dom = -1;
1095         }
1096         rrule.append(";BYMONTHDAY=" + dom);
1097     }
1098 
1099     /**
1100      * Generate the String version of the EAS integer for a given BYDAY value in an rrule
1101      * @param dow the BYDAY value of the rrule
1102      * @return the String version of the EAS value of these days
1103      */
generateEasDayOfWeek(String dow)1104     static String generateEasDayOfWeek(String dow) {
1105         int bits = 0;
1106         int bit = 1;
1107         for (String token: sDayTokens) {
1108             // If we can find the day in the dow String, add the bit to our bits value
1109             if (dow.indexOf(token) >= 0) {
1110                 bits |= bit;
1111             }
1112             bit <<= 1;
1113         }
1114         return Integer.toString(bits);
1115     }
1116 
1117     /**
1118      * Extract the value of a token in an RRULE string
1119      * @param rrule an RRULE string
1120      * @param token a token to look for in the RRULE
1121      * @return the value of that token
1122      */
tokenFromRrule(String rrule, String token)1123     static String tokenFromRrule(String rrule, String token) {
1124         int start = rrule.indexOf(token);
1125         if (start < 0) return null;
1126         int len = rrule.length();
1127         start += token.length();
1128         int end = start;
1129         char c;
1130         do {
1131             c = rrule.charAt(end++);
1132             if ((c == ';') || (end == len)) {
1133                 if (end == len) end++;
1134                 return rrule.substring(start, end -1);
1135             }
1136         } while (true);
1137     }
1138 
1139     /**
1140      * Reformat an RRULE style UNTIL to an EAS style until
1141      */
1142     @VisibleForTesting
recurrenceUntilToEasUntil(String until)1143     static String recurrenceUntilToEasUntil(String until) {
1144         // Get a calendar in our local time zone
1145         GregorianCalendar localCalendar = new GregorianCalendar(TimeZone.getDefault());
1146         // Set the time per GMT time in the 'until'
1147         localCalendar.setTimeInMillis(Utility.parseDateTimeToMillis(until));
1148         StringBuilder sb = new StringBuilder();
1149         // Build a string with local year/month/date
1150         sb.append(localCalendar.get(Calendar.YEAR));
1151         sb.append(formatTwo(localCalendar.get(Calendar.MONTH) + 1));
1152         sb.append(formatTwo(localCalendar.get(Calendar.DAY_OF_MONTH)));
1153         // EAS ignores the time in 'until'; go figure
1154         sb.append("T000000Z");
1155         return sb.toString();
1156     }
1157 
1158     /**
1159      * Convenience method to add "count", "interval", and "until" to an EAS calendar stream
1160      * According to EAS docs, OCCURRENCES must always come before INTERVAL
1161      */
addCountIntervalAndUntil(String rrule, Serializer s)1162     static private void addCountIntervalAndUntil(String rrule, Serializer s) throws IOException {
1163         String count = tokenFromRrule(rrule, "COUNT=");
1164         if (count != null) {
1165             s.data(Tags.CALENDAR_RECURRENCE_OCCURRENCES, count);
1166         }
1167         String interval = tokenFromRrule(rrule, "INTERVAL=");
1168         if (interval != null) {
1169             s.data(Tags.CALENDAR_RECURRENCE_INTERVAL, interval);
1170         }
1171         String until = tokenFromRrule(rrule, "UNTIL=");
1172         if (until != null) {
1173             s.data(Tags.CALENDAR_RECURRENCE_UNTIL, recurrenceUntilToEasUntil(until));
1174         }
1175     }
1176 
addByDay(String byDay, Serializer s)1177     static private void addByDay(String byDay, Serializer s) throws IOException {
1178         // This can be 1WE (1st Wednesday) or -1FR (last Friday)
1179         int weekOfMonth = byDay.charAt(0);
1180         String bareByDay;
1181         if (weekOfMonth == '-') {
1182             // -1 is the only legal case (last week) Use "5" for EAS
1183             weekOfMonth = 5;
1184             bareByDay = byDay.substring(2);
1185         } else {
1186             weekOfMonth = weekOfMonth - '0';
1187             bareByDay = byDay.substring(1);
1188         }
1189         s.data(Tags.CALENDAR_RECURRENCE_WEEKOFMONTH, Integer.toString(weekOfMonth));
1190         s.data(Tags.CALENDAR_RECURRENCE_DAYOFWEEK, generateEasDayOfWeek(bareByDay));
1191     }
1192 
addByDaySetpos(String byDay, String bySetpos, Serializer s)1193     static private void addByDaySetpos(String byDay, String bySetpos, Serializer s)
1194             throws IOException {
1195         int weekOfMonth = bySetpos.charAt(0);
1196         if (weekOfMonth == '-') {
1197             // -1 is the only legal case (last week) Use "5" for EAS
1198             weekOfMonth = 5;
1199         } else {
1200             weekOfMonth = weekOfMonth - '0';
1201         }
1202         s.data(Tags.CALENDAR_RECURRENCE_WEEKOFMONTH, Integer.toString(weekOfMonth));
1203         s.data(Tags.CALENDAR_RECURRENCE_DAYOFWEEK, generateEasDayOfWeek(byDay));
1204     }
1205 
1206     /**
1207      * Write recurrence information to EAS based on the RRULE in CalendarProvider
1208      * @param rrule the RRULE, from CalendarProvider
1209      * @param startTime, the DTSTART of this Event
1210      * @param s the Serializer we're using to write WBXML data
1211      * @throws IOException
1212      */
1213     // NOTE: For the moment, we're only parsing recurrence types that are supported by the
1214     // Calendar app UI, which is a subset of possible recurrence types
1215     // This code must be updated when the Calendar adds new functionality
recurrenceFromRrule(String rrule, long startTime, Serializer s)1216     static public void recurrenceFromRrule(String rrule, long startTime, Serializer s)
1217             throws IOException {
1218         if (Eas.USER_LOG) {
1219             ExchangeService.log(TAG, "RRULE: " + rrule);
1220         }
1221         String freq = tokenFromRrule(rrule, "FREQ=");
1222         // If there's no FREQ=X, then we don't write a recurrence
1223         // Note that we duplicate s.start(Tags.CALENDAR_RECURRENCE); s.end(); to prevent the
1224         // possibility of writing out a partial recurrence stanza
1225         if (freq != null) {
1226             if (freq.equals("DAILY")) {
1227                 s.start(Tags.CALENDAR_RECURRENCE);
1228                 s.data(Tags.CALENDAR_RECURRENCE_TYPE, "0");
1229                 addCountIntervalAndUntil(rrule, s);
1230                 s.end();
1231             } else if (freq.equals("WEEKLY")) {
1232                 s.start(Tags.CALENDAR_RECURRENCE);
1233                 s.data(Tags.CALENDAR_RECURRENCE_TYPE, "1");
1234                 // Requires a day of week (whereas RRULE does not)
1235                 addCountIntervalAndUntil(rrule, s);
1236                 String byDay = tokenFromRrule(rrule, "BYDAY=");
1237                 if (byDay != null) {
1238                     s.data(Tags.CALENDAR_RECURRENCE_DAYOFWEEK, generateEasDayOfWeek(byDay));
1239                     // Find week number (1-4 and 5 for last)
1240                     if (byDay.startsWith("-1")) {
1241                         s.data(Tags.CALENDAR_RECURRENCE_WEEKOFMONTH, "5");
1242                     } else {
1243                         char c = byDay.charAt(0);
1244                         if (c >= '1' && c <= '4') {
1245                             s.data(Tags.CALENDAR_RECURRENCE_WEEKOFMONTH, byDay.substring(0, 1));
1246                         }
1247                     }
1248                 }
1249                 s.end();
1250             } else if (freq.equals("MONTHLY")) {
1251                 String byMonthDay = tokenFromRrule(rrule, "BYMONTHDAY=");
1252                 if (byMonthDay != null) {
1253                     s.start(Tags.CALENDAR_RECURRENCE);
1254                     // Special case for last day of month
1255                     if (byMonthDay == "-1") {
1256                         s.data(Tags.CALENDAR_RECURRENCE_TYPE, "3");
1257                         addCountIntervalAndUntil(rrule, s);
1258                         s.data(Tags.CALENDAR_RECURRENCE_DAYOFWEEK, "127");
1259                     } else {
1260                         // The nth day of the month
1261                         s.data(Tags.CALENDAR_RECURRENCE_TYPE, "2");
1262                         addCountIntervalAndUntil(rrule, s);
1263                         s.data(Tags.CALENDAR_RECURRENCE_DAYOFMONTH, byMonthDay);
1264                     }
1265                     s.end();
1266                 } else {
1267                     String byDay = tokenFromRrule(rrule, "BYDAY=");
1268                     String bySetpos = tokenFromRrule(rrule, "BYSETPOS=");
1269                     if (byDay != null) {
1270                         s.start(Tags.CALENDAR_RECURRENCE);
1271                         s.data(Tags.CALENDAR_RECURRENCE_TYPE, "3");
1272                         addCountIntervalAndUntil(rrule, s);
1273                         if (bySetpos != null) {
1274                             addByDaySetpos(byDay, bySetpos, s);
1275                         } else {
1276                             addByDay(byDay, s);
1277                         }
1278                         s.end();
1279                     }
1280                 }
1281             } else if (freq.equals("YEARLY")) {
1282                 String byMonth = tokenFromRrule(rrule, "BYMONTH=");
1283                 String byMonthDay = tokenFromRrule(rrule, "BYMONTHDAY=");
1284                 String byDay = tokenFromRrule(rrule, "BYDAY=");
1285                 if (byMonth == null && byMonthDay == null) {
1286                     // Calculate the month and day from the startDate
1287                     GregorianCalendar cal = new GregorianCalendar();
1288                     cal.setTimeInMillis(startTime);
1289                     cal.setTimeZone(TimeZone.getDefault());
1290                     byMonth = Integer.toString(cal.get(Calendar.MONTH) + 1);
1291                     byMonthDay = Integer.toString(cal.get(Calendar.DAY_OF_MONTH));
1292                 }
1293                 if (byMonth != null && (byMonthDay != null || byDay != null)) {
1294                     s.start(Tags.CALENDAR_RECURRENCE);
1295                     s.data(Tags.CALENDAR_RECURRENCE_TYPE, byDay == null ? "5" : "6");
1296                     addCountIntervalAndUntil(rrule, s);
1297                     s.data(Tags.CALENDAR_RECURRENCE_MONTHOFYEAR, byMonth);
1298                     // Note that both byMonthDay and byDay can't be true in a valid RRULE
1299                     if (byMonthDay != null) {
1300                         s.data(Tags.CALENDAR_RECURRENCE_DAYOFMONTH, byMonthDay);
1301                     } else {
1302                         addByDay(byDay, s);
1303                     }
1304                     s.end();
1305                 }
1306             }
1307         }
1308     }
1309 
1310     /**
1311      * Build an RRULE String from EAS recurrence information
1312      * @param type the type of recurrence
1313      * @param occurrences how many recurrences (instances)
1314      * @param interval the interval between recurrences
1315      * @param dow day of the week
1316      * @param dom day of the month
1317      * @param wom week of the month
1318      * @param moy month of the year
1319      * @param until the last recurrence time
1320      * @return a valid RRULE String
1321      */
rruleFromRecurrence(int type, int occurrences, int interval, int dow, int dom, int wom, int moy, String until)1322     static public String rruleFromRecurrence(int type, int occurrences, int interval, int dow,
1323             int dom, int wom, int moy, String until) {
1324         StringBuilder rrule = new StringBuilder("FREQ=" + sTypeToFreq[type]);
1325         // INTERVAL and COUNT
1326         if (occurrences > 0) {
1327             rrule.append(";COUNT=" + occurrences);
1328         }
1329         if (interval > 0) {
1330             rrule.append(";INTERVAL=" + interval);
1331         }
1332 
1333         // Days, weeks, months, etc.
1334         switch(type) {
1335             case 0: // DAILY
1336             case 1: // WEEKLY
1337                 if (dow > 0) addByDay(rrule, dow, wom);
1338                 break;
1339             case 2: // MONTHLY
1340                 if (dom > 0) addByMonthDay(rrule, dom);
1341                 break;
1342             case 3: // MONTHLY (on the nth day)
1343                 // 127 is a special case meaning "last day of the month"
1344                 if (dow == 127) {
1345                     rrule.append(";BYMONTHDAY=-1");
1346                 // week 5 and dow = weekdays -> last weekday (need BYSETPOS)
1347                 } else if ((wom == 5 || wom == 1) && (dow == EAS_WEEKDAYS || dow == EAS_WEEKENDS)) {
1348                     addBySetpos(rrule, dow, wom);
1349                 } else if (dow > 0) addByDay(rrule, dow, wom);
1350                 break;
1351             case 5: // YEARLY (specific day)
1352                 if (dom > 0) addByMonthDay(rrule, dom);
1353                 if (moy > 0) {
1354                     rrule.append(";BYMONTH=" + moy);
1355                 }
1356                 break;
1357             case 6: // YEARLY
1358                 if (dow > 0) addByDay(rrule, dow, wom);
1359                 if (dom > 0) addByMonthDay(rrule, dom);
1360                 if (moy > 0) {
1361                     rrule.append(";BYMONTH=" + moy);
1362                 }
1363                 break;
1364             default:
1365                 break;
1366         }
1367 
1368         // UNTIL comes last
1369         if (until != null) {
1370             rrule.append(";UNTIL=" + until);
1371         }
1372 
1373         if (Eas.USER_LOG) {
1374             Log.d(Logging.LOG_TAG, "Created rrule: " + rrule);
1375         }
1376         return rrule.toString();
1377     }
1378 
1379     /**
1380      * Create a Calendar in CalendarProvider to which synced Events will be linked
1381      * @param service the sync service requesting Calendar creation
1382      * @param account the account being synced
1383      * @param mailbox the Exchange mailbox for the calendar
1384      * @return the unique id of the Calendar
1385      */
createCalendar(EasSyncService service, Account account, Mailbox mailbox)1386     static public long createCalendar(EasSyncService service, Account account, Mailbox mailbox) {
1387         // Create a Calendar object
1388         ContentValues cv = new ContentValues();
1389         // TODO How will this change if the user changes his account display name?
1390         cv.put(Calendars.CALENDAR_DISPLAY_NAME, account.mDisplayName);
1391         cv.put(Calendars.ACCOUNT_NAME, account.mEmailAddress);
1392         cv.put(Calendars.ACCOUNT_TYPE, Eas.EXCHANGE_ACCOUNT_MANAGER_TYPE);
1393         cv.put(Calendars.SYNC_EVENTS, 1);
1394         cv.put(Calendars.VISIBLE, 1);
1395         // Don't show attendee status if we're the organizer
1396         cv.put(Calendars.CAN_ORGANIZER_RESPOND, 0);
1397         cv.put(Calendars.CAN_MODIFY_TIME_ZONE, 0);
1398         cv.put(Calendars.MAX_REMINDERS, 1);
1399         cv.put(Calendars.ALLOWED_REMINDERS, ALLOWED_REMINDER_TYPES);
1400         cv.put(Calendars.ALLOWED_ATTENDEE_TYPES, ALLOWED_ATTENDEE_TYPES);
1401         cv.put(Calendars.ALLOWED_AVAILABILITY, ALLOWED_AVAILABILITIES);
1402 
1403         // TODO Coordinate account colors w/ Calendar, if possible
1404         int color = new AccountServiceProxy(service.mContext).getAccountColor(account.mId);
1405         cv.put(Calendars.CALENDAR_COLOR, color);
1406         cv.put(Calendars.CALENDAR_TIME_ZONE, Time.getCurrentTimezone());
1407         cv.put(Calendars.CALENDAR_ACCESS_LEVEL, Calendars.CAL_ACCESS_OWNER);
1408         cv.put(Calendars.OWNER_ACCOUNT, account.mEmailAddress);
1409 
1410         Uri uri = service.mContentResolver.insert(
1411                 asSyncAdapter(Calendars.CONTENT_URI, account.mEmailAddress,
1412                         Eas.EXCHANGE_ACCOUNT_MANAGER_TYPE), cv);
1413         // We save the id of the calendar into mSyncStatus
1414         if (uri != null) {
1415             String stringId = uri.getPathSegments().get(1);
1416             mailbox.mSyncStatus = stringId;
1417             return Long.parseLong(stringId);
1418         }
1419         return -1;
1420     }
1421 
asSyncAdapter(Uri uri, String account, String accountType)1422     static Uri asSyncAdapter(Uri uri, String account, String accountType) {
1423         return uri.buildUpon()
1424                 .appendQueryParameter(android.provider.CalendarContract.CALLER_IS_SYNCADAPTER,
1425                         "true")
1426                 .appendQueryParameter(Calendars.ACCOUNT_NAME, account)
1427                 .appendQueryParameter(Calendars.ACCOUNT_TYPE, accountType).build();
1428     }
1429 
1430     /**
1431      * Return the uid for an event based on its globalObjId
1432      * @param globalObjId the base64 encoded String provided by EAS
1433      * @return the uid for the calendar event
1434      */
getUidFromGlobalObjId(String globalObjId)1435     static public String getUidFromGlobalObjId(String globalObjId) {
1436         StringBuilder sb = new StringBuilder();
1437         // First get the decoded base64
1438         try {
1439             byte[] idBytes = Base64.decode(globalObjId, Base64.DEFAULT);
1440             String idString = new String(idBytes);
1441             // If the base64 decoded string contains the magic substring: "vCal-Uid", then
1442             // the actual uid is hidden within; the magic substring is never at the start of the
1443             // decoded base64
1444             int index = idString.indexOf("vCal-Uid");
1445             if (index > 0) {
1446                 // The uid starts after "vCal-Uidxxxx", where xxxx are padding
1447                 // characters.  And it ends before the last character, which is ascii 0
1448                 return idString.substring(index + 12, idString.length() - 1);
1449             } else {
1450                 // This is an EAS uid. Go through the bytes and write out the hex
1451                 // values as characters; this is what we'll need to pass back to EAS
1452                 // when responding to the invitation
1453                 for (byte b: idBytes) {
1454                     Utility.byteToHex(sb, b);
1455                 }
1456                 return sb.toString();
1457             }
1458         } catch (RuntimeException e) {
1459             // In the worst of cases (bad format, etc.), we can always return the input
1460             return globalObjId;
1461         }
1462     }
1463 
1464     /**
1465      * Get a selfAttendeeStatus from a busy status
1466      * The default here is NONE (i.e. we don't know the status)
1467      * Note that a busy status of FREE must mean NONE as well, since it can't mean declined
1468      * (there would be no event)
1469      * @param busyStatus the busy status, from EAS
1470      * @return the corresponding value for selfAttendeeStatus
1471      */
attendeeStatusFromBusyStatus(int busyStatus)1472     static public int attendeeStatusFromBusyStatus(int busyStatus) {
1473         int attendeeStatus;
1474         switch (busyStatus) {
1475             case BUSY_STATUS_BUSY:
1476                 attendeeStatus = Attendees.ATTENDEE_STATUS_ACCEPTED;
1477                 break;
1478             case BUSY_STATUS_TENTATIVE:
1479                 attendeeStatus = Attendees.ATTENDEE_STATUS_TENTATIVE;
1480                 break;
1481             case BUSY_STATUS_FREE:
1482             case BUSY_STATUS_OUT_OF_OFFICE:
1483             default:
1484                 attendeeStatus = Attendees.ATTENDEE_STATUS_NONE;
1485         }
1486         return attendeeStatus;
1487     }
1488 
1489     /**
1490      * Get a selfAttendeeStatus from a response type (EAS 14+)
1491      * The default here is NONE (i.e. we don't know the status), though in theory this can't happen
1492      * @param busyStatus the response status, from EAS
1493      * @return the corresponding value for selfAttendeeStatus
1494      */
attendeeStatusFromResponseType(int responseType)1495     static public int attendeeStatusFromResponseType(int responseType) {
1496         int attendeeStatus;
1497         switch (responseType) {
1498             case RESPONSE_TYPE_NOT_RESPONDED:
1499                 attendeeStatus = Attendees.ATTENDEE_STATUS_NONE;
1500                 break;
1501             case RESPONSE_TYPE_ACCEPTED:
1502                 attendeeStatus = Attendees.ATTENDEE_STATUS_ACCEPTED;
1503                 break;
1504             case RESPONSE_TYPE_TENTATIVE:
1505                 attendeeStatus = Attendees.ATTENDEE_STATUS_TENTATIVE;
1506                 break;
1507             case RESPONSE_TYPE_DECLINED:
1508                 attendeeStatus = Attendees.ATTENDEE_STATUS_DECLINED;
1509                 break;
1510             default:
1511                 attendeeStatus = Attendees.ATTENDEE_STATUS_NONE;
1512         }
1513         return attendeeStatus;
1514     }
1515 
1516     /** Get a busy status from a selfAttendeeStatus
1517      * The default here is BUSY
1518      * @param selfAttendeeStatus from CalendarProvider2
1519      * @return the corresponding value of busy status
1520      */
busyStatusFromAttendeeStatus(int selfAttendeeStatus)1521     static public int busyStatusFromAttendeeStatus(int selfAttendeeStatus) {
1522         int busyStatus;
1523         switch (selfAttendeeStatus) {
1524             case Attendees.ATTENDEE_STATUS_DECLINED:
1525             case Attendees.ATTENDEE_STATUS_NONE:
1526             case Attendees.ATTENDEE_STATUS_INVITED:
1527                 busyStatus = BUSY_STATUS_FREE;
1528                 break;
1529             case Attendees.ATTENDEE_STATUS_TENTATIVE:
1530                 busyStatus = BUSY_STATUS_TENTATIVE;
1531                 break;
1532             case Attendees.ATTENDEE_STATUS_ACCEPTED:
1533             default:
1534                 busyStatus = BUSY_STATUS_BUSY;
1535                 break;
1536         }
1537         return busyStatus;
1538     }
1539 
1540     /** Get a busy status from event availability
1541      * The default here is TENTATIVE
1542      * @param availability from CalendarProvider2
1543      * @return the corresponding value of busy status
1544      */
busyStatusFromAvailability(int availability)1545     static public int busyStatusFromAvailability(int availability) {
1546         int busyStatus;
1547         switch (availability) {
1548             case Events.AVAILABILITY_BUSY:
1549                 busyStatus = BUSY_STATUS_BUSY;
1550                 break;
1551             case Events.AVAILABILITY_FREE:
1552                 busyStatus = BUSY_STATUS_FREE;
1553                 break;
1554             case Events.AVAILABILITY_TENTATIVE:
1555             default:
1556                 busyStatus = BUSY_STATUS_TENTATIVE;
1557                 break;
1558         }
1559         return busyStatus;
1560     }
1561 
1562     /** Get an event availability from busy status
1563      * The default here is TENTATIVE
1564      * @param busyStatus from CalendarProvider2
1565      * @return the corresponding availability value
1566      */
availabilityFromBusyStatus(int busyStatus)1567     static public int availabilityFromBusyStatus(int busyStatus) {
1568         int availability;
1569         switch (busyStatus) {
1570             case BUSY_STATUS_BUSY:
1571                 availability = Events.AVAILABILITY_BUSY;
1572                 break;
1573             case BUSY_STATUS_FREE:
1574                 availability = Events.AVAILABILITY_FREE;
1575                 break;
1576             case BUSY_STATUS_TENTATIVE:
1577             default:
1578                 availability = Events.AVAILABILITY_TENTATIVE;
1579                 break;
1580         }
1581         return availability;
1582     }
1583 
buildMessageTextFromEntityValues(Context context, ContentValues entityValues, StringBuilder sb)1584     static public String buildMessageTextFromEntityValues(Context context,
1585             ContentValues entityValues, StringBuilder sb) {
1586         if (sb == null) {
1587             sb = new StringBuilder();
1588         }
1589         Resources resources = context.getResources();
1590         // TODO: Add more detail to message text
1591         // Right now, we're using.. When: Tuesday, March 5th at 2:00pm
1592         // What we're missing is the duration and any recurrence information.  So this should be
1593         // more like... When: Tuesdays, starting March 5th from 2:00pm - 3:00pm
1594         // This would require code to build complex strings, and it will have to wait
1595         // For now, we'll just use the meeting_recurring string
1596 
1597         boolean allDayEvent = false;
1598         if (entityValues.containsKey(Events.ALL_DAY)) {
1599             Integer ade = entityValues.getAsInteger(Events.ALL_DAY);
1600             allDayEvent = (ade != null) && (ade == 1);
1601         }
1602         boolean recurringEvent = !entityValues.containsKey(Events.ORIGINAL_SYNC_ID) &&
1603             entityValues.containsKey(Events.RRULE);
1604 
1605         String dateTimeString;
1606         int res;
1607         long startTime = entityValues.getAsLong(Events.DTSTART);
1608         if (allDayEvent) {
1609             Date date = new Date(getLocalAllDayCalendarTime(startTime, TimeZone.getDefault()));
1610             dateTimeString = DateFormat.getDateInstance().format(date);
1611             res = recurringEvent ? R.string.meeting_allday_recurring : R.string.meeting_allday;
1612         } else {
1613             dateTimeString = DateFormat.getDateTimeInstance().format(new Date(startTime));
1614             res = recurringEvent ? R.string.meeting_recurring : R.string.meeting_when;
1615         }
1616         sb.append(resources.getString(res, dateTimeString));
1617 
1618         String location = null;
1619         if (entityValues.containsKey(Events.EVENT_LOCATION)) {
1620             location = entityValues.getAsString(Events.EVENT_LOCATION);
1621             if (!TextUtils.isEmpty(location)) {
1622                 sb.append("\n");
1623                 sb.append(resources.getString(R.string.meeting_where, location));
1624             }
1625         }
1626         // If there's a description for this event, append it
1627         String desc = entityValues.getAsString(Events.DESCRIPTION);
1628         if (desc != null) {
1629             sb.append("\n--\n");
1630             sb.append(desc);
1631         }
1632         return sb.toString();
1633     }
1634 
1635     /**
1636      * Add an attendee to the ics attachment and the to list of the Message being composed
1637      * @param ics the ics attachment writer
1638      * @param toList the list of addressees for this email
1639      * @param attendeeName the name of the attendee
1640      * @param attendeeEmail the email address of the attendee
1641      * @param messageFlag the flag indicating the action to be indicated by the message
1642      * @param account the sending account of the email
1643      */
addAttendeeToMessage(SimpleIcsWriter ics, ArrayList<Address> toList, String attendeeName, String attendeeEmail, int messageFlag, Account account)1644     static private void addAttendeeToMessage(SimpleIcsWriter ics, ArrayList<Address> toList,
1645             String attendeeName, String attendeeEmail, int messageFlag, Account account) {
1646         if ((messageFlag & Message.FLAG_OUTGOING_MEETING_REQUEST_MASK) != 0) {
1647             String icalTag = ICALENDAR_ATTENDEE_INVITE;
1648             if ((messageFlag & Message.FLAG_OUTGOING_MEETING_CANCEL) != 0) {
1649                 icalTag = ICALENDAR_ATTENDEE_CANCEL;
1650             }
1651             if (attendeeName != null) {
1652                 icalTag += ";CN=" + SimpleIcsWriter.quoteParamValue(attendeeName);
1653             }
1654             ics.writeTag(icalTag, "MAILTO:" + attendeeEmail);
1655             toList.add(attendeeName == null ? new Address(attendeeEmail) :
1656                 new Address(attendeeEmail, attendeeName));
1657         } else if (attendeeEmail.equalsIgnoreCase(account.mEmailAddress)) {
1658             String icalTag = null;
1659             switch (messageFlag) {
1660                 case Message.FLAG_OUTGOING_MEETING_ACCEPT:
1661                     icalTag = ICALENDAR_ATTENDEE_ACCEPT;
1662                     break;
1663                 case Message.FLAG_OUTGOING_MEETING_DECLINE:
1664                     icalTag = ICALENDAR_ATTENDEE_DECLINE;
1665                     break;
1666                 case Message.FLAG_OUTGOING_MEETING_TENTATIVE:
1667                     icalTag = ICALENDAR_ATTENDEE_TENTATIVE;
1668                     break;
1669             }
1670             if (icalTag != null) {
1671                 if (attendeeName != null) {
1672                     icalTag += ";CN="
1673                             + SimpleIcsWriter.quoteParamValue(attendeeName);
1674                 }
1675                 ics.writeTag(icalTag, "MAILTO:" + attendeeEmail);
1676             }
1677         }
1678     }
1679 
1680     /**
1681      * Create a Message for an (Event) Entity
1682      * @param entity the Entity for the Event (as might be retrieved by CalendarProvider)
1683      * @param messageFlag the Message.FLAG_XXX constant indicating the type of email to be sent
1684      * @param the unique id of this Event, or null if it can be retrieved from the Event
1685      * @param the user's account
1686      * @return a Message with many fields pre-filled (more later)
1687      */
createMessageForEntity(Context context, Entity entity, int messageFlag, String uid, Account account)1688     static public Message createMessageForEntity(Context context, Entity entity,
1689             int messageFlag, String uid, Account account) {
1690         return createMessageForEntity(context, entity, messageFlag, uid, account,
1691                 null /*specifiedAttendee*/);
1692     }
1693 
createMessageForEntity(Context context, Entity entity, int messageFlag, String uid, Account account, String specifiedAttendee)1694     static public EmailContent.Message createMessageForEntity(Context context, Entity entity,
1695             int messageFlag, String uid, Account account, String specifiedAttendee) {
1696         ContentValues entityValues = entity.getEntityValues();
1697         ArrayList<NamedContentValues> subValues = entity.getSubValues();
1698         boolean isException = entityValues.containsKey(Events.ORIGINAL_SYNC_ID);
1699         boolean isReply = false;
1700 
1701         EmailContent.Message msg = new EmailContent.Message();
1702         msg.mFlags = messageFlag;
1703         msg.mTimeStamp = System.currentTimeMillis();
1704 
1705         String method;
1706         if ((messageFlag & EmailContent.Message.FLAG_OUTGOING_MEETING_INVITE) != 0) {
1707             method = "REQUEST";
1708         } else if ((messageFlag & EmailContent.Message.FLAG_OUTGOING_MEETING_CANCEL) != 0) {
1709             method = "CANCEL";
1710         } else {
1711             method = "REPLY";
1712             isReply = true;
1713         }
1714 
1715         try {
1716             // Create our iCalendar writer and start generating tags
1717             SimpleIcsWriter ics = new SimpleIcsWriter();
1718             ics.writeTag("BEGIN", "VCALENDAR");
1719             ics.writeTag("METHOD", method);
1720             ics.writeTag("PRODID", "AndroidEmail");
1721             ics.writeTag("VERSION", "2.0");
1722 
1723             // Our default vcalendar time zone is UTC, but this will change (below) if we're
1724             // sending a recurring event, in which case we use local time
1725             TimeZone vCalendarTimeZone = sGmtTimeZone;
1726             String vCalendarDateSuffix = "";
1727 
1728             // Check for all day event
1729             boolean allDayEvent = false;
1730             if (entityValues.containsKey(Events.ALL_DAY)) {
1731                 Integer ade = entityValues.getAsInteger(Events.ALL_DAY);
1732                 allDayEvent = (ade != null) && (ade == 1);
1733                 if (allDayEvent) {
1734                     // Example: DTSTART;VALUE=DATE:20100331 (all day event)
1735                     vCalendarDateSuffix = ";VALUE=DATE";
1736                 }
1737             }
1738 
1739             // If we're inviting people and the meeting is recurring, we need to send our time zone
1740             // information and make sure to send DTSTART/DTEND in local time (unless, of course,
1741             // this is an all-day event).  Recurring, for this purpose, includes exceptions to
1742             // recurring events
1743             if (!isReply && !allDayEvent &&
1744                     (entityValues.containsKey(Events.RRULE) ||
1745                             entityValues.containsKey(Events.ORIGINAL_SYNC_ID))) {
1746                 vCalendarTimeZone = TimeZone.getDefault();
1747                 // Write the VTIMEZONE block to the writer
1748                 timeZoneToVTimezone(vCalendarTimeZone, ics);
1749                 // Example: DTSTART;TZID=US/Pacific:20100331T124500
1750                 vCalendarDateSuffix = ";TZID=" + vCalendarTimeZone.getID();
1751             }
1752 
1753             ics.writeTag("BEGIN", "VEVENT");
1754             if (uid == null) {
1755                 uid = entityValues.getAsString(Events.SYNC_DATA2);
1756             }
1757             if (uid != null) {
1758                 ics.writeTag("UID", uid);
1759             }
1760 
1761             if (entityValues.containsKey("DTSTAMP")) {
1762                 ics.writeTag("DTSTAMP", entityValues.getAsString("DTSTAMP"));
1763             } else {
1764                 ics.writeTag("DTSTAMP", millisToEasDateTime(System.currentTimeMillis()));
1765             }
1766 
1767             long startTime = entityValues.getAsLong(Events.DTSTART);
1768             if (startTime != 0) {
1769                 ics.writeTag("DTSTART" + vCalendarDateSuffix,
1770                         millisToEasDateTime(startTime, vCalendarTimeZone, !allDayEvent));
1771             }
1772 
1773             // If this is an Exception, we send the recurrence-id, which is just the original
1774             // instance time
1775             if (isException) {
1776                 long originalTime = entityValues.getAsLong(Events.ORIGINAL_INSTANCE_TIME);
1777                 ics.writeTag("RECURRENCE-ID" + vCalendarDateSuffix,
1778                         millisToEasDateTime(originalTime, vCalendarTimeZone, !allDayEvent));
1779             }
1780 
1781             if (!entityValues.containsKey(Events.DURATION)) {
1782                 if (entityValues.containsKey(Events.DTEND)) {
1783                     ics.writeTag("DTEND" + vCalendarDateSuffix,
1784                             millisToEasDateTime(
1785                                     entityValues.getAsLong(Events.DTEND), vCalendarTimeZone,
1786                                     !allDayEvent));
1787                 }
1788             } else {
1789                 // Convert this into millis and add it to DTSTART for DTEND
1790                 // We'll use 1 hour as a default
1791                 long durationMillis = HOURS;
1792                 Duration duration = new Duration();
1793                 try {
1794                     duration.parse(entityValues.getAsString(Events.DURATION));
1795                     durationMillis = duration.getMillis();
1796                 } catch (DateException e) {
1797                     // We'll use the default in this case
1798                 }
1799                 ics.writeTag("DTEND" + vCalendarDateSuffix,
1800                         millisToEasDateTime(
1801                                 startTime + durationMillis, vCalendarTimeZone, !allDayEvent));
1802             }
1803 
1804             String location = null;
1805             if (entityValues.containsKey(Events.EVENT_LOCATION)) {
1806                 location = entityValues.getAsString(Events.EVENT_LOCATION);
1807                 ics.writeTag("LOCATION", location);
1808             }
1809 
1810             String sequence = entityValues.getAsString(SYNC_VERSION);
1811             if (sequence == null) {
1812                 sequence = "0";
1813             }
1814 
1815             // We'll use 0 to mean a meeting invitation
1816             int titleId = 0;
1817             switch (messageFlag) {
1818                 case Message.FLAG_OUTGOING_MEETING_INVITE:
1819                     if (!sequence.equals("0")) {
1820                         titleId = R.string.meeting_updated;
1821                     }
1822                     break;
1823                 case Message.FLAG_OUTGOING_MEETING_ACCEPT:
1824                     titleId = R.string.meeting_accepted;
1825                     break;
1826                 case Message.FLAG_OUTGOING_MEETING_DECLINE:
1827                     titleId = R.string.meeting_declined;
1828                     break;
1829                 case Message.FLAG_OUTGOING_MEETING_TENTATIVE:
1830                     titleId = R.string.meeting_tentative;
1831                     break;
1832                 case Message.FLAG_OUTGOING_MEETING_CANCEL:
1833                     titleId = R.string.meeting_canceled;
1834                     break;
1835             }
1836             Resources resources = context.getResources();
1837             String title = entityValues.getAsString(Events.TITLE);
1838             if (title == null) {
1839                 title = "";
1840             }
1841             ics.writeTag("SUMMARY", title);
1842             // For meeting invitations just use the title
1843             if (titleId == 0) {
1844                 msg.mSubject = title;
1845             } else {
1846                 // Otherwise, use the additional text
1847                 msg.mSubject = resources.getString(titleId, title);
1848             }
1849 
1850             // Build the text for the message, starting with an initial line describing the
1851             // exception (if this is one)
1852             StringBuilder sb = new StringBuilder();
1853             if (isException && !isReply) {
1854                 // Add the line, depending on whether this is a cancellation or update
1855                 Date date = new Date(entityValues.getAsLong(Events.ORIGINAL_INSTANCE_TIME));
1856                 String dateString = DateFormat.getDateInstance().format(date);
1857                 if (titleId == R.string.meeting_canceled) {
1858                     sb.append(resources.getString(R.string.exception_cancel, dateString));
1859                 } else {
1860                     sb.append(resources.getString(R.string.exception_updated, dateString));
1861                 }
1862                 sb.append("\n\n");
1863             }
1864             String text =
1865                 CalendarUtilities.buildMessageTextFromEntityValues(context, entityValues, sb);
1866 
1867             if (text.length() > 0) {
1868                 ics.writeTag("DESCRIPTION", text);
1869             }
1870             // And store the message text
1871             msg.mText = text;
1872             if (!isReply) {
1873                 if (entityValues.containsKey(Events.ALL_DAY)) {
1874                     Integer ade = entityValues.getAsInteger(Events.ALL_DAY);
1875                     ics.writeTag("X-MICROSOFT-CDO-ALLDAYEVENT", ade == 0 ? "FALSE" : "TRUE");
1876                 }
1877 
1878                 String rrule = entityValues.getAsString(Events.RRULE);
1879                 if (rrule != null) {
1880                     ics.writeTag("RRULE", rrule);
1881                 }
1882 
1883                 // If we decide to send alarm information in the meeting request ics file,
1884                 // handle it here by looping through the subvalues
1885             }
1886 
1887             // Handle attendee data here; determine "to" list and add ATTENDEE tags to ics
1888             String organizerName = null;
1889             String organizerEmail = null;
1890             ArrayList<Address> toList = new ArrayList<Address>();
1891             for (NamedContentValues ncv: subValues) {
1892                 Uri ncvUri = ncv.uri;
1893                 ContentValues ncvValues = ncv.values;
1894                 if (ncvUri.equals(Attendees.CONTENT_URI)) {
1895                     Integer relationship =
1896                         ncvValues.getAsInteger(Attendees.ATTENDEE_RELATIONSHIP);
1897                     // If there's no relationship, we can't create this for EAS
1898                     // Similarly, we need an attendee email for each invitee
1899                     if (relationship != null &&
1900                             ncvValues.containsKey(Attendees.ATTENDEE_EMAIL)) {
1901                         // Organizer isn't among attendees in EAS
1902                         if (relationship == Attendees.RELATIONSHIP_ORGANIZER) {
1903                             organizerName = ncvValues.getAsString(Attendees.ATTENDEE_NAME);
1904                             organizerEmail = ncvValues.getAsString(Attendees.ATTENDEE_EMAIL);
1905                             continue;
1906                         }
1907                         String attendeeEmail = ncvValues.getAsString(Attendees.ATTENDEE_EMAIL);
1908                         String attendeeName = ncvValues.getAsString(Attendees.ATTENDEE_NAME);
1909 
1910                         // This shouldn't be possible, but allow for it
1911                         if (attendeeEmail == null) continue;
1912                         // If we only want to send to the specifiedAttendee, eliminate others here
1913                         if ((specifiedAttendee != null) &&
1914                                 !attendeeEmail.equalsIgnoreCase(specifiedAttendee)) {
1915                             continue;
1916                         }
1917 
1918                         addAttendeeToMessage(ics, toList, attendeeName, attendeeEmail, messageFlag,
1919                                 account);
1920                     }
1921                 }
1922             }
1923 
1924             // Manually add the specifiedAttendee if he wasn't added in the Attendees loop
1925             if (toList.isEmpty() && (specifiedAttendee != null)) {
1926                 addAttendeeToMessage(ics, toList, null, specifiedAttendee, messageFlag, account);
1927             }
1928 
1929             // Create the organizer tag for ical
1930             if (organizerEmail != null) {
1931                 String icalTag = "ORGANIZER";
1932                 // We should be able to find this, assuming the Email is the user's email
1933                 // TODO Find this in the account
1934                 if (organizerName != null) {
1935                     icalTag += ";CN=" + SimpleIcsWriter.quoteParamValue(organizerName);
1936                 }
1937                 ics.writeTag(icalTag, "MAILTO:" + organizerEmail);
1938                 if (isReply) {
1939                     toList.add(organizerName == null ? new Address(organizerEmail) :
1940                         new Address(organizerEmail, organizerName));
1941                 }
1942             }
1943 
1944             // If we have no "to" list, we're done
1945             if (toList.isEmpty()) return null;
1946 
1947             // Write out the "to" list
1948             Address[] toArray = new Address[toList.size()];
1949             int i = 0;
1950             for (Address address: toList) {
1951                 toArray[i++] = address;
1952             }
1953             msg.mTo = Address.pack(toArray);
1954 
1955             ics.writeTag("CLASS", "PUBLIC");
1956             ics.writeTag("STATUS", (messageFlag == Message.FLAG_OUTGOING_MEETING_CANCEL) ?
1957                     "CANCELLED" : "CONFIRMED");
1958             ics.writeTag("TRANSP", "OPAQUE"); // What Exchange uses
1959             ics.writeTag("PRIORITY", "5");  // 1 to 9, 5 = medium
1960             ics.writeTag("SEQUENCE", sequence);
1961             ics.writeTag("END", "VEVENT");
1962             ics.writeTag("END", "VCALENDAR");
1963 
1964             // Create the ics attachment using the "content" field
1965             Attachment att = new Attachment();
1966             att.mContentBytes = ics.getBytes();
1967             att.mMimeType = "text/calendar; method=" + method;
1968             att.mFileName = "invite.ics";
1969             att.mSize = att.mContentBytes.length;
1970             // We don't send content-disposition with this attachment
1971             att.mFlags = Attachment.FLAG_ICS_ALTERNATIVE_PART;
1972 
1973             // Add the attachment to the message
1974             msg.mAttachments = new ArrayList<Attachment>();
1975             msg.mAttachments.add(att);
1976         } catch (IOException e) {
1977             Log.w(TAG, "IOException in createMessageForEntity");
1978             return null;
1979         }
1980 
1981         // Return the new Message to caller
1982         return msg;
1983     }
1984 
1985     /**
1986      * Create a Message for an Event that can be retrieved from CalendarProvider
1987      * by its unique id
1988      *
1989      * @param cr a content resolver that can be used to query for the Event
1990      * @param eventId the unique id of the Event
1991      * @param messageFlag the Message.FLAG_XXX constant indicating the type of
1992      *            email to be sent
1993      * @param the unique id of this Event, or null if it can be retrieved from
1994      *            the Event
1995      * @param the user's account
1996      * @param requireAddressees if true (the default), no Message is returned if
1997      *            there aren't any addressees; if false, return the Message
1998      *            regardless (addressees will be filled in later)
1999      * @return a Message with many fields pre-filled (more later)
2000      * @throws RemoteException if there is an issue retrieving the Event from
2001      *             CalendarProvider
2002      */
createMessageForEventId(Context context, long eventId, int messageFlag, String uid, Account account)2003     static public EmailContent.Message createMessageForEventId(Context context, long eventId,
2004             int messageFlag, String uid, Account account) throws RemoteException {
2005         return createMessageForEventId(context, eventId, messageFlag, uid, account,
2006                 null /* specifiedAttendee */);
2007     }
2008 
createMessageForEventId(Context context, long eventId, int messageFlag, String uid, Account account, String specifiedAttendee)2009     static public EmailContent.Message createMessageForEventId(Context context, long eventId,
2010             int messageFlag, String uid, Account account, String specifiedAttendee)
2011             throws RemoteException {
2012         ContentResolver cr = context.getContentResolver();
2013         EntityIterator eventIterator = EventsEntity.newEntityIterator(cr.query(
2014                 ContentUris.withAppendedId(Events.CONTENT_URI, eventId), null, null, null, null),
2015                 cr);
2016         try {
2017             while (eventIterator.hasNext()) {
2018                 Entity entity = eventIterator.next();
2019                 return createMessageForEntity(context, entity, messageFlag, uid, account,
2020                         specifiedAttendee);
2021             }
2022         } finally {
2023             eventIterator.close();
2024         }
2025         return null;
2026     }
2027 
2028     /**
2029      * Return a boolean value for an integer ContentValues column
2030      * @param values a ContentValues object
2031      * @param columnName the name of a column to be found in the ContentValues
2032      * @return a boolean representation of the value of columnName in values; null and 0 = false,
2033      * other integers = true
2034      */
getIntegerValueAsBoolean(ContentValues values, String columnName)2035     static public boolean getIntegerValueAsBoolean(ContentValues values, String columnName) {
2036         Integer intValue = values.getAsInteger(columnName);
2037         return (intValue != null && intValue != 0);
2038     }
2039 }
2040