/*
* Copyright (C) 2007 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.calendarcommon2;
import android.content.ContentValues;
import android.database.Cursor;
import android.provider.CalendarContract;
import android.text.TextUtils;
import android.text.format.Time;
import android.util.Log;
import android.util.TimeFormatException;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Pattern;
/**
* Basic information about a recurrence, following RFC 2445 Section 4.8.5.
* Contains the RRULEs, RDATE, EXRULEs, and EXDATE properties.
*/
public class RecurrenceSet {
private final static String TAG = "RecurrenceSet";
private final static String RULE_SEPARATOR = "\n";
private final static String FOLDING_SEPARATOR = "\n ";
// TODO: make these final?
public EventRecurrence[] rrules = null;
public long[] rdates = null;
public EventRecurrence[] exrules = null;
public long[] exdates = null;
/**
* Creates a new RecurrenceSet from information stored in the
* events table in the CalendarProvider.
* @param values The values retrieved from the Events table.
*/
public RecurrenceSet(ContentValues values)
throws EventRecurrence.InvalidFormatException {
String rruleStr = values.getAsString(CalendarContract.Events.RRULE);
String rdateStr = values.getAsString(CalendarContract.Events.RDATE);
String exruleStr = values.getAsString(CalendarContract.Events.EXRULE);
String exdateStr = values.getAsString(CalendarContract.Events.EXDATE);
init(rruleStr, rdateStr, exruleStr, exdateStr);
}
/**
* Creates a new RecurrenceSet from information stored in a database
* {@link Cursor} pointing to the events table in the
* CalendarProvider. The cursor must contain the RRULE, RDATE, EXRULE,
* and EXDATE columns.
*
* @param cursor The cursor containing the RRULE, RDATE, EXRULE, and EXDATE
* columns.
*/
public RecurrenceSet(Cursor cursor)
throws EventRecurrence.InvalidFormatException {
int rruleColumn = cursor.getColumnIndex(CalendarContract.Events.RRULE);
int rdateColumn = cursor.getColumnIndex(CalendarContract.Events.RDATE);
int exruleColumn = cursor.getColumnIndex(CalendarContract.Events.EXRULE);
int exdateColumn = cursor.getColumnIndex(CalendarContract.Events.EXDATE);
String rruleStr = cursor.getString(rruleColumn);
String rdateStr = cursor.getString(rdateColumn);
String exruleStr = cursor.getString(exruleColumn);
String exdateStr = cursor.getString(exdateColumn);
init(rruleStr, rdateStr, exruleStr, exdateStr);
}
public RecurrenceSet(String rruleStr, String rdateStr,
String exruleStr, String exdateStr)
throws EventRecurrence.InvalidFormatException {
init(rruleStr, rdateStr, exruleStr, exdateStr);
}
private void init(String rruleStr, String rdateStr,
String exruleStr, String exdateStr)
throws EventRecurrence.InvalidFormatException {
if (!TextUtils.isEmpty(rruleStr) || !TextUtils.isEmpty(rdateStr)) {
if (!TextUtils.isEmpty(rruleStr)) {
String[] rruleStrs = rruleStr.split(RULE_SEPARATOR);
rrules = new EventRecurrence[rruleStrs.length];
for (int i = 0; i < rruleStrs.length; ++i) {
EventRecurrence rrule = new EventRecurrence();
rrule.parse(rruleStrs[i]);
rrules[i] = rrule;
}
}
if (!TextUtils.isEmpty(rdateStr)) {
rdates = parseRecurrenceDates(rdateStr);
}
if (!TextUtils.isEmpty(exruleStr)) {
String[] exruleStrs = exruleStr.split(RULE_SEPARATOR);
exrules = new EventRecurrence[exruleStrs.length];
for (int i = 0; i < exruleStrs.length; ++i) {
EventRecurrence exrule = new EventRecurrence();
exrule.parse(exruleStr);
exrules[i] = exrule;
}
}
if (!TextUtils.isEmpty(exdateStr)) {
final List<Long> list = new ArrayList<Long>();
for (String exdate : exdateStr.split(RULE_SEPARATOR)) {
final long[] dates = parseRecurrenceDates(exdate);
for (long date : dates) {
list.add(date);
}
}
exdates = new long[list.size()];
for (int i = 0, n = list.size(); i < n; i++) {
exdates[i] = list.get(i);
}
}
}
}
/**
* Returns whether or not a recurrence is defined in this RecurrenceSet.
* @return Whether or not a recurrence is defined in this RecurrenceSet.
*/
public boolean hasRecurrence() {
return (rrules != null || rdates != null);
}
/**
* Parses the provided RDATE or EXDATE string into an array of longs
* representing each date/time in the recurrence.
* @param recurrence The recurrence to be parsed.
* @return The list of date/times.
*/
public static long[] parseRecurrenceDates(String recurrence)
throws EventRecurrence.InvalidFormatException{
// TODO: use "local" time as the default. will need to handle times
// that end in "z" (UTC time) explicitly at that point.
String tz = Time.TIMEZONE_UTC;
int tzidx = recurrence.indexOf(";");
if (tzidx != -1) {
tz = recurrence.substring(0, tzidx);
recurrence = recurrence.substring(tzidx + 1);
}
Time time = new Time(tz);
String[] rawDates = recurrence.split(",");
int n = rawDates.length;
long[] dates = new long[n];
for (int i = 0; i<n; ++i) {
// The timezone is updated to UTC if the time string specified 'Z'.
try {
time.parse(rawDates[i]);
} catch (TimeFormatException e) {
throw new EventRecurrence.InvalidFormatException(
"TimeFormatException thrown when parsing time " + rawDates[i]
+ " in recurrence " + recurrence);
}
dates[i] = time.toMillis(false /* use isDst */);
time.timezone = tz;
}
return dates;
}
/**
* Populates the database map of values with the appropriate RRULE, RDATE,
* EXRULE, and EXDATE values extracted from the parsed iCalendar component.
* @param component The iCalendar component containing the desired
* recurrence specification.
* @param values The db values that should be updated.
* @return true if the component contained the necessary information
* to specify a recurrence. The required fields are DTSTART,
* one of DTEND/DURATION, and one of RRULE/RDATE. Returns false if
* there was an error, including if the date is out of range.
*/
public static boolean populateContentValues(ICalendar.Component component,
ContentValues values) {
try {
ICalendar.Property dtstartProperty =
component.getFirstProperty("DTSTART");
String dtstart = dtstartProperty.getValue();
ICalendar.Parameter tzidParam =
dtstartProperty.getFirstParameter("TZID");
// NOTE: the timezone may be null, if this is a floating time.
String tzid = tzidParam == null ? null : tzidParam.value;
Time start = new Time(tzidParam == null ? Time.TIMEZONE_UTC : tzid);
boolean inUtc = start.parse(dtstart);
boolean allDay = start.allDay;
// We force TimeZone to UTC for "all day recurring events" as the server is sending no
// TimeZone in DTSTART for them
if (inUtc || allDay) {
tzid = Time.TIMEZONE_UTC;
}
String duration = computeDuration(start, component);
String rrule = flattenProperties(component, "RRULE");
String rdate = extractDates(component.getFirstProperty("RDATE"));
String exrule = flattenProperties(component, "EXRULE");
String exdate = extractDates(component.getFirstProperty("EXDATE"));
if ((TextUtils.isEmpty(dtstart))||
(TextUtils.isEmpty(duration))||
((TextUtils.isEmpty(rrule))&&
(TextUtils.isEmpty(rdate)))) {
if (false) {
Log.d(TAG, "Recurrence missing DTSTART, DTEND/DURATION, "
+ "or RRULE/RDATE: "
+ component.toString());
}
return false;
}
if (allDay) {
start.timezone = Time.TIMEZONE_UTC;
}
long millis = start.toMillis(false /* use isDst */);
values.put(CalendarContract.Events.DTSTART, millis);
if (millis == -1) {
if (false) {
Log.d(TAG, "DTSTART is out of range: " + component.toString());
}
return false;
}
values.put(CalendarContract.Events.RRULE, rrule);
values.put(CalendarContract.Events.RDATE, rdate);
values.put(CalendarContract.Events.EXRULE, exrule);
values.put(CalendarContract.Events.EXDATE, exdate);
values.put(CalendarContract.Events.EVENT_TIMEZONE, tzid);
values.put(CalendarContract.Events.DURATION, duration);
values.put(CalendarContract.Events.ALL_DAY, allDay ? 1 : 0);
return true;
} catch (TimeFormatException e) {
// Something is wrong with the format of this event
Log.i(TAG,"Failed to parse event: " + component.toString());
return false;
}
}
// This can be removed when the old CalendarSyncAdapter is removed.
public static boolean populateComponent(Cursor cursor,
ICalendar.Component component) {
int dtstartColumn = cursor.getColumnIndex(CalendarContract.Events.DTSTART);
int durationColumn = cursor.getColumnIndex(CalendarContract.Events.DURATION);
int tzidColumn = cursor.getColumnIndex(CalendarContract.Events.EVENT_TIMEZONE);
int rruleColumn = cursor.getColumnIndex(CalendarContract.Events.RRULE);
int rdateColumn = cursor.getColumnIndex(CalendarContract.Events.RDATE);
int exruleColumn = cursor.getColumnIndex(CalendarContract.Events.EXRULE);
int exdateColumn = cursor.getColumnIndex(CalendarContract.Events.EXDATE);
int allDayColumn = cursor.getColumnIndex(CalendarContract.Events.ALL_DAY);
long dtstart = -1;
if (!cursor.isNull(dtstartColumn)) {
dtstart = cursor.getLong(dtstartColumn);
}
String duration = cursor.getString(durationColumn);
String tzid = cursor.getString(tzidColumn);
String rruleStr = cursor.getString(rruleColumn);
String rdateStr = cursor.getString(rdateColumn);
String exruleStr = cursor.getString(exruleColumn);
String exdateStr = cursor.getString(exdateColumn);
boolean allDay = cursor.getInt(allDayColumn) == 1;
if ((dtstart == -1) ||
(TextUtils.isEmpty(duration))||
((TextUtils.isEmpty(rruleStr))&&
(TextUtils.isEmpty(rdateStr)))) {
// no recurrence.
return false;
}
ICalendar.Property dtstartProp = new ICalendar.Property("DTSTART");
Time dtstartTime = null;
if (!TextUtils.isEmpty(tzid)) {
if (!allDay) {
dtstartProp.addParameter(new ICalendar.Parameter("TZID", tzid));
}
dtstartTime = new Time(tzid);
} else {
// use the "floating" timezone
dtstartTime = new Time(Time.TIMEZONE_UTC);
}
dtstartTime.set(dtstart);
// make sure the time is printed just as a date, if all day.
// TODO: android.pim.Time really should take care of this for us.
if (allDay) {
dtstartProp.addParameter(new ICalendar.Parameter("VALUE", "DATE"));
dtstartTime.allDay = true;
dtstartTime.hour = 0;
dtstartTime.minute = 0;
dtstartTime.second = 0;
}
dtstartProp.setValue(dtstartTime.format2445());
component.addProperty(dtstartProp);
ICalendar.Property durationProp = new ICalendar.Property("DURATION");
durationProp.setValue(duration);
component.addProperty(durationProp);
addPropertiesForRuleStr(component, "RRULE", rruleStr);
addPropertyForDateStr(component, "RDATE", rdateStr);
addPropertiesForRuleStr(component, "EXRULE", exruleStr);
addPropertyForDateStr(component, "EXDATE", exdateStr);
return true;
}
public static boolean populateComponent(ContentValues values,
ICalendar.Component component) {
long dtstart = -1;
if (values.containsKey(CalendarContract.Events.DTSTART)) {
dtstart = values.getAsLong(CalendarContract.Events.DTSTART);
}
final String duration = values.getAsString(CalendarContract.Events.DURATION);
final String tzid = values.getAsString(CalendarContract.Events.EVENT_TIMEZONE);
final String rruleStr = values.getAsString(CalendarContract.Events.RRULE);
final String rdateStr = values.getAsString(CalendarContract.Events.RDATE);
final String exruleStr = values.getAsString(CalendarContract.Events.EXRULE);
final String exdateStr = values.getAsString(CalendarContract.Events.EXDATE);
final Integer allDayInteger = values.getAsInteger(CalendarContract.Events.ALL_DAY);
final boolean allDay = (null != allDayInteger) ? (allDayInteger == 1) : false;
if ((dtstart == -1) ||
(TextUtils.isEmpty(duration))||
((TextUtils.isEmpty(rruleStr))&&
(TextUtils.isEmpty(rdateStr)))) {
// no recurrence.
return false;
}
ICalendar.Property dtstartProp = new ICalendar.Property("DTSTART");
Time dtstartTime = null;
if (!TextUtils.isEmpty(tzid)) {
if (!allDay) {
dtstartProp.addParameter(new ICalendar.Parameter("TZID", tzid));
}
dtstartTime = new Time(tzid);
} else {
// use the "floating" timezone
dtstartTime = new Time(Time.TIMEZONE_UTC);
}
dtstartTime.set(dtstart);
// make sure the time is printed just as a date, if all day.
// TODO: android.pim.Time really should take care of this for us.
if (allDay) {
dtstartProp.addParameter(new ICalendar.Parameter("VALUE", "DATE"));
dtstartTime.allDay = true;
dtstartTime.hour = 0;
dtstartTime.minute = 0;
dtstartTime.second = 0;
}
dtstartProp.setValue(dtstartTime.format2445());
component.addProperty(dtstartProp);
ICalendar.Property durationProp = new ICalendar.Property("DURATION");
durationProp.setValue(duration);
component.addProperty(durationProp);
addPropertiesForRuleStr(component, "RRULE", rruleStr);
addPropertyForDateStr(component, "RDATE", rdateStr);
addPropertiesForRuleStr(component, "EXRULE", exruleStr);
addPropertyForDateStr(component, "EXDATE", exdateStr);
return true;
}
public static void addPropertiesForRuleStr(ICalendar.Component component,
String propertyName,
String ruleStr) {
if (TextUtils.isEmpty(ruleStr)) {
return;
}
String[] rrules = getRuleStrings(ruleStr);
for (String rrule : rrules) {
ICalendar.Property prop = new ICalendar.Property(propertyName);
prop.setValue(rrule);
component.addProperty(prop);
}
}
private static String[] getRuleStrings(String ruleStr) {
if (null == ruleStr) {
return new String[0];
}
String unfoldedRuleStr = unfold(ruleStr);
String[] split = unfoldedRuleStr.split(RULE_SEPARATOR);
int count = split.length;
for (int n = 0; n < count; n++) {
split[n] = fold(split[n]);
}
return split;
}
private static final Pattern IGNORABLE_ICAL_WHITESPACE_RE =
Pattern.compile("(?:\\r\\n?|\\n)[ \t]");
private static final Pattern FOLD_RE = Pattern.compile(".{75}");
/**
* fold and unfolds ical content lines as per RFC 2445 section 4.1.
*
* <h3>4.1 Content Lines</h3>
*
* <p>The iCalendar object is organized into individual lines of text, called
* content lines. Content lines are delimited by a line break, which is a CRLF
* sequence (US-ASCII decimal 13, followed by US-ASCII decimal 10).
*
* <p>Lines of text SHOULD NOT be longer than 75 octets, excluding the line
* break. Long content lines SHOULD be split into a multiple line
* representations using a line "folding" technique. That is, a long line can
* be split between any two characters by inserting a CRLF immediately
* followed by a single linear white space character (i.e., SPACE, US-ASCII
* decimal 32 or HTAB, US-ASCII decimal 9). Any sequence of CRLF followed
* immediately by a single linear white space character is ignored (i.e.,
* removed) when processing the content type.
*/
public static String fold(String unfoldedIcalContent) {
return FOLD_RE.matcher(unfoldedIcalContent).replaceAll("$0\r\n ");
}
public static String unfold(String foldedIcalContent) {
return IGNORABLE_ICAL_WHITESPACE_RE.matcher(
foldedIcalContent).replaceAll("");
}
public static void addPropertyForDateStr(ICalendar.Component component,
String propertyName,
String dateStr) {
if (TextUtils.isEmpty(dateStr)) {
return;
}
ICalendar.Property prop = new ICalendar.Property(propertyName);
String tz = null;
int tzidx = dateStr.indexOf(";");
if (tzidx != -1) {
tz = dateStr.substring(0, tzidx);
dateStr = dateStr.substring(tzidx + 1);
}
if (!TextUtils.isEmpty(tz)) {
prop.addParameter(new ICalendar.Parameter("TZID", tz));
}
prop.setValue(dateStr);
component.addProperty(prop);
}
private static String computeDuration(Time start,
ICalendar.Component component) {
// see if a duration is defined
ICalendar.Property durationProperty =
component.getFirstProperty("DURATION");
if (durationProperty != null) {
// just return the duration
return durationProperty.getValue();
}
// must compute a duration from the DTEND
ICalendar.Property dtendProperty =
component.getFirstProperty("DTEND");
if (dtendProperty == null) {
// no DURATION, no DTEND: 0 second duration
return "+P0S";
}
ICalendar.Parameter endTzidParameter =
dtendProperty.getFirstParameter("TZID");
String endTzid = (endTzidParameter == null)
? start.timezone : endTzidParameter.value;
Time end = new Time(endTzid);
end.parse(dtendProperty.getValue());
long durationMillis = end.toMillis(false /* use isDst */)
- start.toMillis(false /* use isDst */);
long durationSeconds = (durationMillis / 1000);
if (start.allDay && (durationSeconds % 86400) == 0) {
return "P" + (durationSeconds / 86400) + "D"; // Server wants this instead of P86400S
} else {
return "P" + durationSeconds + "S";
}
}
private static String flattenProperties(ICalendar.Component component,
String name) {
List<ICalendar.Property> properties = component.getProperties(name);
if (properties == null || properties.isEmpty()) {
return null;
}
if (properties.size() == 1) {
return properties.get(0).getValue();
}
StringBuilder sb = new StringBuilder();
boolean first = true;
for (ICalendar.Property property : component.getProperties(name)) {
if (first) {
first = false;
} else {
// TODO: use commas. our RECUR parsing should handle that
// anyway.
sb.append(RULE_SEPARATOR);
}
sb.append(property.getValue());
}
return sb.toString();
}
private static String extractDates(ICalendar.Property recurrence) {
if (recurrence == null) {
return null;
}
ICalendar.Parameter tzidParam =
recurrence.getFirstParameter("TZID");
if (tzidParam != null) {
return tzidParam.value + ";" + recurrence.getValue();
}
return recurrence.getValue();
}
}