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