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