Home | History | Annotate | Download | only in calendarcommon2
      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.calendarcommon2;
     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.ArrayList;
     28 import java.util.List;
     29 import java.util.regex.Pattern;
     30 
     31 /**
     32  * Basic information about a recurrence, following RFC 2445 Section 4.8.5.
     33  * Contains the RRULEs, RDATE, EXRULEs, and EXDATE properties.
     34  */
     35 public class RecurrenceSet {
     36 
     37     private final static String TAG = "RecurrenceSet";
     38 
     39     private final static String RULE_SEPARATOR = "\n";
     40     private final static String FOLDING_SEPARATOR = "\n ";
     41 
     42     // TODO: make these final?
     43     public EventRecurrence[] rrules = null;
     44     public long[] rdates = null;
     45     public EventRecurrence[] exrules = null;
     46     public long[] exdates = null;
     47 
     48     /**
     49      * Creates a new RecurrenceSet from information stored in the
     50      * events table in the CalendarProvider.
     51      * @param values The values retrieved from the Events table.
     52      */
     53     public RecurrenceSet(ContentValues values)
     54             throws EventRecurrence.InvalidFormatException {
     55         String rruleStr = values.getAsString(CalendarContract.Events.RRULE);
     56         String rdateStr = values.getAsString(CalendarContract.Events.RDATE);
     57         String exruleStr = values.getAsString(CalendarContract.Events.EXRULE);
     58         String exdateStr = values.getAsString(CalendarContract.Events.EXDATE);
     59         init(rruleStr, rdateStr, exruleStr, exdateStr);
     60     }
     61 
     62     /**
     63      * Creates a new RecurrenceSet from information stored in a database
     64      * {@link Cursor} pointing to the events table in the
     65      * CalendarProvider.  The cursor must contain the RRULE, RDATE, EXRULE,
     66      * and EXDATE columns.
     67      *
     68      * @param cursor The cursor containing the RRULE, RDATE, EXRULE, and EXDATE
     69      * columns.
     70      */
     71     public RecurrenceSet(Cursor cursor)
     72             throws EventRecurrence.InvalidFormatException {
     73         int rruleColumn = cursor.getColumnIndex(CalendarContract.Events.RRULE);
     74         int rdateColumn = cursor.getColumnIndex(CalendarContract.Events.RDATE);
     75         int exruleColumn = cursor.getColumnIndex(CalendarContract.Events.EXRULE);
     76         int exdateColumn = cursor.getColumnIndex(CalendarContract.Events.EXDATE);
     77         String rruleStr = cursor.getString(rruleColumn);
     78         String rdateStr = cursor.getString(rdateColumn);
     79         String exruleStr = cursor.getString(exruleColumn);
     80         String exdateStr = cursor.getString(exdateColumn);
     81         init(rruleStr, rdateStr, exruleStr, exdateStr);
     82     }
     83 
     84     public RecurrenceSet(String rruleStr, String rdateStr,
     85                   String exruleStr, String exdateStr)
     86             throws EventRecurrence.InvalidFormatException {
     87         init(rruleStr, rdateStr, exruleStr, exdateStr);
     88     }
     89 
     90     private void init(String rruleStr, String rdateStr,
     91                       String exruleStr, String exdateStr)
     92             throws EventRecurrence.InvalidFormatException {
     93         if (!TextUtils.isEmpty(rruleStr) || !TextUtils.isEmpty(rdateStr)) {
     94 
     95             if (!TextUtils.isEmpty(rruleStr)) {
     96                 String[] rruleStrs = rruleStr.split(RULE_SEPARATOR);
     97                 rrules = new EventRecurrence[rruleStrs.length];
     98                 for (int i = 0; i < rruleStrs.length; ++i) {
     99                     EventRecurrence rrule = new EventRecurrence();
    100                     rrule.parse(rruleStrs[i]);
    101                     rrules[i] = rrule;
    102                 }
    103             }
    104 
    105             if (!TextUtils.isEmpty(rdateStr)) {
    106                 rdates = parseRecurrenceDates(rdateStr);
    107             }
    108 
    109             if (!TextUtils.isEmpty(exruleStr)) {
    110                 String[] exruleStrs = exruleStr.split(RULE_SEPARATOR);
    111                 exrules = new EventRecurrence[exruleStrs.length];
    112                 for (int i = 0; i < exruleStrs.length; ++i) {
    113                     EventRecurrence exrule = new EventRecurrence();
    114                     exrule.parse(exruleStr);
    115                     exrules[i] = exrule;
    116                 }
    117             }
    118 
    119             if (!TextUtils.isEmpty(exdateStr)) {
    120                 final List<Long> list = new ArrayList<Long>();
    121                 for (String exdate : exdateStr.split(RULE_SEPARATOR)) {
    122                     final long[] dates = parseRecurrenceDates(exdate);
    123                     for (long date : dates) {
    124                         list.add(date);
    125                     }
    126                 }
    127                 exdates = new long[list.size()];
    128                 for (int i = 0, n = list.size(); i < n; i++) {
    129                     exdates[i] = list.get(i);
    130                 }
    131             }
    132         }
    133     }
    134 
    135     /**
    136      * Returns whether or not a recurrence is defined in this RecurrenceSet.
    137      * @return Whether or not a recurrence is defined in this RecurrenceSet.
    138      */
    139     public boolean hasRecurrence() {
    140         return (rrules != null || rdates != null);
    141     }
    142 
    143     /**
    144      * Parses the provided RDATE or EXDATE string into an array of longs
    145      * representing each date/time in the recurrence.
    146      * @param recurrence The recurrence to be parsed.
    147      * @return The list of date/times.
    148      */
    149     public static long[] parseRecurrenceDates(String recurrence)
    150             throws EventRecurrence.InvalidFormatException{
    151         // TODO: use "local" time as the default.  will need to handle times
    152         // that end in "z" (UTC time) explicitly at that point.
    153         String tz = Time.TIMEZONE_UTC;
    154         int tzidx = recurrence.indexOf(";");
    155         if (tzidx != -1) {
    156             tz = recurrence.substring(0, tzidx);
    157             recurrence = recurrence.substring(tzidx + 1);
    158         }
    159         Time time = new Time(tz);
    160         String[] rawDates = recurrence.split(",");
    161         int n = rawDates.length;
    162         long[] dates = new long[n];
    163         for (int i = 0; i<n; ++i) {
    164             // The timezone is updated to UTC if the time string specified 'Z'.
    165             try {
    166                 time.parse(rawDates[i]);
    167             } catch (TimeFormatException e) {
    168                 throw new EventRecurrence.InvalidFormatException(
    169                         "TimeFormatException thrown when parsing time " + rawDates[i]
    170                                 + " in recurrence " + recurrence);
    171 
    172             }
    173             dates[i] = time.toMillis(false /* use isDst */);
    174             time.timezone = tz;
    175         }
    176         return dates;
    177     }
    178 
    179     /**
    180      * Populates the database map of values with the appropriate RRULE, RDATE,
    181      * EXRULE, and EXDATE values extracted from the parsed iCalendar component.
    182      * @param component The iCalendar component containing the desired
    183      * recurrence specification.
    184      * @param values The db values that should be updated.
    185      * @return true if the component contained the necessary information
    186      * to specify a recurrence.  The required fields are DTSTART,
    187      * one of DTEND/DURATION, and one of RRULE/RDATE.  Returns false if
    188      * there was an error, including if the date is out of range.
    189      */
    190     public static boolean populateContentValues(ICalendar.Component component,
    191             ContentValues values) {
    192         try {
    193             ICalendar.Property dtstartProperty =
    194                     component.getFirstProperty("DTSTART");
    195             String dtstart = dtstartProperty.getValue();
    196             ICalendar.Parameter tzidParam =
    197                     dtstartProperty.getFirstParameter("TZID");
    198             // NOTE: the timezone may be null, if this is a floating time.
    199             String tzid = tzidParam == null ? null : tzidParam.value;
    200             Time start = new Time(tzidParam == null ? Time.TIMEZONE_UTC : tzid);
    201             boolean inUtc = start.parse(dtstart);
    202             boolean allDay = start.allDay;
    203 
    204             // We force TimeZone to UTC for "all day recurring events" as the server is sending no
    205             // TimeZone in DTSTART for them
    206             if (inUtc || allDay) {
    207                 tzid = Time.TIMEZONE_UTC;
    208             }
    209 
    210             String duration = computeDuration(start, component);
    211             String rrule = flattenProperties(component, "RRULE");
    212             String rdate = extractDates(component.getFirstProperty("RDATE"));
    213             String exrule = flattenProperties(component, "EXRULE");
    214             String exdate = extractDates(component.getFirstProperty("EXDATE"));
    215 
    216             if ((TextUtils.isEmpty(dtstart))||
    217                     (TextUtils.isEmpty(duration))||
    218                     ((TextUtils.isEmpty(rrule))&&
    219                             (TextUtils.isEmpty(rdate)))) {
    220                     if (false) {
    221                         Log.d(TAG, "Recurrence missing DTSTART, DTEND/DURATION, "
    222                                     + "or RRULE/RDATE: "
    223                                     + component.toString());
    224                     }
    225                     return false;
    226             }
    227 
    228             if (allDay) {
    229                 start.timezone = Time.TIMEZONE_UTC;
    230             }
    231             long millis = start.toMillis(false /* use isDst */);
    232             values.put(CalendarContract.Events.DTSTART, millis);
    233             if (millis == -1) {
    234                 if (false) {
    235                     Log.d(TAG, "DTSTART is out of range: " + component.toString());
    236                 }
    237                 return false;
    238             }
    239 
    240             values.put(CalendarContract.Events.RRULE, rrule);
    241             values.put(CalendarContract.Events.RDATE, rdate);
    242             values.put(CalendarContract.Events.EXRULE, exrule);
    243             values.put(CalendarContract.Events.EXDATE, exdate);
    244             values.put(CalendarContract.Events.EVENT_TIMEZONE, tzid);
    245             values.put(CalendarContract.Events.DURATION, duration);
    246             values.put(CalendarContract.Events.ALL_DAY, allDay ? 1 : 0);
    247             return true;
    248         } catch (TimeFormatException e) {
    249             // Something is wrong with the format of this event
    250             Log.i(TAG,"Failed to parse event: " + component.toString());
    251             return false;
    252         }
    253     }
    254 
    255     // This can be removed when the old CalendarSyncAdapter is removed.
    256     public static boolean populateComponent(Cursor cursor,
    257                                             ICalendar.Component component) {
    258 
    259         int dtstartColumn = cursor.getColumnIndex(CalendarContract.Events.DTSTART);
    260         int durationColumn = cursor.getColumnIndex(CalendarContract.Events.DURATION);
    261         int tzidColumn = cursor.getColumnIndex(CalendarContract.Events.EVENT_TIMEZONE);
    262         int rruleColumn = cursor.getColumnIndex(CalendarContract.Events.RRULE);
    263         int rdateColumn = cursor.getColumnIndex(CalendarContract.Events.RDATE);
    264         int exruleColumn = cursor.getColumnIndex(CalendarContract.Events.EXRULE);
    265         int exdateColumn = cursor.getColumnIndex(CalendarContract.Events.EXDATE);
    266         int allDayColumn = cursor.getColumnIndex(CalendarContract.Events.ALL_DAY);
    267 
    268 
    269         long dtstart = -1;
    270         if (!cursor.isNull(dtstartColumn)) {
    271             dtstart = cursor.getLong(dtstartColumn);
    272         }
    273         String duration = cursor.getString(durationColumn);
    274         String tzid = cursor.getString(tzidColumn);
    275         String rruleStr = cursor.getString(rruleColumn);
    276         String rdateStr = cursor.getString(rdateColumn);
    277         String exruleStr = cursor.getString(exruleColumn);
    278         String exdateStr = cursor.getString(exdateColumn);
    279         boolean allDay = cursor.getInt(allDayColumn) == 1;
    280 
    281         if ((dtstart == -1) ||
    282             (TextUtils.isEmpty(duration))||
    283             ((TextUtils.isEmpty(rruleStr))&&
    284                 (TextUtils.isEmpty(rdateStr)))) {
    285                 // no recurrence.
    286                 return false;
    287         }
    288 
    289         ICalendar.Property dtstartProp = new ICalendar.Property("DTSTART");
    290         Time dtstartTime = null;
    291         if (!TextUtils.isEmpty(tzid)) {
    292             if (!allDay) {
    293                 dtstartProp.addParameter(new ICalendar.Parameter("TZID", tzid));
    294             }
    295             dtstartTime = new Time(tzid);
    296         } else {
    297             // use the "floating" timezone
    298             dtstartTime = new Time(Time.TIMEZONE_UTC);
    299         }
    300 
    301         dtstartTime.set(dtstart);
    302         // make sure the time is printed just as a date, if all day.
    303         // TODO: android.pim.Time really should take care of this for us.
    304         if (allDay) {
    305             dtstartProp.addParameter(new ICalendar.Parameter("VALUE", "DATE"));
    306             dtstartTime.allDay = true;
    307             dtstartTime.hour = 0;
    308             dtstartTime.minute = 0;
    309             dtstartTime.second = 0;
    310         }
    311 
    312         dtstartProp.setValue(dtstartTime.format2445());
    313         component.addProperty(dtstartProp);
    314         ICalendar.Property durationProp = new ICalendar.Property("DURATION");
    315         durationProp.setValue(duration);
    316         component.addProperty(durationProp);
    317 
    318         addPropertiesForRuleStr(component, "RRULE", rruleStr);
    319         addPropertyForDateStr(component, "RDATE", rdateStr);
    320         addPropertiesForRuleStr(component, "EXRULE", exruleStr);
    321         addPropertyForDateStr(component, "EXDATE", exdateStr);
    322         return true;
    323     }
    324 
    325 public static boolean populateComponent(ContentValues values,
    326                                             ICalendar.Component component) {
    327         long dtstart = -1;
    328         if (values.containsKey(CalendarContract.Events.DTSTART)) {
    329             dtstart = values.getAsLong(CalendarContract.Events.DTSTART);
    330         }
    331         final String duration = values.getAsString(CalendarContract.Events.DURATION);
    332         final String tzid = values.getAsString(CalendarContract.Events.EVENT_TIMEZONE);
    333         final String rruleStr = values.getAsString(CalendarContract.Events.RRULE);
    334         final String rdateStr = values.getAsString(CalendarContract.Events.RDATE);
    335         final String exruleStr = values.getAsString(CalendarContract.Events.EXRULE);
    336         final String exdateStr = values.getAsString(CalendarContract.Events.EXDATE);
    337         final Integer allDayInteger = values.getAsInteger(CalendarContract.Events.ALL_DAY);
    338         final boolean allDay = (null != allDayInteger) ? (allDayInteger == 1) : false;
    339 
    340         if ((dtstart == -1) ||
    341             (TextUtils.isEmpty(duration))||
    342             ((TextUtils.isEmpty(rruleStr))&&
    343                 (TextUtils.isEmpty(rdateStr)))) {
    344                 // no recurrence.
    345                 return false;
    346         }
    347 
    348         ICalendar.Property dtstartProp = new ICalendar.Property("DTSTART");
    349         Time dtstartTime = null;
    350         if (!TextUtils.isEmpty(tzid)) {
    351             if (!allDay) {
    352                 dtstartProp.addParameter(new ICalendar.Parameter("TZID", tzid));
    353             }
    354             dtstartTime = new Time(tzid);
    355         } else {
    356             // use the "floating" timezone
    357             dtstartTime = new Time(Time.TIMEZONE_UTC);
    358         }
    359 
    360         dtstartTime.set(dtstart);
    361         // make sure the time is printed just as a date, if all day.
    362         // TODO: android.pim.Time really should take care of this for us.
    363         if (allDay) {
    364             dtstartProp.addParameter(new ICalendar.Parameter("VALUE", "DATE"));
    365             dtstartTime.allDay = true;
    366             dtstartTime.hour = 0;
    367             dtstartTime.minute = 0;
    368             dtstartTime.second = 0;
    369         }
    370 
    371         dtstartProp.setValue(dtstartTime.format2445());
    372         component.addProperty(dtstartProp);
    373         ICalendar.Property durationProp = new ICalendar.Property("DURATION");
    374         durationProp.setValue(duration);
    375         component.addProperty(durationProp);
    376 
    377         addPropertiesForRuleStr(component, "RRULE", rruleStr);
    378         addPropertyForDateStr(component, "RDATE", rdateStr);
    379         addPropertiesForRuleStr(component, "EXRULE", exruleStr);
    380         addPropertyForDateStr(component, "EXDATE", exdateStr);
    381         return true;
    382     }
    383 
    384     public static void addPropertiesForRuleStr(ICalendar.Component component,
    385                                                 String propertyName,
    386                                                 String ruleStr) {
    387         if (TextUtils.isEmpty(ruleStr)) {
    388             return;
    389         }
    390         String[] rrules = getRuleStrings(ruleStr);
    391         for (String rrule : rrules) {
    392             ICalendar.Property prop = new ICalendar.Property(propertyName);
    393             prop.setValue(rrule);
    394             component.addProperty(prop);
    395         }
    396     }
    397 
    398     private static String[] getRuleStrings(String ruleStr) {
    399         if (null == ruleStr) {
    400             return new String[0];
    401         }
    402         String unfoldedRuleStr = unfold(ruleStr);
    403         String[] split = unfoldedRuleStr.split(RULE_SEPARATOR);
    404         int count = split.length;
    405         for (int n = 0; n < count; n++) {
    406             split[n] = fold(split[n]);
    407         }
    408         return split;
    409     }
    410 
    411 
    412     private static final Pattern IGNORABLE_ICAL_WHITESPACE_RE =
    413             Pattern.compile("(?:\\r\\n?|\\n)[ \t]");
    414 
    415     private static final Pattern FOLD_RE = Pattern.compile(".{75}");
    416 
    417     /**
    418     * fold and unfolds ical content lines as per RFC 2445 section 4.1.
    419     *
    420     * <h3>4.1 Content Lines</h3>
    421     *
    422     * <p>The iCalendar object is organized into individual lines of text, called
    423     * content lines. Content lines are delimited by a line break, which is a CRLF
    424     * sequence (US-ASCII decimal 13, followed by US-ASCII decimal 10).
    425     *
    426     * <p>Lines of text SHOULD NOT be longer than 75 octets, excluding the line
    427     * break. Long content lines SHOULD be split into a multiple line
    428     * representations using a line "folding" technique. That is, a long line can
    429     * be split between any two characters by inserting a CRLF immediately
    430     * followed by a single linear white space character (i.e., SPACE, US-ASCII
    431     * decimal 32 or HTAB, US-ASCII decimal 9). Any sequence of CRLF followed
    432     * immediately by a single linear white space character is ignored (i.e.,
    433     * removed) when processing the content type.
    434     */
    435     public static String fold(String unfoldedIcalContent) {
    436         return FOLD_RE.matcher(unfoldedIcalContent).replaceAll("$0\r\n ");
    437     }
    438 
    439     public static String unfold(String foldedIcalContent) {
    440         return IGNORABLE_ICAL_WHITESPACE_RE.matcher(
    441             foldedIcalContent).replaceAll("");
    442     }
    443 
    444     public static void addPropertyForDateStr(ICalendar.Component component,
    445                                               String propertyName,
    446                                               String dateStr) {
    447         if (TextUtils.isEmpty(dateStr)) {
    448             return;
    449         }
    450 
    451         ICalendar.Property prop = new ICalendar.Property(propertyName);
    452         String tz = null;
    453         int tzidx = dateStr.indexOf(";");
    454         if (tzidx != -1) {
    455             tz = dateStr.substring(0, tzidx);
    456             dateStr = dateStr.substring(tzidx + 1);
    457         }
    458         if (!TextUtils.isEmpty(tz)) {
    459             prop.addParameter(new ICalendar.Parameter("TZID", tz));
    460         }
    461         prop.setValue(dateStr);
    462         component.addProperty(prop);
    463     }
    464 
    465     private static String computeDuration(Time start,
    466                                           ICalendar.Component component) {
    467         // see if a duration is defined
    468         ICalendar.Property durationProperty =
    469                 component.getFirstProperty("DURATION");
    470         if (durationProperty != null) {
    471             // just return the duration
    472             return durationProperty.getValue();
    473         }
    474 
    475         // must compute a duration from the DTEND
    476         ICalendar.Property dtendProperty =
    477                 component.getFirstProperty("DTEND");
    478         if (dtendProperty == null) {
    479             // no DURATION, no DTEND: 0 second duration
    480             return "+P0S";
    481         }
    482         ICalendar.Parameter endTzidParameter =
    483                 dtendProperty.getFirstParameter("TZID");
    484         String endTzid = (endTzidParameter == null)
    485                 ? start.timezone : endTzidParameter.value;
    486 
    487         Time end = new Time(endTzid);
    488         end.parse(dtendProperty.getValue());
    489         long durationMillis = end.toMillis(false /* use isDst */)
    490                 - start.toMillis(false /* use isDst */);
    491         long durationSeconds = (durationMillis / 1000);
    492         if (start.allDay && (durationSeconds % 86400) == 0) {
    493             return "P" + (durationSeconds / 86400) + "D"; // Server wants this instead of P86400S
    494         } else {
    495             return "P" + durationSeconds + "S";
    496         }
    497     }
    498 
    499     private static String flattenProperties(ICalendar.Component component,
    500                                             String name) {
    501         List<ICalendar.Property> properties = component.getProperties(name);
    502         if (properties == null || properties.isEmpty()) {
    503             return null;
    504         }
    505 
    506         if (properties.size() == 1) {
    507             return properties.get(0).getValue();
    508         }
    509 
    510         StringBuilder sb = new StringBuilder();
    511 
    512         boolean first = true;
    513         for (ICalendar.Property property : component.getProperties(name)) {
    514             if (first) {
    515                 first = false;
    516             } else {
    517                 // TODO: use commas.  our RECUR parsing should handle that
    518                 // anyway.
    519                 sb.append(RULE_SEPARATOR);
    520             }
    521             sb.append(property.getValue());
    522         }
    523         return sb.toString();
    524     }
    525 
    526     private static String extractDates(ICalendar.Property recurrence) {
    527         if (recurrence == null) {
    528             return null;
    529         }
    530         ICalendar.Parameter tzidParam =
    531                 recurrence.getFirstParameter("TZID");
    532         if (tzidParam != null) {
    533             return tzidParam.value + ";" + recurrence.getValue();
    534         }
    535         return recurrence.getValue();
    536     }
    537 }
    538