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