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