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