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