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