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