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