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.calendarcommon; 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 */ 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 */ 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 83 public RecurrenceSet(String rruleStr, String rdateStr, 84 String exruleStr, String exdateStr) 85 throws EventRecurrence.InvalidFormatException { 86 init(rruleStr, rdateStr, exruleStr, exdateStr); 87 } 88 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 */ 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 */ 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 */ 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. 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 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 String duration = values.getAsString(CalendarContract.Events.DURATION); 321 String tzid = values.getAsString(CalendarContract.Events.EVENT_TIMEZONE); 322 String rruleStr = values.getAsString(CalendarContract.Events.RRULE); 323 String rdateStr = values.getAsString(CalendarContract.Events.RDATE); 324 String exruleStr = values.getAsString(CalendarContract.Events.EXRULE); 325 String exdateStr = values.getAsString(CalendarContract.Events.EXDATE); 326 Integer allDayInteger = values.getAsInteger(CalendarContract.Events.ALL_DAY); 327 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 373 private 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 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 */ 424 public static String fold(String unfoldedIcalContent) { 425 return FOLD_RE.matcher(unfoldedIcalContent).replaceAll("$0\r\n "); 426 } 427 428 public static String unfold(String foldedIcalContent) { 429 return IGNORABLE_ICAL_WHITESPACE_RE.matcher( 430 foldedIcalContent).replaceAll(""); 431 } 432 433 private 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 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 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 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