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