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