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 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 */ 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 */ 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 */ 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. 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 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 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 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 */ 435 public static String fold(String unfoldedIcalContent) { 436 return FOLD_RE.matcher(unfoldedIcalContent).replaceAll("$0\r\n "); 437 } 438 439 public static String unfold(String foldedIcalContent) { 440 return IGNORABLE_ICAL_WHITESPACE_RE.matcher( 441 foldedIcalContent).replaceAll(""); 442 } 443 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 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 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 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