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