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 */ 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 */ 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 84 public RecurrenceSet(String rruleStr, String rdateStr, 85 String exruleStr, String exdateStr) 86 throws EventRecurrence.InvalidFormatException { 87 init(rruleStr, rdateStr, exruleStr, exdateStr); 88 } 89 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 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 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 */ 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 */ 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 */ 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. 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 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 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 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 */ 433 public static String fold(String unfoldedIcalContent) { 434 return FOLD_RE.matcher(unfoldedIcalContent).replaceAll("$0\r\n "); 435 } 436 437 public static String unfold(String foldedIcalContent) { 438 return IGNORABLE_ICAL_WHITESPACE_RE.matcher( 439 foldedIcalContent).replaceAll(""); 440 } 441 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 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 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 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