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