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