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