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