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