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 
17 package com.android.calendarcommon2;
18 
19 import android.content.ContentValues;
20 import android.database.Cursor;
21 import android.provider.CalendarContract;
22 import android.text.TextUtils;
23 import android.text.format.Time;
24 import android.util.Log;
25 import android.util.TimeFormatException;
26 
27 import java.util.ArrayList;
28 import java.util.List;
29 import 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  */
35 public 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      */
RecurrenceSet(ContentValues values)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      */
RecurrenceSet(Cursor cursor)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 
RecurrenceSet(String rruleStr, String rdateStr, String exruleStr, String exdateStr)84     public RecurrenceSet(String rruleStr, String rdateStr,
85                   String exruleStr, String exdateStr)
86             throws EventRecurrence.InvalidFormatException {
87         init(rruleStr, rdateStr, exruleStr, exdateStr);
88     }
89 
init(String rruleStr, String rdateStr, String exruleStr, String exdateStr)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 
parseMultiLineRecurrenceRules(String ruleStr)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 
parseMultiLineRecurrenceDates(String dateStr)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      */
hasRecurrence()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      */
parseRecurrenceDates(String recurrence)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      */
populateContentValues(ICalendar.Component component, ContentValues values)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.
populateComponent(Cursor cursor, ICalendar.Component component)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 
populateComponent(ContentValues values, ICalendar.Component component)323 public 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 
addPropertiesForRuleStr(ICalendar.Component component, String propertyName, String ruleStr)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 
getRuleStrings(String ruleStr)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     */
fold(String unfoldedIcalContent)433     public static String fold(String unfoldedIcalContent) {
434         return FOLD_RE.matcher(unfoldedIcalContent).replaceAll("$0\r\n ");
435     }
436 
unfold(String foldedIcalContent)437     public static String unfold(String foldedIcalContent) {
438         return IGNORABLE_ICAL_WHITESPACE_RE.matcher(
439             foldedIcalContent).replaceAll("");
440     }
441 
addPropertyForDateStr(ICalendar.Component component, String propertyName, String dateStr)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 
computeDuration(Time start, ICalendar.Component component)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 
flattenProperties(ICalendar.Component component, String name)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 
extractDates(ICalendar.Property recurrence)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