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