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