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