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