RecurrenceSet.java revision 3b95f5378957c4e985429dfefda3975416c1a039
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
17package android.pim;
18
19import android.content.ContentValues;
20import android.database.Cursor;
21import android.os.Bundle;
22import android.provider.Calendar;
23import android.text.TextUtils;
24import android.text.format.Time;
25import android.util.Config;
26import android.util.Log;
27
28import java.util.List;
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 */
34public class RecurrenceSet {
35
36    private final static String TAG = "CalendarProvider";
37
38    private final static String RULE_SEPARATOR = "\n";
39
40    // TODO: make these final?
41    public EventRecurrence[] rrules = null;
42    public long[] rdates = null;
43    public EventRecurrence[] exrules = null;
44    public long[] exdates = null;
45
46    /**
47     * Creates a new RecurrenceSet from information stored in the
48     * events table in the CalendarProvider.
49     * @param values The values retrieved from the Events table.
50     */
51    public RecurrenceSet(ContentValues values) {
52        String rruleStr = values.getAsString(Calendar.Events.RRULE);
53        String rdateStr = values.getAsString(Calendar.Events.RDATE);
54        String exruleStr = values.getAsString(Calendar.Events.EXRULE);
55        String exdateStr = values.getAsString(Calendar.Events.EXDATE);
56        init(rruleStr, rdateStr, exruleStr, exdateStr);
57    }
58
59    /**
60     * Creates a new RecurrenceSet from information stored in a database
61     * {@link Cursor} pointing to the events table in the
62     * CalendarProvider.  The cursor must contain the RRULE, RDATE, EXRULE,
63     * and EXDATE columns.
64     *
65     * @param cursor The cursor containing the RRULE, RDATE, EXRULE, and EXDATE
66     * columns.
67     */
68    public RecurrenceSet(Cursor cursor) {
69        int rruleColumn = cursor.getColumnIndex(Calendar.Events.RRULE);
70        int rdateColumn = cursor.getColumnIndex(Calendar.Events.RDATE);
71        int exruleColumn = cursor.getColumnIndex(Calendar.Events.EXRULE);
72        int exdateColumn = cursor.getColumnIndex(Calendar.Events.EXDATE);
73        String rruleStr = cursor.getString(rruleColumn);
74        String rdateStr = cursor.getString(rdateColumn);
75        String exruleStr = cursor.getString(exruleColumn);
76        String exdateStr = cursor.getString(exdateColumn);
77        init(rruleStr, rdateStr, exruleStr, exdateStr);
78    }
79
80    public RecurrenceSet(String rruleStr, String rdateStr,
81                  String exruleStr, String exdateStr) {
82        init(rruleStr, rdateStr, exruleStr, exdateStr);
83    }
84
85    private void init(String rruleStr, String rdateStr,
86                      String exruleStr, String exdateStr) {
87        if (!TextUtils.isEmpty(rruleStr) || !TextUtils.isEmpty(rdateStr)) {
88
89            if (!TextUtils.isEmpty(rruleStr)) {
90                String[] rruleStrs = rruleStr.split(RULE_SEPARATOR);
91                rrules = new EventRecurrence[rruleStrs.length];
92                for (int i = 0; i < rruleStrs.length; ++i) {
93                    EventRecurrence rrule = new EventRecurrence();
94                    rrule.parse(rruleStrs[i]);
95                    rrules[i] = rrule;
96                }
97            }
98
99            if (!TextUtils.isEmpty(rdateStr)) {
100                rdates = parseRecurrenceDates(rdateStr);
101            }
102
103            if (!TextUtils.isEmpty(exruleStr)) {
104                String[] exruleStrs = exruleStr.split(RULE_SEPARATOR);
105                exrules = new EventRecurrence[exruleStrs.length];
106                for (int i = 0; i < exruleStrs.length; ++i) {
107                    EventRecurrence exrule = new EventRecurrence();
108                    exrule.parse(exruleStr);
109                    exrules[i] = exrule;
110                }
111            }
112
113            if (!TextUtils.isEmpty(exdateStr)) {
114                exdates = parseRecurrenceDates(exdateStr);
115            }
116        }
117    }
118
119    /**
120     * Returns whether or not a recurrence is defined in this RecurrenceSet.
121     * @return Whether or not a recurrence is defined in this RecurrenceSet.
122     */
123    public boolean hasRecurrence() {
124        return (rrules != null || rdates != null);
125    }
126
127    /**
128     * Parses the provided RDATE or EXDATE string into an array of longs
129     * representing each date/time in the recurrence.
130     * @param recurrence The recurrence to be parsed.
131     * @return The list of date/times.
132     */
133    public static long[] parseRecurrenceDates(String recurrence) {
134        // TODO: use "local" time as the default.  will need to handle times
135        // that end in "z" (UTC time) explicitly at that point.
136        String tz = Time.TIMEZONE_UTC;
137        int tzidx = recurrence.indexOf(";");
138        if (tzidx != -1) {
139            tz = recurrence.substring(0, tzidx);
140            recurrence = recurrence.substring(tzidx + 1);
141        }
142        Time time = new Time(tz);
143        String[] rawDates = recurrence.split(",");
144        int n = rawDates.length;
145        long[] dates = new long[n];
146        for (int i = 0; i<n; ++i) {
147            // The timezone is updated to UTC if the time string specified 'Z'.
148            time.parse(rawDates[i]);
149            dates[i] = time.toMillis(false /* use isDst */);
150            time.timezone = tz;
151        }
152        return dates;
153    }
154
155    /**
156     * Populates the database map of values with the appropriate RRULE, RDATE,
157     * EXRULE, and EXDATE values extracted from the parsed iCalendar component.
158     * @param component The iCalendar component containing the desired
159     * recurrence specification.
160     * @param values The db values that should be updated.
161     * @return true if the component contained the necessary information
162     * to specify a recurrence.  The required fields are DTSTART,
163     * one of DTEND/DURATION, and one of RRULE/RDATE.  Returns false if
164     * there was an error, including if the date is out of range.
165     */
166    public static boolean populateContentValues(ICalendar.Component component,
167            ContentValues values) {
168        ICalendar.Property dtstartProperty =
169                component.getFirstProperty("DTSTART");
170        String dtstart = dtstartProperty.getValue();
171        ICalendar.Parameter tzidParam =
172                dtstartProperty.getFirstParameter("TZID");
173        // NOTE: the timezone may be null, if this is a floating time.
174        String tzid = tzidParam == null ? null : tzidParam.value;
175        Time start = new Time(tzidParam == null ? Time.TIMEZONE_UTC : tzid);
176        boolean inUtc = start.parse(dtstart);
177        boolean allDay = start.allDay;
178
179        if (inUtc) {
180            tzid = Time.TIMEZONE_UTC;
181        }
182
183        String duration = computeDuration(start, component);
184        String rrule = flattenProperties(component, "RRULE");
185        String rdate = extractDates(component.getFirstProperty("RDATE"));
186        String exrule = flattenProperties(component, "EXRULE");
187        String exdate = extractDates(component.getFirstProperty("EXDATE"));
188
189        if ((TextUtils.isEmpty(dtstart))||
190                (TextUtils.isEmpty(duration))||
191                ((TextUtils.isEmpty(rrule))&&
192                        (TextUtils.isEmpty(rdate)))) {
193                if (Config.LOGD) {
194                    Log.d(TAG, "Recurrence missing DTSTART, DTEND/DURATION, "
195                                + "or RRULE/RDATE: "
196                                + component.toString());
197                }
198                return false;
199        }
200
201        if (allDay) {
202        	// TODO: also change tzid to be UTC?  that would be consistent, but
203        	// that would not reflect the original timezone value back to the
204        	// server.
205        	start.timezone = Time.TIMEZONE_UTC;
206        }
207        long millis = start.toMillis(false /* use isDst */);
208        values.put(Calendar.Events.DTSTART, millis);
209        if (millis == -1) {
210            if (Config.LOGD) {
211                Log.d(TAG, "DTSTART is out of range: " + component.toString());
212            }
213            return false;
214        }
215
216        values.put(Calendar.Events.RRULE, rrule);
217        values.put(Calendar.Events.RDATE, rdate);
218        values.put(Calendar.Events.EXRULE, exrule);
219        values.put(Calendar.Events.EXDATE, exdate);
220        values.put(Calendar.Events.EVENT_TIMEZONE, tzid);
221        values.put(Calendar.Events.DURATION, duration);
222        values.put(Calendar.Events.ALL_DAY, allDay ? 1 : 0);
223        return true;
224    }
225
226    // This can be removed when the old CalendarSyncAdapter is removed.
227    public static boolean populateComponent(Cursor cursor,
228                                            ICalendar.Component component) {
229
230        int dtstartColumn = cursor.getColumnIndex(Calendar.Events.DTSTART);
231        int durationColumn = cursor.getColumnIndex(Calendar.Events.DURATION);
232        int tzidColumn = cursor.getColumnIndex(Calendar.Events.EVENT_TIMEZONE);
233        int rruleColumn = cursor.getColumnIndex(Calendar.Events.RRULE);
234        int rdateColumn = cursor.getColumnIndex(Calendar.Events.RDATE);
235        int exruleColumn = cursor.getColumnIndex(Calendar.Events.EXRULE);
236        int exdateColumn = cursor.getColumnIndex(Calendar.Events.EXDATE);
237        int allDayColumn = cursor.getColumnIndex(Calendar.Events.ALL_DAY);
238
239
240        long dtstart = -1;
241        if (!cursor.isNull(dtstartColumn)) {
242            dtstart = cursor.getLong(dtstartColumn);
243        }
244        String duration = cursor.getString(durationColumn);
245        String tzid = cursor.getString(tzidColumn);
246        String rruleStr = cursor.getString(rruleColumn);
247        String rdateStr = cursor.getString(rdateColumn);
248        String exruleStr = cursor.getString(exruleColumn);
249        String exdateStr = cursor.getString(exdateColumn);
250        boolean allDay = cursor.getInt(allDayColumn) == 1;
251
252        if ((dtstart == -1) ||
253            (TextUtils.isEmpty(duration))||
254            ((TextUtils.isEmpty(rruleStr))&&
255                (TextUtils.isEmpty(rdateStr)))) {
256                // no recurrence.
257                return false;
258        }
259
260        ICalendar.Property dtstartProp = new ICalendar.Property("DTSTART");
261        Time dtstartTime = null;
262        if (!TextUtils.isEmpty(tzid)) {
263            if (!allDay) {
264                dtstartProp.addParameter(new ICalendar.Parameter("TZID", tzid));
265            }
266            dtstartTime = new Time(tzid);
267        } else {
268            // use the "floating" timezone
269            dtstartTime = new Time(Time.TIMEZONE_UTC);
270        }
271
272        dtstartTime.set(dtstart);
273        // make sure the time is printed just as a date, if all day.
274        // TODO: android.pim.Time really should take care of this for us.
275        if (allDay) {
276            dtstartProp.addParameter(new ICalendar.Parameter("VALUE", "DATE"));
277            dtstartTime.allDay = true;
278            dtstartTime.hour = 0;
279            dtstartTime.minute = 0;
280            dtstartTime.second = 0;
281        }
282
283        dtstartProp.setValue(dtstartTime.format2445());
284        component.addProperty(dtstartProp);
285        ICalendar.Property durationProp = new ICalendar.Property("DURATION");
286        durationProp.setValue(duration);
287        component.addProperty(durationProp);
288
289        addPropertiesForRuleStr(component, "RRULE", rruleStr);
290        addPropertyForDateStr(component, "RDATE", rdateStr);
291        addPropertiesForRuleStr(component, "EXRULE", exruleStr);
292        addPropertyForDateStr(component, "EXDATE", exdateStr);
293        return true;
294    }
295
296public static boolean populateComponent(ContentValues values,
297                                            ICalendar.Component component) {
298        long dtstart = -1;
299        if (values.containsKey(Calendar.Events.DTSTART)) {
300            dtstart = values.getAsLong(Calendar.Events.DTSTART);
301        }
302        String duration = values.getAsString(Calendar.Events.DURATION);
303        String tzid = values.getAsString(Calendar.Events.EVENT_TIMEZONE);
304        String rruleStr = values.getAsString(Calendar.Events.RRULE);
305        String rdateStr = values.getAsString(Calendar.Events.RDATE);
306        String exruleStr = values.getAsString(Calendar.Events.EXRULE);
307        String exdateStr = values.getAsString(Calendar.Events.EXDATE);
308        boolean allDay = values.getAsInteger(Calendar.Events.ALL_DAY) == 1;
309
310        if ((dtstart == -1) ||
311            (TextUtils.isEmpty(duration))||
312            ((TextUtils.isEmpty(rruleStr))&&
313                (TextUtils.isEmpty(rdateStr)))) {
314                // no recurrence.
315                return false;
316        }
317
318        ICalendar.Property dtstartProp = new ICalendar.Property("DTSTART");
319        Time dtstartTime = null;
320        if (!TextUtils.isEmpty(tzid)) {
321            if (!allDay) {
322                dtstartProp.addParameter(new ICalendar.Parameter("TZID", tzid));
323            }
324            dtstartTime = new Time(tzid);
325        } else {
326            // use the "floating" timezone
327            dtstartTime = new Time(Time.TIMEZONE_UTC);
328        }
329
330        dtstartTime.set(dtstart);
331        // make sure the time is printed just as a date, if all day.
332        // TODO: android.pim.Time really should take care of this for us.
333        if (allDay) {
334            dtstartProp.addParameter(new ICalendar.Parameter("VALUE", "DATE"));
335            dtstartTime.allDay = true;
336            dtstartTime.hour = 0;
337            dtstartTime.minute = 0;
338            dtstartTime.second = 0;
339        }
340
341        dtstartProp.setValue(dtstartTime.format2445());
342        component.addProperty(dtstartProp);
343        ICalendar.Property durationProp = new ICalendar.Property("DURATION");
344        durationProp.setValue(duration);
345        component.addProperty(durationProp);
346
347        addPropertiesForRuleStr(component, "RRULE", rruleStr);
348        addPropertyForDateStr(component, "RDATE", rdateStr);
349        addPropertiesForRuleStr(component, "EXRULE", exruleStr);
350        addPropertyForDateStr(component, "EXDATE", exdateStr);
351        return true;
352    }
353
354    private static void addPropertiesForRuleStr(ICalendar.Component component,
355                                                String propertyName,
356                                                String ruleStr) {
357        if (TextUtils.isEmpty(ruleStr)) {
358            return;
359        }
360        String[] rrules = ruleStr.split(RULE_SEPARATOR);
361        for (String rrule : rrules) {
362            ICalendar.Property prop = new ICalendar.Property(propertyName);
363            prop.setValue(rrule);
364            component.addProperty(prop);
365        }
366    }
367
368    private static void addPropertyForDateStr(ICalendar.Component component,
369                                              String propertyName,
370                                              String dateStr) {
371        if (TextUtils.isEmpty(dateStr)) {
372            return;
373        }
374
375        ICalendar.Property prop = new ICalendar.Property(propertyName);
376        String tz = null;
377        int tzidx = dateStr.indexOf(";");
378        if (tzidx != -1) {
379            tz = dateStr.substring(0, tzidx);
380            dateStr = dateStr.substring(tzidx + 1);
381        }
382        if (!TextUtils.isEmpty(tz)) {
383            prop.addParameter(new ICalendar.Parameter("TZID", tz));
384        }
385        prop.setValue(dateStr);
386        component.addProperty(prop);
387    }
388
389    private static String computeDuration(Time start,
390                                          ICalendar.Component component) {
391        // see if a duration is defined
392        ICalendar.Property durationProperty =
393                component.getFirstProperty("DURATION");
394        if (durationProperty != null) {
395            // just return the duration
396            return durationProperty.getValue();
397        }
398
399        // must compute a duration from the DTEND
400        ICalendar.Property dtendProperty =
401                component.getFirstProperty("DTEND");
402        if (dtendProperty == null) {
403            // no DURATION, no DTEND: 0 second duration
404            return "+P0S";
405        }
406        ICalendar.Parameter endTzidParameter =
407                dtendProperty.getFirstParameter("TZID");
408        String endTzid = (endTzidParameter == null)
409                ? start.timezone : endTzidParameter.value;
410
411        Time end = new Time(endTzid);
412        end.parse(dtendProperty.getValue());
413        long durationMillis = end.toMillis(false /* use isDst */)
414                - start.toMillis(false /* use isDst */);
415        long durationSeconds = (durationMillis / 1000);
416        return "P" + durationSeconds + "S";
417    }
418
419    private static String flattenProperties(ICalendar.Component component,
420                                            String name) {
421        List<ICalendar.Property> properties = component.getProperties(name);
422        if (properties == null || properties.isEmpty()) {
423            return null;
424        }
425
426        if (properties.size() == 1) {
427            return properties.get(0).getValue();
428        }
429
430        StringBuilder sb = new StringBuilder();
431
432        boolean first = true;
433        for (ICalendar.Property property : component.getProperties(name)) {
434            if (first) {
435                first = false;
436            } else {
437                // TODO: use commas.  our RECUR parsing should handle that
438                // anyway.
439                sb.append(RULE_SEPARATOR);
440            }
441            sb.append(property.getValue());
442        }
443        return sb.toString();
444    }
445
446    private static String extractDates(ICalendar.Property recurrence) {
447        if (recurrence == null) {
448            return null;
449        }
450        ICalendar.Parameter tzidParam =
451                recurrence.getFirstParameter("TZID");
452        if (tzidParam != null) {
453            return tzidParam.value + ";" + recurrence.getValue();
454        }
455        return recurrence.getValue();
456    }
457}
458