CalendarUtilities.java revision 4577f71f76c94dc9fcb06efd2656970925dd3f6a
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.net.Uri;
40import android.os.RemoteException;
41import android.provider.Calendar.Attendees;
42import android.provider.Calendar.Calendars;
43import android.provider.Calendar.Events;
44import android.provider.Calendar.EventsEntity;
45import android.provider.Calendar.Reminders;
46import android.text.format.Time;
47import android.util.Log;
48import android.util.base64.Base64;
49
50import java.io.IOException;
51import java.text.ParseException;
52import java.util.ArrayList;
53import java.util.Calendar;
54import java.util.Date;
55import java.util.GregorianCalendar;
56import java.util.HashMap;
57import java.util.TimeZone;
58
59public class CalendarUtilities {
60    // NOTE: Most definitions in this class are have package visibility for testing purposes
61    private static final String TAG = "CalendarUtility";
62
63    // Time related convenience constants, in milliseconds
64    static final int SECONDS = 1000;
65    static final int MINUTES = SECONDS*60;
66    static final int HOURS = MINUTES*60;
67    static final long DAYS = HOURS*24;
68
69    // NOTE All Microsoft data structures are little endian
70
71    // The following constants relate to standard Microsoft data sizes
72    // For documentation, see http://msdn.microsoft.com/en-us/library/aa505945.aspx
73    static final int MSFT_LONG_SIZE = 4;
74    static final int MSFT_WCHAR_SIZE = 2;
75    static final int MSFT_WORD_SIZE = 2;
76
77    // The following constants relate to Microsoft's SYSTEMTIME structure
78    // For documentation, see: http://msdn.microsoft.com/en-us/library/ms724950(VS.85).aspx?ppud=4
79
80    static final int MSFT_SYSTEMTIME_YEAR = 0 * MSFT_WORD_SIZE;
81    static final int MSFT_SYSTEMTIME_MONTH = 1 * MSFT_WORD_SIZE;
82    static final int MSFT_SYSTEMTIME_DAY_OF_WEEK = 2 * MSFT_WORD_SIZE;
83    static final int MSFT_SYSTEMTIME_DAY = 3 * MSFT_WORD_SIZE;
84    static final int MSFT_SYSTEMTIME_HOUR = 4 * MSFT_WORD_SIZE;
85    static final int MSFT_SYSTEMTIME_MINUTE = 5 * MSFT_WORD_SIZE;
86    //static final int MSFT_SYSTEMTIME_SECONDS = 6 * MSFT_WORD_SIZE;
87    //static final int MSFT_SYSTEMTIME_MILLIS = 7 * MSFT_WORD_SIZE;
88    static final int MSFT_SYSTEMTIME_SIZE = 8*MSFT_WORD_SIZE;
89
90    // The following constants relate to Microsoft's TIME_ZONE_INFORMATION structure
91    // For documentation, see http://msdn.microsoft.com/en-us/library/ms725481(VS.85).aspx
92    static final int MSFT_TIME_ZONE_BIAS_OFFSET = 0;
93    static final int MSFT_TIME_ZONE_STANDARD_NAME_OFFSET =
94        MSFT_TIME_ZONE_BIAS_OFFSET + MSFT_LONG_SIZE;
95    static final int MSFT_TIME_ZONE_STANDARD_DATE_OFFSET =
96        MSFT_TIME_ZONE_STANDARD_NAME_OFFSET + (MSFT_WCHAR_SIZE*32);
97    static final int MSFT_TIME_ZONE_STANDARD_BIAS_OFFSET =
98        MSFT_TIME_ZONE_STANDARD_DATE_OFFSET + MSFT_SYSTEMTIME_SIZE;
99    static final int MSFT_TIME_ZONE_DAYLIGHT_NAME_OFFSET =
100        MSFT_TIME_ZONE_STANDARD_BIAS_OFFSET + MSFT_LONG_SIZE;
101    static final int MSFT_TIME_ZONE_DAYLIGHT_DATE_OFFSET =
102        MSFT_TIME_ZONE_DAYLIGHT_NAME_OFFSET + (MSFT_WCHAR_SIZE*32);
103    static final int MSFT_TIME_ZONE_DAYLIGHT_BIAS_OFFSET =
104        MSFT_TIME_ZONE_DAYLIGHT_DATE_OFFSET + MSFT_SYSTEMTIME_SIZE;
105    static final int MSFT_TIME_ZONE_SIZE =
106        MSFT_TIME_ZONE_DAYLIGHT_BIAS_OFFSET + MSFT_LONG_SIZE;
107
108    // TimeZone cache; we parse/decode as little as possible, because the process is quite slow
109    private static HashMap<String, TimeZone> sTimeZoneCache = new HashMap<String, TimeZone>();
110    // TZI string cache; we keep around our encoded TimeZoneInformation strings
111    private static HashMap<TimeZone, String> sTziStringCache = new HashMap<TimeZone, String>();
112
113    // There is no type 4 (thus, the "")
114    static final String[] sTypeToFreq =
115        new String[] {"DAILY", "WEEKLY", "MONTHLY", "MONTHLY", "", "YEARLY", "YEARLY"};
116
117    static final String[] sDayTokens =
118        new String[] {"SU", "MO", "TU", "WE", "TH", "FR", "SA"};
119
120    static final String[] sTwoCharacterNumbers =
121        new String[] {"00", "01", "02", "03", "04", "05", "06", "07", "08", "09", "10", "11", "12"};
122
123    static final int sCurrentYear = new GregorianCalendar().get(Calendar.YEAR);
124    static final TimeZone sGmtTimeZone = TimeZone.getTimeZone("GMT");
125
126    private static final String ICALENDAR_ATTENDEE = "ATTENDEE;ROLE=REQ-PARTICIPANT;PARTSTAT=";
127    private static final String ICALENDAR_ATTENDEE_INVITE =
128        ICALENDAR_ATTENDEE + "NEEDS-ACTION;RSVP=TRUE";
129    private static final String ICALENDAR_ATTENDEE_ACCEPT = ICALENDAR_ATTENDEE + "ACCEPTED";
130    private static final String ICALENDAR_ATTENDEE_DECLINE = ICALENDAR_ATTENDEE + "DECLINED";
131    private static final String ICALENDAR_ATTENDEE_TENTATIVE = ICALENDAR_ATTENDEE + "TENTATIVE";
132
133    // Return a 4-byte long from a byte array (little endian)
134    static int getLong(byte[] bytes, int offset) {
135        return (bytes[offset++] & 0xFF) | ((bytes[offset++] & 0xFF) << 8) |
136        ((bytes[offset++] & 0xFF) << 16) | ((bytes[offset] & 0xFF) << 24);
137    }
138
139    // Put a 4-byte long into a byte array (little endian)
140    static void setLong(byte[] bytes, int offset, int value) {
141        bytes[offset++] = (byte) (value & 0xFF);
142        bytes[offset++] = (byte) ((value >> 8) & 0xFF);
143        bytes[offset++] = (byte) ((value >> 16) & 0xFF);
144        bytes[offset] = (byte) ((value >> 24) & 0xFF);
145    }
146
147    // Return a 2-byte word from a byte array (little endian)
148    static int getWord(byte[] bytes, int offset) {
149        return (bytes[offset++] & 0xFF) | ((bytes[offset] & 0xFF) << 8);
150    }
151
152    // Put a 2-byte word into a byte array (little endian)
153    static void setWord(byte[] bytes, int offset, int value) {
154        bytes[offset++] = (byte) (value & 0xFF);
155        bytes[offset] = (byte) ((value >> 8) & 0xFF);
156    }
157
158    // Internal structure for storing a time zone date from a SYSTEMTIME structure
159    // This date represents either the start or the end time for DST
160    static class TimeZoneDate {
161        String year;
162        int month;
163        int dayOfWeek;
164        int day;
165        int time;
166        int hour;
167        int minute;
168    }
169
170    static void putRuleIntoTimeZoneInformation(byte[] bytes, int offset, RRule rrule, int hour,
171            int minute) {
172        // MSFT months are 1 based, same as RRule
173        setWord(bytes, offset + MSFT_SYSTEMTIME_MONTH, rrule.month);
174        // MSFT day of week starts w/ Sunday = 0; RRule starts w/ Sunday = 1
175        setWord(bytes, offset + MSFT_SYSTEMTIME_DAY_OF_WEEK, rrule.dayOfWeek - 1);
176        // 5 means "last" in MSFT land; for RRule, it's -1
177        setWord(bytes, offset + MSFT_SYSTEMTIME_DAY, rrule.week < 0 ? 5 : rrule.week);
178        // Turn hours/minutes into ms from midnight (per TimeZone)
179        setWord(bytes, offset + MSFT_SYSTEMTIME_HOUR, hour);
180        setWord(bytes, offset + MSFT_SYSTEMTIME_MINUTE, minute);
181    }
182
183    // Write SYSTEMTIME data into a byte array (this will either be for the standard or daylight
184    // transition)
185    static void putTimeInMillisIntoSystemTime(byte[] bytes, int offset, long millis) {
186        GregorianCalendar cal = new GregorianCalendar(TimeZone.getDefault());
187        // Round to the next highest minute; we always write seconds as zero
188        cal.setTimeInMillis(millis + 30*SECONDS);
189
190        // MSFT months are 1 based; TimeZone is 0 based
191        setWord(bytes, offset + MSFT_SYSTEMTIME_MONTH, cal.get(Calendar.MONTH) + 1);
192        // MSFT day of week starts w/ Sunday = 0; TimeZone starts w/ Sunday = 1
193        setWord(bytes, offset + MSFT_SYSTEMTIME_DAY_OF_WEEK, cal.get(Calendar.DAY_OF_WEEK) - 1);
194
195        // Get the "day" in TimeZone format
196        int wom = cal.get(Calendar.DAY_OF_WEEK_IN_MONTH);
197        // 5 means "last" in MSFT land; for TimeZone, it's -1
198        setWord(bytes, offset + MSFT_SYSTEMTIME_DAY, wom < 0 ? 5 : wom);
199
200        // Turn hours/minutes into ms from midnight (per TimeZone)
201        setWord(bytes, offset + MSFT_SYSTEMTIME_HOUR, cal.get(Calendar.HOUR));
202        setWord(bytes, offset + MSFT_SYSTEMTIME_MINUTE, cal.get(Calendar.MINUTE));
203     }
204
205    // Build a TimeZoneDate structure from a SYSTEMTIME within a byte array at a given offset
206    static TimeZoneDate getTimeZoneDateFromSystemTime(byte[] bytes, int offset) {
207        TimeZoneDate tzd = new TimeZoneDate();
208
209        // MSFT year is an int; TimeZone is a String
210        int num = getWord(bytes, offset + MSFT_SYSTEMTIME_YEAR);
211        tzd.year = Integer.toString(num);
212
213        // MSFT month = 0 means no daylight time
214        // MSFT months are 1 based; TimeZone is 0 based
215        num = getWord(bytes, offset + MSFT_SYSTEMTIME_MONTH);
216        if (num == 0) {
217            return null;
218        } else {
219            tzd.month = num -1;
220        }
221
222        // MSFT day of week starts w/ Sunday = 0; TimeZone starts w/ Sunday = 1
223        tzd.dayOfWeek = getWord(bytes, offset + MSFT_SYSTEMTIME_DAY_OF_WEEK) + 1;
224
225        // Get the "day" in TimeZone format
226        num = getWord(bytes, offset + MSFT_SYSTEMTIME_DAY);
227        // 5 means "last" in MSFT land; for TimeZone, it's -1
228        if (num == 5) {
229            tzd.day = -1;
230        } else {
231            tzd.day = num;
232        }
233
234        // Turn hours/minutes into ms from midnight (per TimeZone)
235        int hour = getWord(bytes, offset + MSFT_SYSTEMTIME_HOUR);
236        tzd.hour = hour;
237        int minute = getWord(bytes, offset + MSFT_SYSTEMTIME_MINUTE);
238        tzd.minute = minute;
239        tzd.time = (hour*HOURS) + (minute*MINUTES);
240
241        return tzd;
242    }
243
244    /**
245     * Build a GregorianCalendar, based on a time zone and TimeZoneDate.
246     * @param timeZone the time zone we're checking
247     * @param tzd the TimeZoneDate we're interested in
248     * @return a GregorianCalendar with the given time zone and date
249     */
250    static GregorianCalendar getCheckCalendar(TimeZone timeZone, TimeZoneDate tzd) {
251        GregorianCalendar testCalendar = new GregorianCalendar(timeZone);
252        testCalendar.set(GregorianCalendar.YEAR, sCurrentYear);
253        testCalendar.set(GregorianCalendar.MONTH, tzd.month);
254        testCalendar.set(GregorianCalendar.DAY_OF_WEEK, tzd.dayOfWeek);
255        testCalendar.set(GregorianCalendar.DAY_OF_WEEK_IN_MONTH, tzd.day);
256        testCalendar.set(GregorianCalendar.HOUR_OF_DAY, tzd.hour);
257        testCalendar.set(GregorianCalendar.MINUTE, tzd.minute);
258        return testCalendar;
259    }
260
261    /**
262     * Return a GregorianCalendar representing the first standard/daylight transition between a
263     * start time and an end time in the given time zone
264     * @param tz a TimeZone the time zone in which we're looking for transitions
265     * @param startTime the start time for the test
266     * @param endTime the end time for the test
267     * @param startInDaylightTime whether daylight time is in effect at the startTime
268     * @return a GregorianCalendar representing the transition or null if none
269     */
270    static /*package*/ GregorianCalendar findTransitionDate(TimeZone tz, long startTime, long endTime,
271            boolean startInDaylightTime) {
272        long startingEndTime = endTime;
273        Date date = null;
274        // We'll keep splitting the difference until we're within a minute
275        while ((endTime - startTime) > MINUTES) {
276            long checkTime = ((startTime + endTime) / 2) + 1;
277            date = new Date(checkTime);
278            if (tz.inDaylightTime(date) != startInDaylightTime) {
279                endTime = checkTime;
280            } else {
281                startTime = checkTime;
282            }
283        }
284
285        // If these are the same, we're really messed up; return null
286        if (endTime == startingEndTime) {
287            return null;
288        }
289
290        // Set up our calendar
291        GregorianCalendar calendar = new GregorianCalendar(tz);
292        calendar.setTimeInMillis(startInDaylightTime ? startTime : endTime);
293        int min = calendar.get(Calendar.MINUTE);
294        if (min == 59) {
295            // If we're at XX:59:ZZ, round up to the next minute
296            calendar.add(Calendar.SECOND, 60 - calendar.get(Calendar.SECOND));
297        } else if (min == 0) {
298            // If we're at XX:00:ZZ, round down to the minute
299           calendar.add(Calendar.SECOND, -calendar.get(Calendar.SECOND));
300        }
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 private 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 /*package*/ 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 public 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                millisToVCalendarTime(toDaylightCalendars[0].getTimeInMillis(), tz, true));
574        if (hasRule) {
575            writer.writeTag("RRULE", daylightRule.toString());
576        } else {
577            for (int i = 1; i < maxYears; i++) {
578                writer.writeTag("RDATE", millisToVCalendarTime(
579                        toDaylightCalendars[i].getTimeInMillis(), tz, true));
580            }
581        }
582        writer.writeTag("END", "DAYLIGHT");
583        // Write the STANDARD block
584        writer.writeTag("BEGIN", "STANDARD");
585        writer.writeTag("TZOFFSETFROM", daylightOffsetString);
586        writer.writeTag("TZOFFSETTO", standardOffsetString);
587        writer.writeTag("DTSTART",
588                millisToVCalendarTime(toStandardCalendars[0].getTimeInMillis(), tz, false));
589        if (hasRule) {
590            writer.writeTag("RRULE", standardRule.toString());
591        } else {
592            for (int i = 1; i < maxYears; i++) {
593                writer.writeTag("RDATE", millisToVCalendarTime(
594                        toStandardCalendars[i].getTimeInMillis(), tz, true));
595            }
596        }
597        writer.writeTag("END", "STANDARD");
598        // And we're done
599        writer.writeTag("END", "VTIMEZONE");
600    }
601
602    /**
603     * Find the next transition to occur (i.e. after the current date/time)
604     * @param transitions calendars representing transitions to/from DST
605     * @return millis for the first transition after the current date/time
606     */
607    static private long findNextTransition(long startingMillis, GregorianCalendar[] transitions) {
608        for (GregorianCalendar transition: transitions) {
609            long transitionMillis = transition.getTimeInMillis();
610            if (transitionMillis > startingMillis) {
611                return transitionMillis;
612            }
613        }
614        return 0;
615    }
616
617    /**
618     * Calculate the Base64 representation of a MSFT TIME_ZONE_INFORMATION structure from a TimeZone
619     * that might be found in an Event.  Since the internal representation of the TimeZone is hidden
620     * from us we'll find the DST transitions and build the structure from that information
621     * @param tz the TimeZone
622     * @return the Base64 String representing a Microsoft TIME_ZONE_INFORMATION element
623     */
624    static public String timeZoneToTziStringImpl(TimeZone tz) {
625        String tziString;
626        byte[] tziBytes = new byte[MSFT_TIME_ZONE_SIZE];
627        int standardBias = - tz.getRawOffset();
628        standardBias /= 60*SECONDS;
629        setLong(tziBytes, MSFT_TIME_ZONE_BIAS_OFFSET, standardBias);
630        // If this time zone has daylight savings time, we need to do more work
631        if (tz.useDaylightTime()) {
632            GregorianCalendar[] toDaylightCalendars = new GregorianCalendar[3];
633            GregorianCalendar[] toStandardCalendars = new GregorianCalendar[3];
634            // See if we can get transitions for a few years; if not, we can't generate DST info
635            // for this time zone
636            if (getDSTCalendars(tz, toDaylightCalendars, toStandardCalendars)) {
637                // Try to find a rule to cover these years
638                RRule daylightRule = inferRRuleFromCalendars(toDaylightCalendars);
639                RRule standardRule = inferRRuleFromCalendars(toStandardCalendars);
640                if ((daylightRule != null) && (daylightRule.type == RRule.RRULE_DAY_WEEK) &&
641                        (standardRule != null) && (standardRule.type == RRule.RRULE_DAY_WEEK)) {
642                    // We need both rules and they have to be DAY/WEEK type
643                    // Write month, day of week, week, hour, minute
644                    putRuleIntoTimeZoneInformation(tziBytes, MSFT_TIME_ZONE_STANDARD_DATE_OFFSET,
645                            standardRule,
646                            toStandardCalendars[0].get(Calendar.HOUR),
647                            toStandardCalendars[0].get(Calendar.MINUTE));
648                    putRuleIntoTimeZoneInformation(tziBytes, MSFT_TIME_ZONE_DAYLIGHT_DATE_OFFSET,
649                            daylightRule,
650                            toDaylightCalendars[0].get(Calendar.HOUR),
651                            toDaylightCalendars[0].get(Calendar.MINUTE));
652                } else {
653                    // If there's no rule, we'll use the first transition to standard/to daylight
654                    // And indicate that it's just for this year...
655                    long now = System.currentTimeMillis();
656                    long standardTransition = findNextTransition(now, toStandardCalendars);
657                    long daylightTransition = findNextTransition(now, toDaylightCalendars);
658                    // If we can't find transitions, we can't do DST
659                    if (standardTransition != 0 && daylightTransition != 0) {
660                        putTimeInMillisIntoSystemTime(tziBytes, MSFT_TIME_ZONE_STANDARD_DATE_OFFSET,
661                                standardTransition);
662                        putTimeInMillisIntoSystemTime(tziBytes, MSFT_TIME_ZONE_DAYLIGHT_DATE_OFFSET,
663                                daylightTransition);
664                    }
665                }
666            }
667            int dstOffset = tz.getDSTSavings();
668            setLong(tziBytes, MSFT_TIME_ZONE_DAYLIGHT_BIAS_OFFSET, - dstOffset / MINUTES);
669        }
670        byte[] tziEncodedBytes = Base64.encode(tziBytes, Base64.NO_WRAP);
671        tziString = new String(tziEncodedBytes);
672        return tziString;
673    }
674
675    /**
676     * Given a String as directly read from EAS, returns a TimeZone corresponding to that String
677     * @param timeZoneString the String read from the server
678     * @return the TimeZone, or TimeZone.getDefault() if not found
679     */
680    static public TimeZone tziStringToTimeZone(String timeZoneString) {
681        // If we have this time zone cached, use that value and return
682        TimeZone timeZone = sTimeZoneCache.get(timeZoneString);
683        if (timeZone != null) {
684            if (Eas.USER_LOG) {
685                Log.d(TAG, " Using cached TimeZone " + timeZone.getDisplayName());
686            }
687        } else {
688            timeZone = tziStringToTimeZoneImpl(timeZoneString);
689            if (timeZone == null) {
690                // If we don't find a match, we just return the current TimeZone.  In theory, this
691                // shouldn't be happening...
692                Log.w(TAG, "TimeZone not found using default: " + timeZoneString);
693                timeZone = TimeZone.getDefault();
694            }
695            sTimeZoneCache.put(timeZoneString, timeZone);
696        }
697        return timeZone;
698    }
699
700    /**
701     * Given a String as directly read from EAS, tries to find a TimeZone in the database of all
702     * time zones that corresponds to that String.
703     * @param timeZoneString the String read from the server
704     * @return the TimeZone, or TimeZone.getDefault() if not found
705     */
706    static public TimeZone tziStringToTimeZoneImpl(String timeZoneString) {
707        TimeZone timeZone = null;
708        // First, we need to decode the base64 string
709        byte[] timeZoneBytes = Base64.decode(timeZoneString, Base64.DEFAULT);
710
711        // Then, we get the bias (similar to a rawOffset); for TimeZone, we need ms
712        // but EAS gives us minutes, so do the conversion.  Note that EAS is the bias that's added
713        // to the time zone to reach UTC; our library uses the time from UTC to our time zone, so
714        // we need to change the sign
715        int bias = -1 * getLong(timeZoneBytes, MSFT_TIME_ZONE_BIAS_OFFSET) * MINUTES;
716
717        // Get all of the time zones with the bias as a rawOffset; if there aren't any, we return
718        // the default time zone
719        String[] zoneIds = TimeZone.getAvailableIDs(bias);
720        if (zoneIds.length > 0) {
721            // Try to find an existing TimeZone from the data provided by EAS
722            // We start by pulling out the date that standard time begins
723            TimeZoneDate dstEnd =
724                getTimeZoneDateFromSystemTime(timeZoneBytes, MSFT_TIME_ZONE_STANDARD_DATE_OFFSET);
725            if (dstEnd == null) {
726                // In this case, there is no daylight savings time, so the only interesting data
727                // is the offset, and we know that all of the zoneId's match; we'll take the first
728                timeZone = TimeZone.getTimeZone(zoneIds[0]);
729                String dn = timeZone.getDisplayName();
730                sTimeZoneCache.put(timeZoneString, timeZone);
731                if (Eas.USER_LOG) {
732                    Log.d(TAG, "TimeZone without DST found by offset: " + dn);
733                }
734            } else {
735                TimeZoneDate dstStart = getTimeZoneDateFromSystemTime(timeZoneBytes,
736                        MSFT_TIME_ZONE_DAYLIGHT_DATE_OFFSET);
737                // See comment above for bias...
738                long dstSavings =
739                    -1 * getLong(timeZoneBytes, MSFT_TIME_ZONE_DAYLIGHT_BIAS_OFFSET) * MINUTES;
740
741                // We'll go through each time zone to find one with the same DST transitions and
742                // savings length
743                for (String zoneId: zoneIds) {
744                    // Get the TimeZone using the zoneId
745                    timeZone = TimeZone.getTimeZone(zoneId);
746
747                    // Our strategy here is to check just before and just after the transitions
748                    // and see whether the check for daylight time matches the expectation
749                    // If both transitions match, then we have a match for the offset and start/end
750                    // of dst.  That's the best we can do for now, since there's no other info
751                    // provided by EAS (i.e. we can't get dynamic transitions, etc.)
752
753                    int testSavingsMinutes = timeZone.getDSTSavings() / MINUTES;
754                    int errorBoundsMinutes = (testSavingsMinutes * 2) + 1;
755
756                    // Check start DST transition
757                    GregorianCalendar testCalendar = getCheckCalendar(timeZone, dstStart);
758                    testCalendar.add(GregorianCalendar.MINUTE, - errorBoundsMinutes);
759                    Date before = testCalendar.getTime();
760                    testCalendar.add(GregorianCalendar.MINUTE, 2*errorBoundsMinutes);
761                    Date after = testCalendar.getTime();
762                    if (timeZone.inDaylightTime(before)) continue;
763                    if (!timeZone.inDaylightTime(after)) continue;
764
765                    // Check end DST transition
766                    testCalendar = getCheckCalendar(timeZone, dstEnd);
767                    testCalendar.add(GregorianCalendar.MINUTE, - errorBoundsMinutes);
768                    before = testCalendar.getTime();
769                    testCalendar.add(GregorianCalendar.MINUTE, 2*errorBoundsMinutes);
770                    after = testCalendar.getTime();
771                    if (!timeZone.inDaylightTime(before)) continue;
772                    if (timeZone.inDaylightTime(after)) continue;
773
774                    // Check that the savings are the same
775                    if (dstSavings != timeZone.getDSTSavings()) continue;
776                    break;
777                }
778            }
779        }
780        return timeZone;
781    }
782
783    /**
784     * Generate a time in milliseconds from an email date string that represents a date/time in GMT
785     * @param Email style DateTime string from Exchange server
786     * @return the time in milliseconds (since Jan 1, 1970)
787     */
788    static public long parseEmailDateTimeToMillis(String date) {
789        // Format for email date strings is 2010-02-23T16:00:00.000Z
790        GregorianCalendar cal = new GregorianCalendar(Integer.parseInt(date.substring(0, 4)),
791                Integer.parseInt(date.substring(5, 7)) - 1, Integer.parseInt(date.substring(8, 10)),
792                Integer.parseInt(date.substring(11, 13)), Integer.parseInt(date.substring(14, 16)),
793                Integer.parseInt(date.substring(17, 19)));
794        cal.setTimeZone(TimeZone.getTimeZone("GMT"));
795        return cal.getTimeInMillis();
796    }
797
798    /**
799     * Generate a time in milliseconds from a date string that represents a date/time in GMT
800     * @param DateTime string from Exchange server
801     * @return the time in milliseconds (since Jan 1, 1970)
802     */
803    static public long parseDateTimeToMillis(String date) {
804        // Format for calendar date strings is 20090211T180303Z
805        GregorianCalendar cal = new GregorianCalendar(Integer.parseInt(date.substring(0, 4)),
806                Integer.parseInt(date.substring(4, 6)) - 1, Integer.parseInt(date.substring(6, 8)),
807                Integer.parseInt(date.substring(9, 11)), Integer.parseInt(date.substring(11, 13)),
808                Integer.parseInt(date.substring(13, 15)));
809        cal.setTimeZone(TimeZone.getTimeZone("GMT"));
810        return cal.getTimeInMillis();
811    }
812
813    /**
814     * Generate a GregorianCalendar from a date string that represents a date/time in GMT
815     * @param DateTime string from Exchange server
816     * @return the GregorianCalendar
817     */
818    static public GregorianCalendar parseDateTimeToCalendar(String date) {
819        // Format for calendar date strings is 20090211T180303Z
820        GregorianCalendar cal = new GregorianCalendar(Integer.parseInt(date.substring(0, 4)),
821                Integer.parseInt(date.substring(4, 6)) - 1, Integer.parseInt(date.substring(6, 8)),
822                Integer.parseInt(date.substring(9, 11)), Integer.parseInt(date.substring(11, 13)),
823                Integer.parseInt(date.substring(13, 15)));
824        cal.setTimeZone(TimeZone.getTimeZone("GMT"));
825        return cal;
826    }
827
828    static public String convertEmailDateTimeToCalendarDateTime(String date) {
829        // Format for email date strings is 2010-02-23T16:00:00.000Z
830        // Format for calendar date strings is 2010-02-23T160000Z
831       return date.substring(0, 4) + date.substring(5, 7) + date.substring(8, 13) +
832           date.substring(14, 16) + date.substring(17, 19) + 'Z';
833    }
834
835    static String formatTwo(int num) {
836        if (num <= 12) {
837            return sTwoCharacterNumbers[num];
838        } else
839            return Integer.toString(num);
840    }
841
842    /**
843     * Generate an EAS formatted date/time string based on GMT. See below for details.
844     */
845    static public String millisToEasDateTime(long millis) {
846        return millisToEasDateTime(millis, sGmtTimeZone);
847    }
848
849    /**
850     * Generate an EAS formatted local date/time string from a time and a time zone
851     * @param millis a time in milliseconds
852     * @param tz a time zone
853     * @return an EAS formatted string indicating the date/time in the given time zone
854     */
855    static public String millisToEasDateTime(long millis, TimeZone tz) {
856        StringBuilder sb = new StringBuilder();
857        GregorianCalendar cal = new GregorianCalendar(tz);
858        cal.setTimeInMillis(millis);
859        sb.append(cal.get(Calendar.YEAR));
860        sb.append(formatTwo(cal.get(Calendar.MONTH) + 1));
861        sb.append(formatTwo(cal.get(Calendar.DAY_OF_MONTH)));
862        sb.append('T');
863        sb.append(formatTwo(cal.get(Calendar.HOUR_OF_DAY)));
864        sb.append(formatTwo(cal.get(Calendar.MINUTE)));
865        sb.append(formatTwo(cal.get(Calendar.SECOND)));
866        if (tz == sGmtTimeZone) {
867            sb.append('Z');
868        }
869        return sb.toString();
870    }
871
872    /**
873     * Generate a date/time string suitable for VTIMEZONE, the format is YYYYMMDDTHHMMSS
874     * @param millis a time in milliseconds
875     * @param tz a time zone
876     * @param dst whether we're entering daylight time
877     */
878    static public String millisToVCalendarTime(long millis, TimeZone tz, boolean dst) {
879        StringBuilder sb = new StringBuilder();
880        GregorianCalendar cal = new GregorianCalendar(tz);
881        cal.setTimeInMillis(millis);
882        sb.append(cal.get(Calendar.YEAR));
883        sb.append(formatTwo(cal.get(Calendar.MONTH) + 1));
884        sb.append(formatTwo(cal.get(Calendar.DAY_OF_MONTH)));
885        sb.append('T');
886        // If we're entering daylight time, go back an hour to compensate for the singularity
887        if (dst && (tz.getDSTSavings() == HOURS)) {
888            int hour = cal.get(Calendar.HOUR_OF_DAY);
889            if (hour > 0) {
890                hour--;
891            }
892            sb.append(formatTwo(hour));
893        } else {
894            sb.append(formatTwo(cal.get(Calendar.HOUR_OF_DAY)));
895        }
896        sb.append(formatTwo(cal.get(Calendar.MINUTE)));
897        sb.append(formatTwo(cal.get(Calendar.SECOND)));
898        return sb.toString();
899    }
900
901    static void addByDay(StringBuilder rrule, int dow, int wom) {
902        rrule.append(";BYDAY=");
903        boolean addComma = false;
904        for (int i = 0; i < 7; i++) {
905            if ((dow & 1) == 1) {
906                if (addComma) {
907                    rrule.append(',');
908                }
909                if (wom > 0) {
910                    // 5 = last week -> -1
911                    // So -1SU = last sunday
912                    rrule.append(wom == 5 ? -1 : wom);
913                }
914                rrule.append(sDayTokens[i]);
915                addComma = true;
916            }
917            dow >>= 1;
918        }
919    }
920
921    static void addByMonthDay(StringBuilder rrule, int dom) {
922        // 127 means last day of the month
923        if (dom == 127) {
924            dom = -1;
925        }
926        rrule.append(";BYMONTHDAY=" + dom);
927    }
928
929    /**
930     * Generate the String version of the EAS integer for a given BYDAY value in an rrule
931     * @param dow the BYDAY value of the rrule
932     * @return the String version of the EAS value of these days
933     */
934    static String generateEasDayOfWeek(String dow) {
935        int bits = 0;
936        int bit = 1;
937        for (String token: sDayTokens) {
938            // If we can find the day in the dow String, add the bit to our bits value
939            if (dow.indexOf(token) >= 0) {
940                bits |= bit;
941            }
942            bit <<= 1;
943        }
944        return Integer.toString(bits);
945    }
946
947    /**
948     * Extract the value of a token in an RRULE string
949     * @param rrule an RRULE string
950     * @param token a token to look for in the RRULE
951     * @return the value of that token
952     */
953    static String tokenFromRrule(String rrule, String token) {
954        int start = rrule.indexOf(token);
955        if (start < 0) return null;
956        int len = rrule.length();
957        start += token.length();
958        int end = start;
959        char c;
960        do {
961            c = rrule.charAt(end++);
962            if (!Character.isLetterOrDigit(c) || (end == len)) {
963                if (end == len) end++;
964                return rrule.substring(start, end -1);
965            }
966        } while (true);
967    }
968
969    /**
970     * Reformat an RRULE style UNTIL to an EAS style until
971     */
972    static String recurrenceUntilToEasUntil(String until) {
973        StringBuilder sb = new StringBuilder();
974        sb.append(until.substring(0, 4));
975        sb.append('-');
976        sb.append(until.substring(4, 6));
977        sb.append('-');
978        if (until.length() == 8) {
979            sb.append(until.substring(6, 8));
980            sb.append("T00:00:00");
981
982        } else {
983            sb.append(until.substring(6, 11));
984            sb.append(':');
985            sb.append(until.substring(11, 13));
986            sb.append(':');
987            sb.append(until.substring(13, 15));
988        }
989        sb.append(".000Z");
990        return sb.toString();
991    }
992
993    /**
994     * Convenience method to add "until" to an EAS calendar stream
995     */
996    static void addUntil(String rrule, Serializer s) throws IOException {
997        String until = tokenFromRrule(rrule, "UNTIL=");
998        if (until != null) {
999            s.data(Tags.CALENDAR_RECURRENCE_UNTIL, recurrenceUntilToEasUntil(until));
1000        }
1001    }
1002
1003    /**
1004     * Write recurrence information to EAS based on the RRULE in CalendarProvider
1005     * @param rrule the RRULE, from CalendarProvider
1006     * @param startTime, the DTSTART of this Event
1007     * @param s the Serializer we're using to write WBXML data
1008     * @throws IOException
1009     */
1010    // NOTE: For the moment, we're only parsing recurrence types that are supported by the
1011    // Calendar app UI, which is a subset of possible recurrence types
1012    // This code must be updated when the Calendar adds new functionality
1013    static public void recurrenceFromRrule(String rrule, long startTime, Serializer s)
1014    throws IOException {
1015        Log.d("RRULE", "rule: " + rrule);
1016        String freq = tokenFromRrule(rrule, "FREQ=");
1017        // If there's no FREQ=X, then we don't write a recurrence
1018        // Note that we duplicate s.start(Tags.CALENDAR_RECURRENCE); s.end(); to prevent the
1019        // possibility of writing out a partial recurrence stanza
1020        if (freq != null) {
1021            if (freq.equals("DAILY")) {
1022                s.start(Tags.CALENDAR_RECURRENCE);
1023                s.data(Tags.CALENDAR_RECURRENCE_TYPE, "0");
1024                s.data(Tags.CALENDAR_RECURRENCE_INTERVAL, "1");
1025                s.end();
1026            } else if (freq.equals("WEEKLY")) {
1027                s.start(Tags.CALENDAR_RECURRENCE);
1028                s.data(Tags.CALENDAR_RECURRENCE_TYPE, "1");
1029                s.data(Tags.CALENDAR_RECURRENCE_INTERVAL, "1");
1030                // Requires a day of week (whereas RRULE does not)
1031                String byDay = tokenFromRrule(rrule, "BYDAY=");
1032                if (byDay != null) {
1033                    s.data(Tags.CALENDAR_RECURRENCE_DAYOFWEEK, generateEasDayOfWeek(byDay));
1034                }
1035                addUntil(rrule, s);
1036                s.end();
1037            } else if (freq.equals("MONTHLY")) {
1038                String byMonthDay = tokenFromRrule(rrule, "BYMONTHDAY=");
1039                if (byMonthDay != null) {
1040                    // The nth day of the month
1041                    s.start(Tags.CALENDAR_RECURRENCE);
1042                    s.data(Tags.CALENDAR_RECURRENCE_TYPE, "2");
1043                    s.data(Tags.CALENDAR_RECURRENCE_DAYOFMONTH, byMonthDay);
1044                    addUntil(rrule, s);
1045                    s.end();
1046                } else {
1047                    String byDay = tokenFromRrule(rrule, "BYDAY=");
1048                    String bareByDay;
1049                    if (byDay != null) {
1050                        // This can be 1WE (1st Wednesday) or -1FR (last Friday)
1051                        int wom = byDay.charAt(0);
1052                        if (wom == '-') {
1053                            // -1 is the only legal case (last week) Use "5" for EAS
1054                            wom = 5;
1055                            bareByDay = byDay.substring(2);
1056                        } else {
1057                            wom = wom - '0';
1058                            bareByDay = byDay.substring(1);
1059                        }
1060                        s.start(Tags.CALENDAR_RECURRENCE);
1061                        s.data(Tags.CALENDAR_RECURRENCE_TYPE, "3");
1062                        s.data(Tags.CALENDAR_RECURRENCE_WEEKOFMONTH, Integer.toString(wom));
1063                        s.data(Tags.CALENDAR_RECURRENCE_DAYOFWEEK, generateEasDayOfWeek(bareByDay));
1064                        addUntil(rrule, s);
1065                        s.end();
1066                    }
1067                }
1068            } else if (freq.equals("YEARLY")) {
1069                String byMonth = tokenFromRrule(rrule, "BYMONTH=");
1070                String byMonthDay = tokenFromRrule(rrule, "BYMONTHDAY=");
1071                if (byMonth == null || byMonthDay == null) {
1072                    // Calculate the month and day from the startDate
1073                    GregorianCalendar cal = new GregorianCalendar();
1074                    cal.setTimeInMillis(startTime);
1075                    cal.setTimeZone(TimeZone.getDefault());
1076                    byMonth = Integer.toString(cal.get(Calendar.MONTH) + 1);
1077                    byMonthDay = Integer.toString(cal.get(Calendar.DAY_OF_MONTH));
1078                }
1079                s.start(Tags.CALENDAR_RECURRENCE);
1080                s.data(Tags.CALENDAR_RECURRENCE_TYPE, "5");
1081                s.data(Tags.CALENDAR_RECURRENCE_DAYOFMONTH, byMonthDay);
1082                s.data(Tags.CALENDAR_RECURRENCE_MONTHOFYEAR, byMonth);
1083                addUntil(rrule, s);
1084                s.end();
1085            }
1086        }
1087    }
1088
1089    /**
1090     * Build an RRULE String from EAS recurrence information
1091     * @param type the type of recurrence
1092     * @param occurrences how many recurrences (instances)
1093     * @param interval the interval between recurrences
1094     * @param dow day of the week
1095     * @param dom day of the month
1096     * @param wom week of the month
1097     * @param moy month of the year
1098     * @param until the last recurrence time
1099     * @return a valid RRULE String
1100     */
1101    static public String rruleFromRecurrence(int type, int occurrences, int interval, int dow,
1102            int dom, int wom, int moy, String until) {
1103        StringBuilder rrule = new StringBuilder("FREQ=" + sTypeToFreq[type]);
1104
1105        // INTERVAL and COUNT
1106        if (interval > 0) {
1107            rrule.append(";INTERVAL=" + interval);
1108        }
1109        if (occurrences > 0) {
1110            rrule.append(";COUNT=" + occurrences);
1111        }
1112
1113        // Days, weeks, months, etc.
1114        switch(type) {
1115            case 0: // DAILY
1116            case 1: // WEEKLY
1117                if (dow > 0) addByDay(rrule, dow, -1);
1118                break;
1119            case 2: // MONTHLY
1120                if (dom > 0) addByMonthDay(rrule, dom);
1121                break;
1122            case 3: // MONTHLY (on the nth day)
1123                if (dow > 0) addByDay(rrule, dow, wom);
1124                break;
1125            case 5: // YEARLY (specific day)
1126                if (dom > 0) addByMonthDay(rrule, dom);
1127                if (moy > 0) {
1128                    rrule.append(";BYMONTH=" + moy);
1129                }
1130                break;
1131            case 6: // YEARLY
1132                if (dow > 0) addByDay(rrule, dow, wom);
1133                if (dom > 0) addByMonthDay(rrule, dom);
1134                if (moy > 0) {
1135                    rrule.append(";BYMONTH=" + moy);
1136                }
1137                break;
1138            default:
1139                break;
1140        }
1141
1142        // UNTIL comes last
1143        if (until != null) {
1144            rrule.append(";UNTIL=" + until);
1145        }
1146
1147        return rrule.toString();
1148    }
1149
1150    /**
1151     * Create a Calendar in CalendarProvider to which synced Events will be linked
1152     * @param service the sync service requesting Calendar creation
1153     * @param account the account being synced
1154     * @param mailbox the Exchange mailbox for the calendar
1155     * @return the unique id of the Calendar
1156     */
1157    static public long createCalendar(EasSyncService service, Account account, Mailbox mailbox) {
1158        // Create a Calendar object
1159        ContentValues cv = new ContentValues();
1160        // TODO How will this change if the user changes his account display name?
1161        cv.put(Calendars.DISPLAY_NAME, account.mDisplayName);
1162        cv.put(Calendars._SYNC_ACCOUNT, account.mEmailAddress);
1163        cv.put(Calendars._SYNC_ACCOUNT_TYPE, Email.EXCHANGE_ACCOUNT_MANAGER_TYPE);
1164        cv.put(Calendars.SYNC_EVENTS, 1);
1165        cv.put(Calendars.SELECTED, 1);
1166        cv.put(Calendars.HIDDEN, 0);
1167        // TODO Coordinate account colors w/ Calendar, if possible
1168        // Make Email account color opaque
1169        cv.put(Calendars.COLOR, 0xFF000000 | Email.getAccountColor(account.mId));
1170        cv.put(Calendars.TIMEZONE, Time.getCurrentTimezone());
1171        cv.put(Calendars.ACCESS_LEVEL, Calendars.OWNER_ACCESS);
1172        cv.put(Calendars.OWNER_ACCOUNT, account.mEmailAddress);
1173
1174        Uri uri = service.mContentResolver.insert(Calendars.CONTENT_URI, cv);
1175        // We save the id of the calendar into mSyncStatus
1176        if (uri != null) {
1177            String stringId = uri.getPathSegments().get(1);
1178            mailbox.mSyncStatus = stringId;
1179            return Long.parseLong(stringId);
1180        }
1181        return -1;
1182    }
1183
1184    /**
1185     * Create a Message for an (Event) Entity
1186     * @param entity the Entity for the Event (as might be retrieved by CalendarProvider)
1187     * @param messageFlag the Message.FLAG_XXX constant indicating the type of email to be sent
1188     * @param the unique id of this Event, or null if it can be retrieved from the Event
1189     * @param the user's account
1190     * @return a Message with many fields pre-filled (more later)
1191     */
1192    static public EmailContent.Message createMessageForEntity(Context context, Entity entity,
1193            int messageFlag, String uid, Account account) {
1194        // TODO Handle exceptions; will be a nightmare
1195        // TODO Cries out for unit test
1196        ContentValues entityValues = entity.getEntityValues();
1197        ArrayList<NamedContentValues> subValues = entity.getSubValues();
1198
1199        EmailContent.Message msg = new EmailContent.Message();
1200        msg.mFlags = messageFlag;
1201        msg.mTimeStamp = System.currentTimeMillis();
1202
1203        String method;
1204        if ((messageFlag & EmailContent.Message.FLAG_OUTGOING_MEETING_REQUEST_MASK) != 0) {
1205            method = "REQUEST";
1206        } else {
1207            method = "REPLY";
1208        }
1209
1210        try {
1211            // Create our iCalendar writer and start generating tags
1212            SimpleIcsWriter ics = new SimpleIcsWriter();
1213            ics.writeTag("BEGIN", "VCALENDAR");
1214            ics.writeTag("METHOD", method);
1215            ics.writeTag("PRODID", "AndroidEmail");
1216            ics.writeTag("VERSION", "2.0");
1217
1218            // Our default vcalendar time zone is UTC, but this will change (below) if we're
1219            // sending a recurring event, in which case we use local time
1220            TimeZone vCalendarTimeZone = sGmtTimeZone;
1221            String vCalendarTimeZoneSuffix = "";
1222
1223            // If we're inviting people and the meeting is recurring, we need to send our time zone
1224            // information and make sure to send DTSTART/DTEND in local time
1225            if (method.equals("REQUEST")  && entityValues.containsKey(Events.RRULE)) {
1226                vCalendarTimeZone = TimeZone.getDefault();
1227                // Write the VTIMEZONE block to the writer
1228                timeZoneToVTimezone(vCalendarTimeZone, ics);
1229                vCalendarTimeZoneSuffix = ";TZID=" + vCalendarTimeZone.getID();
1230            }
1231
1232            ics.writeTag("BEGIN", "VEVENT");
1233            if (uid == null) {
1234                uid = entityValues.getAsString(Events._SYNC_DATA);
1235            }
1236            if (uid != null) {
1237                ics.writeTag("UID", uid);
1238            }
1239
1240            if (entityValues.containsKey("DTSTAMP")) {
1241                ics.writeTag("DTSTAMP", entityValues.getAsString("DTSTAMP"));
1242            } else {
1243                ics.writeTag("DTSTAMP",
1244                        CalendarUtilities.millisToEasDateTime(System.currentTimeMillis()));
1245            }
1246
1247            long startTime = entityValues.getAsLong(Events.DTSTART);
1248            if (startTime != 0) {
1249                ics.writeTag("DTSTART" + vCalendarTimeZoneSuffix,
1250                        CalendarUtilities.millisToEasDateTime(startTime, vCalendarTimeZone));
1251            }
1252
1253            if (!entityValues.containsKey(Events.DURATION)) {
1254                if (entityValues.containsKey(Events.DTEND)) {
1255                    ics.writeTag("DTEND" + vCalendarTimeZoneSuffix,
1256                            CalendarUtilities.millisToEasDateTime(
1257                                    entityValues.getAsLong(Events.DTEND), vCalendarTimeZone));
1258                }
1259            } else {
1260                // Convert this into millis and add it to DTSTART for DTEND
1261                // We'll use 1 hour as a default
1262                long durationMillis = HOURS;
1263                Duration duration = new Duration();
1264                try {
1265                    duration.parse(entityValues.getAsString(Events.DURATION));
1266                } catch (ParseException e) {
1267                    // We'll use the default in this case
1268                }
1269                ics.writeTag("DTEND" + vCalendarTimeZoneSuffix,
1270                        CalendarUtilities.millisToEasDateTime(
1271                                startTime + durationMillis, vCalendarTimeZone));
1272            }
1273
1274            if (entityValues.containsKey(Events.EVENT_LOCATION)) {
1275                ics.writeTag("LOCATION", entityValues.getAsString(Events.EVENT_LOCATION));
1276            }
1277
1278            int titleId = 0;
1279            switch (messageFlag) {
1280                case Message.FLAG_OUTGOING_MEETING_INVITE:
1281                    titleId = R.string.meeting_invitation;
1282                    break;
1283                case Message.FLAG_OUTGOING_MEETING_ACCEPT:
1284                    titleId = R.string.meeting_accepted;
1285                    break;
1286                case Message.FLAG_OUTGOING_MEETING_DECLINE:
1287                    titleId = R.string.meeting_declined;
1288                    break;
1289                case Message.FLAG_OUTGOING_MEETING_TENTATIVE:
1290                    titleId = R.string.meeting_tentative;
1291                    break;
1292                case Message.FLAG_OUTGOING_MEETING_CANCEL:
1293                    titleId = R.string.meeting_canceled;
1294                    break;
1295            }
1296            String title = entityValues.getAsString(Events.TITLE);
1297            if (title == null) {
1298                title = "";
1299            }
1300            ics.writeTag("SUMMARY", title);
1301            if (titleId != 0) {
1302                msg.mSubject = context.getResources().getString(titleId, title);
1303            }
1304            if (method.equals("REQUEST")) {
1305                if (entityValues.containsKey(Events.ALL_DAY)) {
1306                    Integer ade = entityValues.getAsInteger(Events.ALL_DAY);
1307                    ics.writeTag("X-MICROSOFT-CDO-ALLDAYEVENT", ade == 0 ? "FALSE" : "TRUE");
1308                }
1309
1310                String desc = entityValues.getAsString(Events.DESCRIPTION);
1311                if (desc != null) {
1312                    // TODO Do we add some description of the event here?
1313                    ics.writeTag("DESCRIPTION", desc);
1314                    msg.mText = desc;
1315                } else {
1316                    msg.mText = "";
1317                }
1318
1319                String rrule = entityValues.getAsString(Events.RRULE);
1320                if (rrule != null) {
1321                    ics.writeTag("RRULE", rrule);
1322                }
1323
1324                // Handle associated data EXCEPT for attendees, which have to be grouped
1325                for (NamedContentValues ncv: subValues) {
1326                    Uri ncvUri = ncv.uri;
1327                    if (ncvUri.equals(Reminders.CONTENT_URI)) {
1328                        // TODO Consider sending alarm information in the meeting request, though
1329                        // it's not obviously appropriate (i.e. telling the user what alarm to use)
1330                        // This should be for REQUEST only
1331                        // Here's what the VALARM would look like:
1332                        //                  BEGIN:VALARM
1333                        //                  ACTION:DISPLAY
1334                        //                  DESCRIPTION:REMINDER
1335                        //                  TRIGGER;RELATED=START:-PT15M
1336                        //                  END:VALARM
1337                    }
1338                }
1339            }
1340
1341            // Handle attendee data here; keep track of organizer and stream it afterward
1342            String organizerName = null;
1343            String organizerEmail = null;
1344            ArrayList<Address> toList = new ArrayList<Address>();
1345            for (NamedContentValues ncv: subValues) {
1346                Uri ncvUri = ncv.uri;
1347                ContentValues ncvValues = ncv.values;
1348                if (ncvUri.equals(Attendees.CONTENT_URI)) {
1349                    Integer relationship =
1350                        ncvValues.getAsInteger(Attendees.ATTENDEE_RELATIONSHIP);
1351                    // If there's no relationship, we can't create this for EAS
1352                    // Similarly, we need an attendee email for each invitee
1353                    if (relationship != null &&
1354                            ncvValues.containsKey(Attendees.ATTENDEE_EMAIL)) {
1355                        // Organizer isn't among attendees in EAS
1356                        if (relationship == Attendees.RELATIONSHIP_ORGANIZER) {
1357                            organizerName = ncvValues.getAsString(Attendees.ATTENDEE_NAME);
1358                            organizerEmail = ncvValues.getAsString(Attendees.ATTENDEE_EMAIL);
1359                            continue;
1360                        }
1361                        String attendeeEmail = ncvValues.getAsString(Attendees.ATTENDEE_EMAIL);
1362                        String attendeeName = ncvValues.getAsString(Attendees.ATTENDEE_NAME);
1363                        // This shouldn't be possible, but allow for it
1364                        if (attendeeEmail == null) continue;
1365
1366                        if ((messageFlag & Message.FLAG_OUTGOING_MEETING_REQUEST_MASK) != 0) {
1367                            String icalTag = ICALENDAR_ATTENDEE_INVITE;
1368                            if (attendeeName != null) {
1369                                icalTag += ";CN=" + attendeeName;
1370                            }
1371                            ics.writeTag(icalTag, "MAILTO:" + attendeeEmail);
1372                            toList.add(attendeeName == null ? new Address(attendeeEmail) :
1373                                new Address(attendeeEmail, attendeeName));
1374                        } else if (attendeeEmail.equalsIgnoreCase(account.mEmailAddress)) {
1375                            String icalTag = null;
1376                            switch (messageFlag) {
1377                                case Message.FLAG_OUTGOING_MEETING_ACCEPT:
1378                                    icalTag = ICALENDAR_ATTENDEE_ACCEPT;
1379                                    break;
1380                                case Message.FLAG_OUTGOING_MEETING_DECLINE:
1381                                    icalTag = ICALENDAR_ATTENDEE_DECLINE;
1382                                    break;
1383                                case Message.FLAG_OUTGOING_MEETING_TENTATIVE:
1384                                    icalTag = ICALENDAR_ATTENDEE_TENTATIVE;
1385                                    break;
1386                            }
1387                            if (icalTag != null) {
1388                                if (attendeeName != null) {
1389                                    icalTag += ";CN=" + attendeeName;
1390                                }
1391                                ics.writeTag(icalTag, "MAILTO:" + attendeeEmail);
1392                            }
1393                        }
1394                    }
1395                }
1396            }
1397
1398            // Create the organizer tag for ical
1399            if (organizerEmail != null) {
1400                String icalTag = "ORGANIZER";
1401                // We should be able to find this, assuming the Email is the user's email
1402                // TODO Find this in the account
1403                if (organizerName != null) {
1404                    icalTag += ";CN=" + organizerName;
1405                }
1406                ics.writeTag(icalTag, "MAILTO:" + organizerEmail);
1407                if (method.equals("REPLY")) {
1408                    toList.add(organizerName == null ? new Address(organizerEmail) :
1409                        new Address(organizerEmail, organizerName));
1410                }
1411            }
1412
1413            // If we have no "to" list, we're done
1414            if (toList.isEmpty()) return null;
1415            // Write out the "to" list
1416            Address[] toArray = new Address[toList.size()];
1417            int i = 0;
1418            for (Address address: toList) {
1419                toArray[i++] = address;
1420            }
1421            msg.mTo = Address.pack(toArray);
1422
1423            ics.writeTag("CLASS", "PUBLIC");
1424            ics.writeTag("STATUS", "CONFIRMED");
1425            ics.writeTag("TRANSP", "OPAQUE"); // What Exchange uses
1426            ics.writeTag("PRIORITY", "5");  // 1 to 9, 5 = medium
1427            ics.writeTag("SEQUENCE", entityValues.getAsString(Events._SYNC_VERSION));
1428            ics.writeTag("END", "VEVENT");
1429            ics.writeTag("END", "VCALENDAR");
1430            ics.flush();
1431            ics.close();
1432
1433            // Create the ics attachment using the "content" field
1434            Attachment att = new Attachment();
1435            att.mContent = ics.toString();
1436            att.mMimeType = "text/calendar; method=" + method;
1437            att.mFileName = "invite.ics";
1438            att.mSize = att.mContent.length();
1439            // We don't send content-disposition with this attachment
1440            att.mFlags = Attachment.FLAG_SUPPRESS_DISPOSITION;
1441
1442            // Add the attachment to the message
1443            msg.mAttachments = new ArrayList<Attachment>();
1444            msg.mAttachments.add(att);
1445        } catch (IOException e) {
1446            Log.w(TAG, "IOException in createMessageForEntity");
1447            return null;
1448        }
1449
1450        // Return the new Message to caller
1451        return msg;
1452    }
1453
1454    /**
1455     * Create a Message for an Event that can be retrieved from CalendarProvider by its unique id
1456     * @param cr a content resolver that can be used to query for the Event
1457     * @param eventId the unique id of the Event
1458     * @param messageFlag the Message.FLAG_XXX constant indicating the type of email to be sent
1459     * @param the unique id of this Event, or null if it can be retrieved from the Event
1460     * @param the user's account
1461     * @return a Message with many fields pre-filled (more later)
1462     * @throws RemoteException if there is an issue retrieving the Event from CalendarProvider
1463     */
1464    static public EmailContent.Message createMessageForEventId(Context context, long eventId,
1465            int messageFlag, String uid, Account account) throws RemoteException {
1466        ContentResolver cr = context.getContentResolver();
1467        EntityIterator eventIterator =
1468            EventsEntity.newEntityIterator(
1469                    cr.query(ContentUris.withAppendedId(Events.CONTENT_URI.buildUpon()
1470                            .appendQueryParameter(android.provider.Calendar.CALLER_IS_SYNCADAPTER,
1471                            "true").build(), eventId), null, null, null, null), cr);
1472        try {
1473            while (eventIterator.hasNext()) {
1474                Entity entity = eventIterator.next();
1475                return createMessageForEntity(context, entity, messageFlag, uid, account);
1476            }
1477        } finally {
1478            eventIterator.close();
1479        }
1480        return null;
1481    }
1482}