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