CalendarUtilities.java revision 6f3013b78708321879728c28db044ab233cb2016
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.exchange.Eas;
20import com.android.exchange.adapter.Serializer;
21import com.android.exchange.adapter.Tags;
22
23import org.bouncycastle.util.encoders.Base64;
24
25import android.util.Log;
26
27import java.io.IOException;
28import java.util.Calendar;
29import java.util.Date;
30import java.util.GregorianCalendar;
31import java.util.HashMap;
32import java.util.TimeZone;
33
34public class CalendarUtilities {
35    // NOTE: Most definitions in this class are have package visibility for testing purposes
36    private static final String TAG = "CalendarUtility";
37
38    // Time related convenience constants, in milliseconds
39    static final int SECONDS = 1000;
40    static final int MINUTES = SECONDS*60;
41    static final int HOURS = MINUTES*60;
42    static final long DAYS = HOURS*24;
43
44    // NOTE All Microsoft data structures are little endian
45
46    // The following constants relate to standard Microsoft data sizes
47    // For documentation, see http://msdn.microsoft.com/en-us/library/aa505945.aspx
48    static final int MSFT_LONG_SIZE = 4;
49    static final int MSFT_WCHAR_SIZE = 2;
50    static final int MSFT_WORD_SIZE = 2;
51
52    // The following constants relate to Microsoft's SYSTEMTIME structure
53    // For documentation, see: http://msdn.microsoft.com/en-us/library/ms724950(VS.85).aspx?ppud=4
54
55    static final int MSFT_SYSTEMTIME_YEAR = 0 * MSFT_WORD_SIZE;
56    static final int MSFT_SYSTEMTIME_MONTH = 1 * MSFT_WORD_SIZE;
57    static final int MSFT_SYSTEMTIME_DAY_OF_WEEK = 2 * MSFT_WORD_SIZE;
58    static final int MSFT_SYSTEMTIME_DAY = 3 * MSFT_WORD_SIZE;
59    static final int MSFT_SYSTEMTIME_HOUR = 4 * MSFT_WORD_SIZE;
60    static final int MSFT_SYSTEMTIME_MINUTE = 5 * MSFT_WORD_SIZE;
61    //static final int MSFT_SYSTEMTIME_SECONDS = 6 * MSFT_WORD_SIZE;
62    //static final int MSFT_SYSTEMTIME_MILLIS = 7 * MSFT_WORD_SIZE;
63    static final int MSFT_SYSTEMTIME_SIZE = 8*MSFT_WORD_SIZE;
64
65    // The following constants relate to Microsoft's TIME_ZONE_INFORMATION structure
66    // For documentation, see http://msdn.microsoft.com/en-us/library/ms725481(VS.85).aspx
67    static final int MSFT_TIME_ZONE_BIAS_OFFSET = 0;
68    static final int MSFT_TIME_ZONE_STANDARD_NAME_OFFSET =
69        MSFT_TIME_ZONE_BIAS_OFFSET + MSFT_LONG_SIZE;
70    static final int MSFT_TIME_ZONE_STANDARD_DATE_OFFSET =
71        MSFT_TIME_ZONE_STANDARD_NAME_OFFSET + (MSFT_WCHAR_SIZE*32);
72    static final int MSFT_TIME_ZONE_STANDARD_BIAS_OFFSET =
73        MSFT_TIME_ZONE_STANDARD_DATE_OFFSET + MSFT_SYSTEMTIME_SIZE;
74    static final int MSFT_TIME_ZONE_DAYLIGHT_NAME_OFFSET =
75        MSFT_TIME_ZONE_STANDARD_BIAS_OFFSET + MSFT_LONG_SIZE;
76    static final int MSFT_TIME_ZONE_DAYLIGHT_DATE_OFFSET =
77        MSFT_TIME_ZONE_DAYLIGHT_NAME_OFFSET + (MSFT_WCHAR_SIZE*32);
78    static final int MSFT_TIME_ZONE_DAYLIGHT_BIAS_OFFSET =
79        MSFT_TIME_ZONE_DAYLIGHT_DATE_OFFSET + MSFT_SYSTEMTIME_SIZE;
80    static final int MSFT_TIME_ZONE_SIZE =
81        MSFT_TIME_ZONE_DAYLIGHT_BIAS_OFFSET + MSFT_LONG_SIZE;
82
83    // TimeZone cache; we parse/decode as little as possible, because the process is quite slow
84    private static HashMap<String, TimeZone> sTimeZoneCache = new HashMap<String, TimeZone>();
85    // TZI string cache; we keep around our encoded TimeZoneInformation strings
86    private static HashMap<TimeZone, String> sTziStringCache = new HashMap<TimeZone, String>();
87
88    // There is no type 4 (thus, the "")
89    static final String[] sTypeToFreq =
90        new String[] {"DAILY", "WEEKLY", "MONTHLY", "MONTHLY", "", "YEARLY", "YEARLY"};
91
92    static final String[] sDayTokens =
93        new String[] {"SU", "MO", "TU", "WE", "TH", "FR", "SA"};
94
95    static final String[] sTwoCharacterNumbers =
96        new String[] {"00", "01", "02", "03", "04", "05", "06", "07", "08", "09", "10", "11", "12"};
97
98    static final int sCurrentYear = new GregorianCalendar().get(Calendar.YEAR);
99    static final TimeZone sGmtTimeZone = TimeZone.getTimeZone("GMT");
100
101    // Return a 4-byte long from a byte array (little endian)
102    static int getLong(byte[] bytes, int offset) {
103        return (bytes[offset++] & 0xFF) | ((bytes[offset++] & 0xFF) << 8) |
104        ((bytes[offset++] & 0xFF) << 16) | ((bytes[offset] & 0xFF) << 24);
105    }
106
107    // Put a 4-byte long into a byte array (little endian)
108    static void setLong(byte[] bytes, int offset, int value) {
109        bytes[offset++] = (byte) (value & 0xFF);
110        bytes[offset++] = (byte) ((value >> 8) & 0xFF);
111        bytes[offset++] = (byte) ((value >> 16) & 0xFF);
112        bytes[offset] = (byte) ((value >> 24) & 0xFF);
113    }
114
115    // Return a 2-byte word from a byte array (little endian)
116    static int getWord(byte[] bytes, int offset) {
117        return (bytes[offset++] & 0xFF) | ((bytes[offset] & 0xFF) << 8);
118    }
119
120    // Put a 2-byte word into a byte array (little endian)
121    static void setWord(byte[] bytes, int offset, int value) {
122        bytes[offset++] = (byte) (value & 0xFF);
123        bytes[offset] = (byte) ((value >> 8) & 0xFF);
124    }
125
126    // Internal structure for storing a time zone date from a SYSTEMTIME structure
127    // This date represents either the start or the end time for DST
128    static class TimeZoneDate {
129        String year;
130        int month;
131        int dayOfWeek;
132        int day;
133        int time;
134        int hour;
135        int minute;
136    }
137
138    // Write SYSTEMTIME data into a byte array (this will either be for the standard or daylight
139    // transition)
140    static void putTimeInMillisIntoSystemTime(byte[] bytes, int offset, long millis) {
141        GregorianCalendar cal = new GregorianCalendar(TimeZone.getDefault());
142        // Round to the next highest minute; we always write seconds as zero
143        cal.setTimeInMillis(millis + 30*SECONDS);
144
145        // MSFT months are 1 based; TimeZone is 0 based
146        setWord(bytes, offset + MSFT_SYSTEMTIME_MONTH, cal.get(Calendar.MONTH) + 1);
147        // MSFT day of week starts w/ Sunday = 0; TimeZone starts w/ Sunday = 1
148        setWord(bytes, offset + MSFT_SYSTEMTIME_DAY_OF_WEEK, cal.get(Calendar.DAY_OF_WEEK) - 1);
149
150        // Get the "day" in TimeZone format
151        int wom = cal.get(Calendar.DAY_OF_WEEK_IN_MONTH);
152        // 5 means "last" in MSFT land; for TimeZone, it's -1
153        setWord(bytes, offset + MSFT_SYSTEMTIME_DAY, wom < 0 ? 5 : wom);
154
155        // Turn hours/minutes into ms from midnight (per TimeZone)
156        setWord(bytes, offset + MSFT_SYSTEMTIME_HOUR, cal.get(Calendar.HOUR));
157        setWord(bytes, offset + MSFT_SYSTEMTIME_MINUTE, cal.get(Calendar.MINUTE));
158     }
159
160    // Build a TimeZoneDate structure from a SYSTEMTIME within a byte array at a given offset
161    static TimeZoneDate getTimeZoneDateFromSystemTime(byte[] bytes, int offset) {
162        TimeZoneDate tzd = new TimeZoneDate();
163
164        // MSFT year is an int; TimeZone is a String
165        int num = getWord(bytes, offset + MSFT_SYSTEMTIME_YEAR);
166        tzd.year = Integer.toString(num);
167
168        // MSFT month = 0 means no daylight time
169        // MSFT months are 1 based; TimeZone is 0 based
170        num = getWord(bytes, offset + MSFT_SYSTEMTIME_MONTH);
171        if (num == 0) {
172            return null;
173        } else {
174            tzd.month = num -1;
175        }
176
177        // MSFT day of week starts w/ Sunday = 0; TimeZone starts w/ Sunday = 1
178        tzd.dayOfWeek = getWord(bytes, offset + MSFT_SYSTEMTIME_DAY_OF_WEEK) + 1;
179
180        // Get the "day" in TimeZone format
181        num = getWord(bytes, offset + MSFT_SYSTEMTIME_DAY);
182        // 5 means "last" in MSFT land; for TimeZone, it's -1
183        if (num == 5) {
184            tzd.day = -1;
185        } else {
186            tzd.day = num;
187        }
188
189        // Turn hours/minutes into ms from midnight (per TimeZone)
190        int hour = getWord(bytes, offset + MSFT_SYSTEMTIME_HOUR);
191        tzd.hour = hour;
192        int minute = getWord(bytes, offset + MSFT_SYSTEMTIME_MINUTE);
193        tzd.minute = minute;
194        tzd.time = (hour*HOURS) + (minute*MINUTES);
195
196        return tzd;
197    }
198
199    // Return a String from within a byte array at the given offset with max characters
200    // Unused for now, but might be helpful for debugging
201    //    String getString(byte[] bytes, int offset, int max) {
202    //    	StringBuilder sb = new StringBuilder();
203    //    	while (max-- > 0) {
204    //    		int b = bytes[offset];
205    //    		if (b == 0) break;
206    //    		sb.append((char)b);
207    //    		offset += 2;
208    //    	}
209    //    	return sb.toString();
210    //    }
211
212    /**
213     * Build a GregorianCalendar, based on a time zone and TimeZoneDate.
214     * @param timeZone the time zone we're checking
215     * @param tzd the TimeZoneDate we're interested in
216     * @return a GregorianCalendar with the given time zone and date
217     */
218    static GregorianCalendar getCheckCalendar(TimeZone timeZone, TimeZoneDate tzd) {
219        GregorianCalendar testCalendar = new GregorianCalendar(timeZone);
220        testCalendar.set(GregorianCalendar.YEAR, sCurrentYear);
221        testCalendar.set(GregorianCalendar.MONTH, tzd.month);
222        testCalendar.set(GregorianCalendar.DAY_OF_WEEK, tzd.dayOfWeek);
223        testCalendar.set(GregorianCalendar.DAY_OF_WEEK_IN_MONTH, tzd.day);
224        testCalendar.set(GregorianCalendar.HOUR_OF_DAY, tzd.hour);
225        testCalendar.set(GregorianCalendar.MINUTE, tzd.minute);
226        return testCalendar;
227    }
228
229    /**
230     * Find a standard/daylight transition between a start time and an end time
231     * @param tz a TimeZone
232     * @param startTime the start time for the test
233     * @param endTime the end time for the test
234     * @param startInDaylightTime whether daylight time is in effect at the startTime
235     * @return the time in millis of the first transition, or 0 if none
236     */
237    static private long findTransition(TimeZone tz, long startTime, long endTime,
238            boolean startInDaylightTime) {
239        long startingEndTime = endTime;
240        Date date = null;
241        while ((endTime - startTime) > MINUTES) {
242            long checkTime = ((startTime + endTime) / 2) + 1;
243            date = new Date(checkTime);
244            if (tz.inDaylightTime(date) != startInDaylightTime) {
245                endTime = checkTime;
246            } else {
247                startTime = checkTime;
248            }
249        }
250        if (endTime == startingEndTime) {
251            // Really, this shouldn't happen
252            return 0;
253        }
254        return startTime;
255    }
256
257    /**
258     * Return a Base64 representation of a MSFT TIME_ZONE_INFORMATION structure from a TimeZone
259     * that might be found in an Event; use cached result, if possible
260     * @param tz the TimeZone
261     * @return the Base64 String representing a Microsoft TIME_ZONE_INFORMATION element
262     */
263    static public String timeZoneToTziString(TimeZone tz) {
264        String tziString = sTziStringCache.get(tz);
265        if (tziString != null) {
266            if (Eas.USER_LOG) {
267                Log.d(TAG, "TZI string for " + tz.getDisplayName() + " found in cache.");
268            }
269            return tziString;
270        }
271        tziString = timeZoneToTziStringImpl(tz);
272        sTziStringCache.put(tz, tziString);
273        return tziString;
274    }
275
276    /**
277     * Calculate the Base64 representation of a MSFT TIME_ZONE_INFORMATION structure from a TimeZone
278     * that might be found in an Event.  Since the internal representation of the TimeZone is hidden
279     * from us we'll find the DST transitions and build the structure from that information
280     * @param tz the TimeZone
281     * @return the Base64 String representing a Microsoft TIME_ZONE_INFORMATION element
282     */
283    static public String timeZoneToTziStringImpl(TimeZone tz) {
284        String tziString;
285        long time = System.currentTimeMillis();
286        byte[] tziBytes = new byte[MSFT_TIME_ZONE_SIZE];
287        int standardBias = - tz.getRawOffset();
288        standardBias /= 60*SECONDS;
289        setLong(tziBytes, MSFT_TIME_ZONE_BIAS_OFFSET, standardBias);
290        // If this time zone has daylight savings time, we need to do a bunch more work
291        if (tz.useDaylightTime()) {
292            long standardTransition = 0;
293            long daylightTransition = 0;
294            GregorianCalendar cal = new GregorianCalendar();
295            cal.set(sCurrentYear, Calendar.JANUARY, 1, 0, 0, 0);
296            cal.setTimeZone(tz);
297            long startTime = cal.getTimeInMillis();
298            // Calculate rough end of year; no need to do the calculation
299            long endOfYearTime = startTime + 365*DAYS;
300            Date date = new Date(startTime);
301            boolean startInDaylightTime = tz.inDaylightTime(date);
302            // Find the first transition, and store
303            startTime = findTransition(tz, startTime, endOfYearTime, startInDaylightTime);
304            if (startInDaylightTime) {
305                standardTransition = startTime;
306            } else {
307                daylightTransition = startTime;
308            }
309            // Find the second transition, and store
310            startTime = findTransition(tz, startTime, endOfYearTime, !startInDaylightTime);
311            if (startInDaylightTime) {
312                daylightTransition = startTime;
313            } else {
314                standardTransition = startTime;
315            }
316            if (standardTransition != 0 && daylightTransition != 0) {
317                putTimeInMillisIntoSystemTime(tziBytes, MSFT_TIME_ZONE_STANDARD_DATE_OFFSET,
318                        standardTransition);
319                putTimeInMillisIntoSystemTime(tziBytes, MSFT_TIME_ZONE_DAYLIGHT_DATE_OFFSET,
320                        daylightTransition);
321                int dstOffset = tz.getDSTSavings();
322                setLong(tziBytes, MSFT_TIME_ZONE_DAYLIGHT_BIAS_OFFSET, - dstOffset / MINUTES);
323            }
324        }
325        // TODO Use a more efficient Base64 API
326        byte[] tziEncodedBytes = Base64.encode(tziBytes);
327        tziString = new String(tziEncodedBytes);
328        if (Eas.USER_LOG) {
329            Log.d(TAG, "Calculated TZI String for " + tz.getDisplayName() + " in " +
330                    (System.currentTimeMillis() - time) + "ms");
331        }
332        return tziString;
333    }
334
335    /**
336     * Given a String as directly read from EAS, returns a TimeZone corresponding to that String
337     * @param timeZoneString the String read from the server
338     * @return the TimeZone, or TimeZone.getDefault() if not found
339     */
340    static public TimeZone tziStringToTimeZone(String timeZoneString) {
341        // If we have this time zone cached, use that value and return
342        TimeZone timeZone = sTimeZoneCache.get(timeZoneString);
343        if (timeZone != null) {
344            if (Eas.USER_LOG) {
345                Log.d(TAG, " Using cached TimeZone " + timeZone.getDisplayName());
346            }
347        } else {
348            timeZone = tziStringToTimeZoneImpl(timeZoneString);
349            if (timeZone == null) {
350                // If we don't find a match, we just return the current TimeZone.  In theory, this
351                // shouldn't be happening...
352                Log.w(TAG, "TimeZone not found using default: " + timeZoneString);
353                timeZone = TimeZone.getDefault();
354            }
355            sTimeZoneCache.put(timeZoneString, timeZone);
356        }
357        return timeZone;
358    }
359
360    /**
361     * Given a String as directly read from EAS, tries to find a TimeZone in the database of all
362     * time zones that corresponds to that String.
363     * @param timeZoneString the String read from the server
364     * @return the TimeZone, or TimeZone.getDefault() if not found
365     */
366    static public TimeZone tziStringToTimeZoneImpl(String timeZoneString) {
367        TimeZone timeZone = null;
368        // TODO Remove after we're comfortable with performance
369        long time = System.currentTimeMillis();
370        // First, we need to decode the base64 string
371        byte[] timeZoneBytes = Base64.decode(timeZoneString);
372
373        // Then, we get the bias (similar to a rawOffset); for TimeZone, we need ms
374        // but EAS gives us minutes, so do the conversion.  Note that EAS is the bias that's added
375        // to the time zone to reach UTC; our library uses the time from UTC to our time zone, so
376        // we need to change the sign
377        int bias = -1 * getLong(timeZoneBytes, MSFT_TIME_ZONE_BIAS_OFFSET) * MINUTES;
378
379        // Get all of the time zones with the bias as a rawOffset; if there aren't any, we return
380        // the default time zone
381        String[] zoneIds = TimeZone.getAvailableIDs(bias);
382        if (zoneIds.length > 0) {
383            // Try to find an existing TimeZone from the data provided by EAS
384            // We start by pulling out the date that standard time begins
385            TimeZoneDate dstEnd =
386                getTimeZoneDateFromSystemTime(timeZoneBytes, MSFT_TIME_ZONE_STANDARD_DATE_OFFSET);
387            if (dstEnd == null) {
388                // In this case, there is no daylight savings time, so the only interesting data
389                // is the offset, and we know that all of the zoneId's match; we'll take the first
390                timeZone = TimeZone.getTimeZone(zoneIds[0]);
391                String dn = timeZone.getDisplayName();
392                sTimeZoneCache.put(timeZoneString, timeZone);
393                if (Eas.USER_LOG) {
394                    Log.d(TAG, "TimeZone without DST found by offset: " + dn);
395                }
396            } else {
397                TimeZoneDate dstStart = getTimeZoneDateFromSystemTime(timeZoneBytes,
398                        MSFT_TIME_ZONE_DAYLIGHT_DATE_OFFSET);
399                // See comment above for bias...
400                long dstSavings =
401                    -1 * getLong(timeZoneBytes, MSFT_TIME_ZONE_DAYLIGHT_BIAS_OFFSET) * MINUTES;
402
403                // We'll go through each time zone to find one with the same DST transitions and
404                // savings length
405                for (String zoneId: zoneIds) {
406                    // Get the TimeZone using the zoneId
407                    timeZone = TimeZone.getTimeZone(zoneId);
408
409                    // Our strategy here is to check just before and just after the transitions
410                    // and see whether the check for daylight time matches the expectation
411                    // If both transitions match, then we have a match for the offset and start/end
412                    // of dst.  That's the best we can do for now, since there's no other info
413                    // provided by EAS (i.e. we can't get dynamic transitions, etc.)
414
415                    int testSavingsMinutes = timeZone.getDSTSavings() / MINUTES;
416                    int errorBoundsMinutes = (testSavingsMinutes * 2) + 1;
417
418                    // Check start DST transition
419                    GregorianCalendar testCalendar = getCheckCalendar(timeZone, dstStart);
420                    testCalendar.add(GregorianCalendar.MINUTE, - errorBoundsMinutes);
421                    Date before = testCalendar.getTime();
422                    testCalendar.add(GregorianCalendar.MINUTE, 2*errorBoundsMinutes);
423                    Date after = testCalendar.getTime();
424                    if (timeZone.inDaylightTime(before)) continue;
425                    if (!timeZone.inDaylightTime(after)) continue;
426
427                    // Check end DST transition
428                    testCalendar = getCheckCalendar(timeZone, dstEnd);
429                    testCalendar.add(GregorianCalendar.MINUTE, - errorBoundsMinutes);
430                    before = testCalendar.getTime();
431                    testCalendar.add(GregorianCalendar.MINUTE, 2*errorBoundsMinutes);
432                    after = testCalendar.getTime();
433                    if (!timeZone.inDaylightTime(before)) continue;
434                    if (timeZone.inDaylightTime(after)) continue;
435
436                    // Check that the savings are the same
437                    if (dstSavings != timeZone.getDSTSavings()) continue;
438
439                    // If we're here, it's the right time zone, modulo dynamic DST
440                    String dn = timeZone.getDisplayName();
441                    // TODO Remove timing when we're comfortable with performance
442                    if (Eas.USER_LOG) {
443                        Log.d(TAG, "TimeZone found by rules: " + dn + " in " +
444                                (System.currentTimeMillis() - time) + "ms");
445                    }
446                    break;
447                }
448            }
449        }
450        return timeZone;
451    }
452
453    /**
454     * Generate a time in milliseconds from a date string that represents a date/time in GMT
455     * @param DateTime string from Exchange server
456     * @return the time in milliseconds (since Jan 1, 1970)
457     */
458    static public long parseDateTimeToMillis(String date) {
459        // Format for calendar date strings is 20090211T180303Z
460        GregorianCalendar cal = new GregorianCalendar(Integer.parseInt(date.substring(0, 4)),
461                Integer.parseInt(date.substring(4, 6)) - 1, Integer.parseInt(date.substring(6, 8)),
462                Integer.parseInt(date.substring(9, 11)), Integer.parseInt(date.substring(11, 13)),
463                Integer.parseInt(date.substring(13, 15)));
464        cal.setTimeZone(TimeZone.getTimeZone("GMT"));
465        return cal.getTimeInMillis();
466    }
467
468    /**
469     * Generate a GregorianCalendar from a date string that represents a date/time in GMT
470     * @param DateTime string from Exchange server
471     * @return the GregorianCalendar
472     */
473    static public GregorianCalendar parseDateTimeToCalendar(String date) {
474        // Format for calendar date strings is 20090211T180303Z
475        GregorianCalendar cal = new GregorianCalendar(Integer.parseInt(date.substring(0, 4)),
476                Integer.parseInt(date.substring(4, 6)) - 1, Integer.parseInt(date.substring(6, 8)),
477                Integer.parseInt(date.substring(9, 11)), Integer.parseInt(date.substring(11, 13)),
478                Integer.parseInt(date.substring(13, 15)));
479        cal.setTimeZone(TimeZone.getTimeZone("GMT"));
480        return cal;
481    }
482
483    static String formatTwo(int num) {
484        if (num <= 12) {
485            return sTwoCharacterNumbers[num];
486        } else
487            return Integer.toString(num);
488    }
489
490    /**
491     * Generate an EAS formatted date/time string based on GMT. See below for details.
492     */
493    static public String millisToEasDateTime(long millis) {
494        return millisToEasDateTime(millis, sGmtTimeZone);
495    }
496
497    /**
498     * Generate an EAS formatted local date/time string from a time and a time zone
499     * @param millis a time in milliseconds
500     * @param tz a time zone
501     * @return an EAS formatted string indicating the date/time in the given time zone
502     */
503    static public String millisToEasDateTime(long millis, TimeZone tz) {
504        StringBuilder sb = new StringBuilder();
505        GregorianCalendar cal = new GregorianCalendar(tz);
506        cal.setTimeInMillis(millis);
507        sb.append(cal.get(Calendar.YEAR));
508        sb.append(formatTwo(cal.get(Calendar.MONTH) + 1));
509        sb.append(formatTwo(cal.get(Calendar.DAY_OF_MONTH)));
510        sb.append('T');
511        sb.append(formatTwo(cal.get(Calendar.HOUR_OF_DAY)));
512        sb.append(formatTwo(cal.get(Calendar.MINUTE)));
513        sb.append(formatTwo(cal.get(Calendar.SECOND)));
514        sb.append('Z');
515        return sb.toString();
516    }
517
518    static void addByDay(StringBuilder rrule, int dow, int wom) {
519        rrule.append(";BYDAY=");
520        boolean addComma = false;
521        for (int i = 0; i < 7; i++) {
522            if ((dow & 1) == 1) {
523                if (addComma) {
524                    rrule.append(',');
525                }
526                if (wom > 0) {
527                    // 5 = last week -> -1
528                    // So -1SU = last sunday
529                    rrule.append(wom == 5 ? -1 : wom);
530                }
531                rrule.append(sDayTokens[i]);
532                addComma = true;
533            }
534            dow >>= 1;
535        }
536    }
537
538    static void addByMonthDay(StringBuilder rrule, int dom) {
539        // 127 means last day of the month
540        if (dom == 127) {
541            dom = -1;
542        }
543        rrule.append(";BYMONTHDAY=" + dom);
544    }
545
546    /**
547     * Generate the String version of the EAS integer for a given BYDAY value in an rrule
548     * @param dow the BYDAY value of the rrule
549     * @return the String version of the EAS value of these days
550     */
551    static String generateEasDayOfWeek(String dow) {
552        int bits = 0;
553        int bit = 1;
554        for (String token: sDayTokens) {
555            // If we can find the day in the dow String, add the bit to our bits value
556            if (dow.indexOf(token) >= 0) {
557                bits |= bit;
558            }
559            bit <<= 1;
560        }
561        return Integer.toString(bits);
562    }
563
564    /**
565     * Extract the value of a token in an RRULE string
566     * @param rrule an RRULE string
567     * @param token a token to look for in the RRULE
568     * @return the value of that token
569     */
570    static String tokenFromRrule(String rrule, String token) {
571        int start = rrule.indexOf(token);
572        if (start < 0) return null;
573        int len = rrule.length();
574        start += token.length();
575        int end = start;
576        char c;
577        do {
578            c = rrule.charAt(end++);
579            if (!Character.isLetterOrDigit(c) || (end == len)) {
580                if (end == len) end++;
581                return rrule.substring(start, end -1);
582            }
583        } while (true);
584    }
585
586    /**
587     * Write recurrence information to EAS based on the RRULE in CalendarProvider
588     * @param rrule the RRULE, from CalendarProvider
589     * @param startTime, the DTSTART of this Event
590     * @param s the Serializer we're using to write WBXML data
591     * @throws IOException
592     */
593    // NOTE: For the moment, we're only parsing recurrence types that are supported by the
594    // Calendar app UI, which is a small subset of possible recurrence types
595    // This code must be updated when the Calendar adds new functionality
596    static public void recurrenceFromRrule(String rrule, long startTime, Serializer s)
597    throws IOException {
598        Log.d("RRULE", "rule: " + rrule);
599        String freq = tokenFromRrule(rrule, "FREQ=");
600        // If there's no FREQ=X, then we don't write a recurrence
601        // Note that we duplicate s.start(Tags.CALENDAR_RECURRENCE); s.end(); to prevent the
602        // possibility of writing out a partial recurrence stanza
603        if (freq != null) {
604            if (freq.equals("DAILY")) {
605                s.start(Tags.CALENDAR_RECURRENCE);
606                s.data(Tags.CALENDAR_RECURRENCE_TYPE, "0");
607                s.data(Tags.CALENDAR_RECURRENCE_INTERVAL, "1");
608                s.end();
609            } else if (freq.equals("WEEKLY")) {
610                s.start(Tags.CALENDAR_RECURRENCE);
611                s.data(Tags.CALENDAR_RECURRENCE_TYPE, "1");
612                s.data(Tags.CALENDAR_RECURRENCE_INTERVAL, "1");
613                // Requires a day of week (whereas RRULE does not)
614                String byDay = tokenFromRrule(rrule, "BYDAY=");
615                if (byDay != null) {
616                    s.data(Tags.CALENDAR_RECURRENCE_DAYOFWEEK, generateEasDayOfWeek(byDay));
617                }
618                s.end();
619            } else if (freq.equals("MONTHLY")) {
620                String byMonthDay = tokenFromRrule(rrule, "BYMONTHDAY=");
621                if (byMonthDay != null) {
622                    // The nth day of the month
623                    s.start(Tags.CALENDAR_RECURRENCE);
624                    s.data(Tags.CALENDAR_RECURRENCE_TYPE, "2");
625                    s.data(Tags.CALENDAR_RECURRENCE_DAYOFMONTH, byMonthDay);
626                    s.end();
627                } else {
628                    String byDay = tokenFromRrule(rrule, "BYDAY=");
629                    String bareByDay;
630                    if (byDay != null) {
631                        // This can be 1WE (1st Wednesday) or -1FR (last Friday)
632                        int wom = byDay.charAt(0);
633                        if (wom == '-') {
634                            // -1 is the only legal case (last week) Use "5" for EAS
635                            wom = 5;
636                            bareByDay = byDay.substring(2);
637                        } else {
638                            wom = wom - '0';
639                            bareByDay = byDay.substring(1);
640                        }
641                        s.start(Tags.CALENDAR_RECURRENCE);
642                        s.data(Tags.CALENDAR_RECURRENCE_TYPE, "3");
643                        s.data(Tags.CALENDAR_RECURRENCE_WEEKOFMONTH, Integer.toString(wom));
644                        s.data(Tags.CALENDAR_RECURRENCE_DAYOFWEEK, generateEasDayOfWeek(bareByDay));
645                        s.end();
646                    }
647                }
648            } else if (freq.equals("YEARLY")) {
649                String byMonth = tokenFromRrule(rrule, "BYMONTH=");
650                String byMonthDay = tokenFromRrule(rrule, "BYMONTHDAY=");
651                if (byMonth == null || byMonthDay == null) {
652                    // Calculate the month and day from the startDate
653                    GregorianCalendar cal = new GregorianCalendar();
654                    cal.setTimeInMillis(startTime);
655                    cal.setTimeZone(TimeZone.getDefault());
656                    byMonth = Integer.toString(cal.get(Calendar.MONTH) + 1);
657                    byMonthDay = Integer.toString(cal.get(Calendar.DAY_OF_MONTH));
658                }
659                s.start(Tags.CALENDAR_RECURRENCE);
660                s.data(Tags.CALENDAR_RECURRENCE_TYPE, "5");
661                s.data(Tags.CALENDAR_RECURRENCE_DAYOFMONTH, byMonthDay);
662                s.data(Tags.CALENDAR_RECURRENCE_MONTHOFYEAR, byMonth);
663                s.end();
664            }
665        }
666    }
667
668    /**
669     * Build an RRULE String from EAS recurrence information
670     * @param type the type of recurrence
671     * @param occurrences how many recurrences (instances)
672     * @param interval the interval between recurrences
673     * @param dow day of the week
674     * @param dom day of the month
675     * @param wom week of the month
676     * @param moy month of the year
677     * @param until the last recurrence time
678     * @return a valid RRULE String
679     */
680    static public String rruleFromRecurrence(int type, int occurrences, int interval, int dow,
681            int dom, int wom, int moy, String until) {
682        StringBuilder rrule = new StringBuilder("FREQ=" + sTypeToFreq[type]);
683
684        // INTERVAL and COUNT
685        if (interval > 0) {
686            rrule.append(";INTERVAL=" + interval);
687        }
688        if (occurrences > 0) {
689            rrule.append(";COUNT=" + occurrences);
690        }
691
692        // Days, weeks, months, etc.
693        switch(type) {
694            case 0: // DAILY
695            case 1: // WEEKLY
696                if (dow > 0) addByDay(rrule, dow, -1);
697                break;
698            case 2: // MONTHLY
699                if (dom > 0) addByMonthDay(rrule, dom);
700                break;
701            case 3: // MONTHLY (on the nth day)
702                if (dow > 0) addByDay(rrule, dow, wom);
703                break;
704            case 5: // YEARLY
705                if (dom > 0) addByMonthDay(rrule, dom);
706                if (moy > 0) {
707                    // TODO MAKE SURE WE'RE 1 BASED
708                    rrule.append(";BYMONTH=" + moy);
709                }
710                break;
711            case 6: // YEARLY (on the nth day)
712                if (dow > 0) addByDay(rrule, dow, wom);
713                if (moy > 0) addByMonthDay(rrule, dow);
714                break;
715            default:
716                break;
717        }
718
719        // UNTIL comes last
720        // TODO Add UNTIL code
721        if (until != null) {
722            // *** until probably needs reformatting
723            //rrule.append(";UNTIL=" + until);
724        }
725
726        return rrule.toString();
727    }
728}