RecurrenceSet.java revision 0d3524562e330e74f150a17c4dc4dd66a0faae46
1/*
2 * Copyright (C) 2007 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.calendarcommon;
18
19import android.content.ContentValues;
20import android.database.Cursor;
21import android.provider.CalendarContract;
22import android.text.TextUtils;
23import android.text.format.Time;
24import android.util.Log;
25
26import java.util.List;
27import java.util.regex.Pattern;
28
29/**
30 * Basic information about a recurrence, following RFC 2445 Section 4.8.5.
31 * Contains the RRULEs, RDATE, EXRULEs, and EXDATE properties.
32 */
33public class RecurrenceSet {
34
35    private final static String TAG = "CalendarProvider";
36
37    private final static String RULE_SEPARATOR = "\n";
38    private final static String FOLDING_SEPARATOR = "\n ";
39
40    // TODO: make these final?
41    public EventRecurrence[] rrules = null;
42    public long[] rdates = null;
43    public EventRecurrence[] exrules = null;
44    public long[] exdates = null;
45
46    /**
47     * Creates a new RecurrenceSet from information stored in the
48     * events table in the CalendarProvider.
49     * @param values The values retrieved from the Events table.
50     */
51    public RecurrenceSet(ContentValues values)
52            throws EventRecurrence.InvalidFormatException {
53        String rruleStr = values.getAsString(CalendarContract.Events.RRULE);
54        String rdateStr = values.getAsString(CalendarContract.Events.RDATE);
55        String exruleStr = values.getAsString(CalendarContract.Events.EXRULE);
56        String exdateStr = values.getAsString(CalendarContract.Events.EXDATE);
57        init(rruleStr, rdateStr, exruleStr, exdateStr);
58    }
59
60    /**
61     * Creates a new RecurrenceSet from information stored in a database
62     * {@link Cursor} pointing to the events table in the
63     * CalendarProvider.  The cursor must contain the RRULE, RDATE, EXRULE,
64     * and EXDATE columns.
65     *
66     * @param cursor The cursor containing the RRULE, RDATE, EXRULE, and EXDATE
67     * columns.
68     */
69    public RecurrenceSet(Cursor cursor)
70            throws EventRecurrence.InvalidFormatException {
71        int rruleColumn = cursor.getColumnIndex(CalendarContract.Events.RRULE);
72        int rdateColumn = cursor.getColumnIndex(CalendarContract.Events.RDATE);
73        int exruleColumn = cursor.getColumnIndex(CalendarContract.Events.EXRULE);
74        int exdateColumn = cursor.getColumnIndex(CalendarContract.Events.EXDATE);
75        String rruleStr = cursor.getString(rruleColumn);
76        String rdateStr = cursor.getString(rdateColumn);
77        String exruleStr = cursor.getString(exruleColumn);
78        String exdateStr = cursor.getString(exdateColumn);
79        init(rruleStr, rdateStr, exruleStr, exdateStr);
80    }
81
82    public RecurrenceSet(String rruleStr, String rdateStr,
83                  String exruleStr, String exdateStr)
84            throws EventRecurrence.InvalidFormatException {
85        init(rruleStr, rdateStr, exruleStr, exdateStr);
86    }
87
88    private void init(String rruleStr, String rdateStr,
89                      String exruleStr, String exdateStr)
90            throws EventRecurrence.InvalidFormatException {
91        if (!TextUtils.isEmpty(rruleStr) || !TextUtils.isEmpty(rdateStr)) {
92
93            if (!TextUtils.isEmpty(rruleStr)) {
94                String[] rruleStrs = rruleStr.split(RULE_SEPARATOR);
95                rrules = new EventRecurrence[rruleStrs.length];
96                for (int i = 0; i < rruleStrs.length; ++i) {
97                    EventRecurrence rrule = new EventRecurrence();
98                    rrule.parse(rruleStrs[i]);
99                    rrules[i] = rrule;
100                }
101            }
102
103            if (!TextUtils.isEmpty(rdateStr)) {
104                rdates = parseRecurrenceDates(rdateStr);
105            }
106
107            if (!TextUtils.isEmpty(exruleStr)) {
108                String[] exruleStrs = exruleStr.split(RULE_SEPARATOR);
109                exrules = new EventRecurrence[exruleStrs.length];
110                for (int i = 0; i < exruleStrs.length; ++i) {
111                    EventRecurrence exrule = new EventRecurrence();
112                    exrule.parse(exruleStr);
113                    exrules[i] = exrule;
114                }
115            }
116
117            if (!TextUtils.isEmpty(exdateStr)) {
118                exdates = parseRecurrenceDates(exdateStr);
119            }
120        }
121    }
122
123    /**
124     * Returns whether or not a recurrence is defined in this RecurrenceSet.
125     * @return Whether or not a recurrence is defined in this RecurrenceSet.
126     */
127    public boolean hasRecurrence() {
128        return (rrules != null || rdates != null);
129    }
130
131    /**
132     * Parses the provided RDATE or EXDATE string into an array of longs
133     * representing each date/time in the recurrence.
134     * @param recurrence The recurrence to be parsed.
135     * @return The list of date/times.
136     */
137    public static long[] parseRecurrenceDates(String recurrence) {
138        // TODO: use "local" time as the default.  will need to handle times
139        // that end in "z" (UTC time) explicitly at that point.
140        String tz = Time.TIMEZONE_UTC;
141        int tzidx = recurrence.indexOf(";");
142        if (tzidx != -1) {
143            tz = recurrence.substring(0, tzidx);
144            recurrence = recurrence.substring(tzidx + 1);
145        }
146        Time time = new Time(tz);
147        String[] rawDates = recurrence.split(",");
148        int n = rawDates.length;
149        long[] dates = new long[n];
150        for (int i = 0; i<n; ++i) {
151            // The timezone is updated to UTC if the time string specified 'Z'.
152            time.parse(rawDates[i]);
153            dates[i] = time.toMillis(false /* use isDst */);
154            time.timezone = tz;
155        }
156        return dates;
157    }
158
159    /**
160     * Populates the database map of values with the appropriate RRULE, RDATE,
161     * EXRULE, and EXDATE values extracted from the parsed iCalendar component.
162     * @param component The iCalendar component containing the desired
163     * recurrence specification.
164     * @param values The db values that should be updated.
165     * @return true if the component contained the necessary information
166     * to specify a recurrence.  The required fields are DTSTART,
167     * one of DTEND/DURATION, and one of RRULE/RDATE.  Returns false if
168     * there was an error, including if the date is out of range.
169     */
170    public static boolean populateContentValues(ICalendar.Component component,
171            ContentValues values) {
172        ICalendar.Property dtstartProperty =
173                component.getFirstProperty("DTSTART");
174        String dtstart = dtstartProperty.getValue();
175        ICalendar.Parameter tzidParam =
176                dtstartProperty.getFirstParameter("TZID");
177        // NOTE: the timezone may be null, if this is a floating time.
178        String tzid = tzidParam == null ? null : tzidParam.value;
179        Time start = new Time(tzidParam == null ? Time.TIMEZONE_UTC : tzid);
180        boolean inUtc = start.parse(dtstart);
181        boolean allDay = start.allDay;
182
183        // We force TimeZone to UTC for "all day recurring events" as the server is sending no
184        // TimeZone in DTSTART for them
185        if (inUtc || allDay) {
186            tzid = Time.TIMEZONE_UTC;
187        }
188
189        String duration = computeDuration(start, component);
190        String rrule = flattenProperties(component, "RRULE");
191        String rdate = extractDates(component.getFirstProperty("RDATE"));
192        String exrule = flattenProperties(component, "EXRULE");
193        String exdate = extractDates(component.getFirstProperty("EXDATE"));
194
195        if ((TextUtils.isEmpty(dtstart))||
196                (TextUtils.isEmpty(duration))||
197                ((TextUtils.isEmpty(rrule))&&
198                        (TextUtils.isEmpty(rdate)))) {
199                if (false) {
200                    Log.d(TAG, "Recurrence missing DTSTART, DTEND/DURATION, "
201                                + "or RRULE/RDATE: "
202                                + component.toString());
203                }
204                return false;
205        }
206
207        if (allDay) {
208            start.timezone = Time.TIMEZONE_UTC;
209        }
210        long millis = start.toMillis(false /* use isDst */);
211        values.put(CalendarContract.Events.DTSTART, millis);
212        if (millis == -1) {
213            if (false) {
214                Log.d(TAG, "DTSTART is out of range: " + component.toString());
215            }
216            return false;
217        }
218
219        values.put(CalendarContract.Events.RRULE, rrule);
220        values.put(CalendarContract.Events.RDATE, rdate);
221        values.put(CalendarContract.Events.EXRULE, exrule);
222        values.put(CalendarContract.Events.EXDATE, exdate);
223        values.put(CalendarContract.Events.EVENT_TIMEZONE, tzid);
224        values.put(CalendarContract.Events.DURATION, duration);
225        values.put(CalendarContract.Events.ALL_DAY, allDay ? 1 : 0);
226        return true;
227    }
228
229    // This can be removed when the old CalendarSyncAdapter is removed.
230    public static boolean populateComponent(Cursor cursor,
231                                            ICalendar.Component component) {
232
233        int dtstartColumn = cursor.getColumnIndex(CalendarContract.Events.DTSTART);
234        int durationColumn = cursor.getColumnIndex(CalendarContract.Events.DURATION);
235        int tzidColumn = cursor.getColumnIndex(CalendarContract.Events.EVENT_TIMEZONE);
236        int rruleColumn = cursor.getColumnIndex(CalendarContract.Events.RRULE);
237        int rdateColumn = cursor.getColumnIndex(CalendarContract.Events.RDATE);
238        int exruleColumn = cursor.getColumnIndex(CalendarContract.Events.EXRULE);
239        int exdateColumn = cursor.getColumnIndex(CalendarContract.Events.EXDATE);
240        int allDayColumn = cursor.getColumnIndex(CalendarContract.Events.ALL_DAY);
241
242
243        long dtstart = -1;
244        if (!cursor.isNull(dtstartColumn)) {
245            dtstart = cursor.getLong(dtstartColumn);
246        }
247        String duration = cursor.getString(durationColumn);
248        String tzid = cursor.getString(tzidColumn);
249        String rruleStr = cursor.getString(rruleColumn);
250        String rdateStr = cursor.getString(rdateColumn);
251        String exruleStr = cursor.getString(exruleColumn);
252        String exdateStr = cursor.getString(exdateColumn);
253        boolean allDay = cursor.getInt(allDayColumn) == 1;
254
255        if ((dtstart == -1) ||
256            (TextUtils.isEmpty(duration))||
257            ((TextUtils.isEmpty(rruleStr))&&
258                (TextUtils.isEmpty(rdateStr)))) {
259                // no recurrence.
260                return false;
261        }
262
263        ICalendar.Property dtstartProp = new ICalendar.Property("DTSTART");
264        Time dtstartTime = null;
265        if (!TextUtils.isEmpty(tzid)) {
266            if (!allDay) {
267                dtstartProp.addParameter(new ICalendar.Parameter("TZID", tzid));
268            }
269            dtstartTime = new Time(tzid);
270        } else {
271            // use the "floating" timezone
272            dtstartTime = new Time(Time.TIMEZONE_UTC);
273        }
274
275        dtstartTime.set(dtstart);
276        // make sure the time is printed just as a date, if all day.
277        // TODO: android.pim.Time really should take care of this for us.
278        if (allDay) {
279            dtstartProp.addParameter(new ICalendar.Parameter("VALUE", "DATE"));
280            dtstartTime.allDay = true;
281            dtstartTime.hour = 0;
282            dtstartTime.minute = 0;
283            dtstartTime.second = 0;
284        }
285
286        dtstartProp.setValue(dtstartTime.format2445());
287        component.addProperty(dtstartProp);
288        ICalendar.Property durationProp = new ICalendar.Property("DURATION");
289        durationProp.setValue(duration);
290        component.addProperty(durationProp);
291
292        addPropertiesForRuleStr(component, "RRULE", rruleStr);
293        addPropertyForDateStr(component, "RDATE", rdateStr);
294        addPropertiesForRuleStr(component, "EXRULE", exruleStr);
295        addPropertyForDateStr(component, "EXDATE", exdateStr);
296        return true;
297    }
298
299public static boolean populateComponent(ContentValues values,
300                                            ICalendar.Component component) {
301        long dtstart = -1;
302        if (values.containsKey(CalendarContract.Events.DTSTART)) {
303            dtstart = values.getAsLong(CalendarContract.Events.DTSTART);
304        }
305        String duration = values.getAsString(CalendarContract.Events.DURATION);
306        String tzid = values.getAsString(CalendarContract.Events.EVENT_TIMEZONE);
307        String rruleStr = values.getAsString(CalendarContract.Events.RRULE);
308        String rdateStr = values.getAsString(CalendarContract.Events.RDATE);
309        String exruleStr = values.getAsString(CalendarContract.Events.EXRULE);
310        String exdateStr = values.getAsString(CalendarContract.Events.EXDATE);
311        Integer allDayInteger = values.getAsInteger(CalendarContract.Events.ALL_DAY);
312        boolean allDay = (null != allDayInteger) ? (allDayInteger == 1) : false;
313
314        if ((dtstart == -1) ||
315            (TextUtils.isEmpty(duration))||
316            ((TextUtils.isEmpty(rruleStr))&&
317                (TextUtils.isEmpty(rdateStr)))) {
318                // no recurrence.
319                return false;
320        }
321
322        ICalendar.Property dtstartProp = new ICalendar.Property("DTSTART");
323        Time dtstartTime = null;
324        if (!TextUtils.isEmpty(tzid)) {
325            if (!allDay) {
326                dtstartProp.addParameter(new ICalendar.Parameter("TZID", tzid));
327            }
328            dtstartTime = new Time(tzid);
329        } else {
330            // use the "floating" timezone
331            dtstartTime = new Time(Time.TIMEZONE_UTC);
332        }
333
334        dtstartTime.set(dtstart);
335        // make sure the time is printed just as a date, if all day.
336        // TODO: android.pim.Time really should take care of this for us.
337        if (allDay) {
338            dtstartProp.addParameter(new ICalendar.Parameter("VALUE", "DATE"));
339            dtstartTime.allDay = true;
340            dtstartTime.hour = 0;
341            dtstartTime.minute = 0;
342            dtstartTime.second = 0;
343        }
344
345        dtstartProp.setValue(dtstartTime.format2445());
346        component.addProperty(dtstartProp);
347        ICalendar.Property durationProp = new ICalendar.Property("DURATION");
348        durationProp.setValue(duration);
349        component.addProperty(durationProp);
350
351        addPropertiesForRuleStr(component, "RRULE", rruleStr);
352        addPropertyForDateStr(component, "RDATE", rdateStr);
353        addPropertiesForRuleStr(component, "EXRULE", exruleStr);
354        addPropertyForDateStr(component, "EXDATE", exdateStr);
355        return true;
356    }
357
358    private static void addPropertiesForRuleStr(ICalendar.Component component,
359                                                String propertyName,
360                                                String ruleStr) {
361        if (TextUtils.isEmpty(ruleStr)) {
362            return;
363        }
364        String[] rrules = getRuleStrings(ruleStr);
365        for (String rrule : rrules) {
366            ICalendar.Property prop = new ICalendar.Property(propertyName);
367            prop.setValue(rrule);
368            component.addProperty(prop);
369        }
370    }
371
372    private static String[] getRuleStrings(String ruleStr) {
373        if (null == ruleStr) {
374            return new String[0];
375        }
376        String unfoldedRuleStr = unfold(ruleStr);
377        String[] split = unfoldedRuleStr.split(RULE_SEPARATOR);
378        int count = split.length;
379        for (int n = 0; n < count; n++) {
380            split[n] = fold(split[n]);
381        }
382        return split;
383    }
384
385
386    private static final Pattern IGNORABLE_ICAL_WHITESPACE_RE =
387            Pattern.compile("(?:\\r\\n?|\\n)[ \t]");
388
389    private static final Pattern FOLD_RE = Pattern.compile(".{75}");
390
391    /**
392    * fold and unfolds ical content lines as per RFC 2445 section 4.1.
393    *
394    * <h3>4.1 Content Lines</h3>
395    *
396    * <p>The iCalendar object is organized into individual lines of text, called
397    * content lines. Content lines are delimited by a line break, which is a CRLF
398    * sequence (US-ASCII decimal 13, followed by US-ASCII decimal 10).
399    *
400    * <p>Lines of text SHOULD NOT be longer than 75 octets, excluding the line
401    * break. Long content lines SHOULD be split into a multiple line
402    * representations using a line "folding" technique. That is, a long line can
403    * be split between any two characters by inserting a CRLF immediately
404    * followed by a single linear white space character (i.e., SPACE, US-ASCII
405    * decimal 32 or HTAB, US-ASCII decimal 9). Any sequence of CRLF followed
406    * immediately by a single linear white space character is ignored (i.e.,
407    * removed) when processing the content type.
408    */
409    public static String fold(String unfoldedIcalContent) {
410        return FOLD_RE.matcher(unfoldedIcalContent).replaceAll("$0\r\n ");
411    }
412
413    public static String unfold(String foldedIcalContent) {
414        return IGNORABLE_ICAL_WHITESPACE_RE.matcher(
415            foldedIcalContent).replaceAll("");
416    }
417
418    private static void addPropertyForDateStr(ICalendar.Component component,
419                                              String propertyName,
420                                              String dateStr) {
421        if (TextUtils.isEmpty(dateStr)) {
422            return;
423        }
424
425        ICalendar.Property prop = new ICalendar.Property(propertyName);
426        String tz = null;
427        int tzidx = dateStr.indexOf(";");
428        if (tzidx != -1) {
429            tz = dateStr.substring(0, tzidx);
430            dateStr = dateStr.substring(tzidx + 1);
431        }
432        if (!TextUtils.isEmpty(tz)) {
433            prop.addParameter(new ICalendar.Parameter("TZID", tz));
434        }
435        prop.setValue(dateStr);
436        component.addProperty(prop);
437    }
438
439    private static String computeDuration(Time start,
440                                          ICalendar.Component component) {
441        // see if a duration is defined
442        ICalendar.Property durationProperty =
443                component.getFirstProperty("DURATION");
444        if (durationProperty != null) {
445            // just return the duration
446            return durationProperty.getValue();
447        }
448
449        // must compute a duration from the DTEND
450        ICalendar.Property dtendProperty =
451                component.getFirstProperty("DTEND");
452        if (dtendProperty == null) {
453            // no DURATION, no DTEND: 0 second duration
454            return "+P0S";
455        }
456        ICalendar.Parameter endTzidParameter =
457                dtendProperty.getFirstParameter("TZID");
458        String endTzid = (endTzidParameter == null)
459                ? start.timezone : endTzidParameter.value;
460
461        Time end = new Time(endTzid);
462        end.parse(dtendProperty.getValue());
463        long durationMillis = end.toMillis(false /* use isDst */)
464                - start.toMillis(false /* use isDst */);
465        long durationSeconds = (durationMillis / 1000);
466        if (start.allDay && (durationSeconds % 86400) == 0) {
467            return "P" + (durationSeconds / 86400) + "D"; // Server wants this instead of P86400S
468        } else {
469            return "P" + durationSeconds + "S";
470        }
471    }
472
473    private static String flattenProperties(ICalendar.Component component,
474                                            String name) {
475        List<ICalendar.Property> properties = component.getProperties(name);
476        if (properties == null || properties.isEmpty()) {
477            return null;
478        }
479
480        if (properties.size() == 1) {
481            return properties.get(0).getValue();
482        }
483
484        StringBuilder sb = new StringBuilder();
485
486        boolean first = true;
487        for (ICalendar.Property property : component.getProperties(name)) {
488            if (first) {
489                first = false;
490            } else {
491                // TODO: use commas.  our RECUR parsing should handle that
492                // anyway.
493                sb.append(RULE_SEPARATOR);
494            }
495            sb.append(property.getValue());
496        }
497        return sb.toString();
498    }
499
500    private static String extractDates(ICalendar.Property recurrence) {
501        if (recurrence == null) {
502            return null;
503        }
504        ICalendar.Parameter tzidParam =
505                recurrence.getFirstParameter("TZID");
506        if (tzidParam != null) {
507            return tzidParam.value + ";" + recurrence.getValue();
508        }
509        return recurrence.getValue();
510    }
511}
512