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