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.util.Log;
20 
21 import java.util.LinkedHashMap;
22 import java.util.LinkedList;
23 import java.util.List;
24 import java.util.Set;
25 import java.util.ArrayList;
26 
27 /**
28  * Parses RFC 2445 iCalendar objects.
29  */
30 public class ICalendar {
31 
32     private static final String TAG = "Sync";
33 
34     // TODO: keep track of VEVENT, VTODO, VJOURNAL, VFREEBUSY, VTIMEZONE, VALARM
35     // components, by type field or by subclass?  subclass would allow us to
36     // enforce grammars.
37 
38     /**
39      * Exception thrown when an iCalendar object has invalid syntax.
40      */
41     public static class FormatException extends Exception {
FormatException()42         public FormatException() {
43             super();
44         }
45 
FormatException(String msg)46         public FormatException(String msg) {
47             super(msg);
48         }
49 
FormatException(String msg, Throwable cause)50         public FormatException(String msg, Throwable cause) {
51             super(msg, cause);
52         }
53     }
54 
55     /**
56      * A component within an iCalendar (VEVENT, VTODO, VJOURNAL, VFEEBUSY,
57      * VTIMEZONE, VALARM).
58      */
59     public static class Component {
60 
61         // components
62         static final String BEGIN = "BEGIN";
63         static final String END = "END";
64         private static final String NEWLINE = "\n";
65         public static final String VCALENDAR = "VCALENDAR";
66         public static final String VEVENT = "VEVENT";
67         public static final String VTODO = "VTODO";
68         public static final String VJOURNAL = "VJOURNAL";
69         public static final String VFREEBUSY = "VFREEBUSY";
70         public static final String VTIMEZONE = "VTIMEZONE";
71         public static final String VALARM = "VALARM";
72 
73         private final String mName;
74         private final Component mParent; // see if we can get rid of this
75         private LinkedList<Component> mChildren = null;
76         private final LinkedHashMap<String, ArrayList<Property>> mPropsMap =
77                 new LinkedHashMap<String, ArrayList<Property>>();
78 
79         /**
80          * Creates a new component with the provided name.
81          * @param name The name of the component.
82          */
Component(String name, Component parent)83         public Component(String name, Component parent) {
84             mName = name;
85             mParent = parent;
86         }
87 
88         /**
89          * Returns the name of the component.
90          * @return The name of the component.
91          */
getName()92         public String getName() {
93             return mName;
94         }
95 
96         /**
97          * Returns the parent of this component.
98          * @return The parent of this component.
99          */
getParent()100         public Component getParent() {
101             return mParent;
102         }
103 
104         /**
105          * Helper that lazily gets/creates the list of children.
106          * @return The list of children.
107          */
getOrCreateChildren()108         protected LinkedList<Component> getOrCreateChildren() {
109             if (mChildren == null) {
110                 mChildren = new LinkedList<Component>();
111             }
112             return mChildren;
113         }
114 
115         /**
116          * Adds a child component to this component.
117          * @param child The child component.
118          */
addChild(Component child)119         public void addChild(Component child) {
120             getOrCreateChildren().add(child);
121         }
122 
123         /**
124          * Returns a list of the Component children of this component.  May be
125          * null, if there are no children.
126          *
127          * @return A list of the children.
128          */
getComponents()129         public List<Component> getComponents() {
130             return mChildren;
131         }
132 
133         /**
134          * Adds a Property to this component.
135          * @param prop
136          */
addProperty(Property prop)137         public void addProperty(Property prop) {
138             String name= prop.getName();
139             ArrayList<Property> props = mPropsMap.get(name);
140             if (props == null) {
141                 props = new ArrayList<Property>();
142                 mPropsMap.put(name, props);
143             }
144             props.add(prop);
145         }
146 
147         /**
148          * Returns a set of the property names within this component.
149          * @return A set of property names within this component.
150          */
getPropertyNames()151         public Set<String> getPropertyNames() {
152             return mPropsMap.keySet();
153         }
154 
155         /**
156          * Returns a list of properties with the specified name.  Returns null
157          * if there are no such properties.
158          * @param name The name of the property that should be returned.
159          * @return A list of properties with the requested name.
160          */
getProperties(String name)161         public List<Property> getProperties(String name) {
162             return mPropsMap.get(name);
163         }
164 
165         /**
166          * Returns the first property with the specified name.  Returns null
167          * if there is no such property.
168          * @param name The name of the property that should be returned.
169          * @return The first property with the specified name.
170          */
getFirstProperty(String name)171         public Property getFirstProperty(String name) {
172             List<Property> props = mPropsMap.get(name);
173             if (props == null || props.size() == 0) {
174                 return null;
175             }
176             return props.get(0);
177         }
178 
179         @Override
toString()180         public String toString() {
181             StringBuilder sb = new StringBuilder();
182             toString(sb);
183             sb.append(NEWLINE);
184             return sb.toString();
185         }
186 
187         /**
188          * Helper method that appends this component to a StringBuilder.  The
189          * caller is responsible for appending a newline at the end of the
190          * component.
191          */
toString(StringBuilder sb)192         public void toString(StringBuilder sb) {
193             sb.append(BEGIN);
194             sb.append(":");
195             sb.append(mName);
196             sb.append(NEWLINE);
197 
198             // append the properties
199             for (String propertyName : getPropertyNames()) {
200                 for (Property property : getProperties(propertyName)) {
201                     property.toString(sb);
202                     sb.append(NEWLINE);
203                 }
204             }
205 
206             // append the sub-components
207             if (mChildren != null) {
208                 for (Component component : mChildren) {
209                     component.toString(sb);
210                     sb.append(NEWLINE);
211                 }
212             }
213 
214             sb.append(END);
215             sb.append(":");
216             sb.append(mName);
217         }
218     }
219 
220     /**
221      * A property within an iCalendar component (e.g., DTSTART, DTEND, etc.,
222      * within a VEVENT).
223      */
224     public static class Property {
225         // properties
226         // TODO: do we want to list these here?  the complete list is long.
227         public static final String DTSTART = "DTSTART";
228         public static final String DTEND = "DTEND";
229         public static final String DURATION = "DURATION";
230         public static final String RRULE = "RRULE";
231         public static final String RDATE = "RDATE";
232         public static final String EXRULE = "EXRULE";
233         public static final String EXDATE = "EXDATE";
234         // ... need to add more.
235 
236         private final String mName;
237         private LinkedHashMap<String, ArrayList<Parameter>> mParamsMap =
238                 new LinkedHashMap<String, ArrayList<Parameter>>();
239         private String mValue; // TODO: make this final?
240 
241         /**
242          * Creates a new property with the provided name.
243          * @param name The name of the property.
244          */
Property(String name)245         public Property(String name) {
246             mName = name;
247         }
248 
249         /**
250          * Creates a new property with the provided name and value.
251          * @param name The name of the property.
252          * @param value The value of the property.
253          */
Property(String name, String value)254         public Property(String name, String value) {
255             mName = name;
256             mValue = value;
257         }
258 
259         /**
260          * Returns the name of the property.
261          * @return The name of the property.
262          */
getName()263         public String getName() {
264             return mName;
265         }
266 
267         /**
268          * Returns the value of this property.
269          * @return The value of this property.
270          */
getValue()271         public String getValue() {
272             return mValue;
273         }
274 
275         /**
276          * Sets the value of this property.
277          * @param value The desired value for this property.
278          */
setValue(String value)279         public void setValue(String value) {
280             mValue = value;
281         }
282 
283         /**
284          * Adds a {@link Parameter} to this property.
285          * @param param The parameter that should be added.
286          */
addParameter(Parameter param)287         public void addParameter(Parameter param) {
288             ArrayList<Parameter> params = mParamsMap.get(param.name);
289             if (params == null) {
290                 params = new ArrayList<Parameter>();
291                 mParamsMap.put(param.name, params);
292             }
293             params.add(param);
294         }
295 
296         /**
297          * Returns the set of parameter names for this property.
298          * @return The set of parameter names for this property.
299          */
getParameterNames()300         public Set<String> getParameterNames() {
301             return mParamsMap.keySet();
302         }
303 
304         /**
305          * Returns the list of parameters with the specified name.  May return
306          * null if there are no such parameters.
307          * @param name The name of the parameters that should be returned.
308          * @return The list of parameters with the specified name.
309          */
getParameters(String name)310         public List<Parameter> getParameters(String name) {
311             return mParamsMap.get(name);
312         }
313 
314         /**
315          * Returns the first parameter with the specified name.  May return
316          * nll if there is no such parameter.
317          * @param name The name of the parameter that should be returned.
318          * @return The first parameter with the specified name.
319          */
getFirstParameter(String name)320         public Parameter getFirstParameter(String name) {
321             ArrayList<Parameter> params = mParamsMap.get(name);
322             if (params == null || params.size() == 0) {
323                 return null;
324             }
325             return params.get(0);
326         }
327 
328         @Override
toString()329         public String toString() {
330             StringBuilder sb = new StringBuilder();
331             toString(sb);
332             return sb.toString();
333         }
334 
335         /**
336          * Helper method that appends this property to a StringBuilder.  The
337          * caller is responsible for appending a newline after this property.
338          */
toString(StringBuilder sb)339         public void toString(StringBuilder sb) {
340             sb.append(mName);
341             Set<String> parameterNames = getParameterNames();
342             for (String parameterName : parameterNames) {
343                 for (Parameter param : getParameters(parameterName)) {
344                     sb.append(";");
345                     param.toString(sb);
346                 }
347             }
348             sb.append(":");
349             sb.append(mValue);
350         }
351     }
352 
353     /**
354      * A parameter defined for an iCalendar property.
355      */
356     // TODO: make this a proper class rather than a struct?
357     public static class Parameter {
358         public String name;
359         public String value;
360 
361         /**
362          * Creates a new empty parameter.
363          */
Parameter()364         public Parameter() {
365         }
366 
367         /**
368          * Creates a new parameter with the specified name and value.
369          * @param name The name of the parameter.
370          * @param value The value of the parameter.
371          */
Parameter(String name, String value)372         public Parameter(String name, String value) {
373             this.name = name;
374             this.value = value;
375         }
376 
377         @Override
toString()378         public String toString() {
379             StringBuilder sb = new StringBuilder();
380             toString(sb);
381             return sb.toString();
382         }
383 
384         /**
385          * Helper method that appends this parameter to a StringBuilder.
386          */
toString(StringBuilder sb)387         public void toString(StringBuilder sb) {
388             sb.append(name);
389             sb.append("=");
390             sb.append(value);
391         }
392     }
393 
394     private static final class ParserState {
395         // public int lineNumber = 0;
396         public String line; // TODO: just point to original text
397         public int index;
398     }
399 
400     // use factory method
ICalendar()401     private ICalendar() {
402     }
403 
404     // TODO: get rid of this -- handle all of the parsing in one pass through
405     // the text.
normalizeText(String text)406     private static String normalizeText(String text) {
407         // it's supposed to be \r\n, but not everyone does that
408         text = text.replaceAll("\r\n", "\n");
409         text = text.replaceAll("\r", "\n");
410 
411         // we deal with line folding, by replacing all "\n " strings
412         // with nothing.  The RFC specifies "\r\n " to be folded, but
413         // we handle "\n " and "\r " too because we can get those.
414         text = text.replaceAll("\n ", "");
415 
416         return text;
417     }
418 
419     /**
420      * Parses text into an iCalendar component.  Parses into the provided
421      * component, if not null, or parses into a new component.  In the latter
422      * case, expects a BEGIN as the first line.  Returns the provided or newly
423      * created top-level component.
424      */
425     // TODO: use an index into the text, so we can make this a recursive
426     // function?
parseComponentImpl(Component component, String text)427     private static Component parseComponentImpl(Component component,
428                                                 String text)
429             throws FormatException {
430         Component current = component;
431         ParserState state = new ParserState();
432         state.index = 0;
433 
434         // split into lines
435         String[] lines = text.split("\n");
436 
437         // each line is of the format:
438         // name *(";" param) ":" value
439         for (String line : lines) {
440             try {
441                 current = parseLine(line, state, current);
442                 // if the provided component was null, we will return the root
443                 // NOTE: in this case, if the first line is not a BEGIN, a
444                 // FormatException will get thrown.
445                 if (component == null) {
446                     component = current;
447                 }
448             } catch (FormatException fe) {
449                 if (false) {
450                     Log.v(TAG, "Cannot parse " + line, fe);
451                 }
452                 // for now, we ignore the parse error.  Google Calendar seems
453                 // to be emitting some misformatted iCalendar objects.
454             }
455             continue;
456         }
457         return component;
458     }
459 
460     /**
461      * Parses a line into the provided component.  Creates a new component if
462      * the line is a BEGIN, adding the newly created component to the provided
463      * parent.  Returns whatever component is the current one (to which new
464      * properties will be added) in the parse.
465      */
parseLine(String line, ParserState state, Component component)466     private static Component parseLine(String line, ParserState state,
467                                        Component component)
468             throws FormatException {
469         state.line = line;
470         int len = state.line.length();
471 
472         // grab the name
473         char c = 0;
474         for (state.index = 0; state.index < len; ++state.index) {
475             c = line.charAt(state.index);
476             if (c == ';' || c == ':') {
477                 break;
478             }
479         }
480         String name = line.substring(0, state.index);
481 
482         if (component == null) {
483             if (!Component.BEGIN.equals(name)) {
484                 throw new FormatException("Expected BEGIN");
485             }
486         }
487 
488         Property property;
489         if (Component.BEGIN.equals(name)) {
490             // start a new component
491             String componentName = extractValue(state);
492             Component child = new Component(componentName, component);
493             if (component != null) {
494                 component.addChild(child);
495             }
496             return child;
497         } else if (Component.END.equals(name)) {
498             // finish the current component
499             String componentName = extractValue(state);
500             if (component == null ||
501                     !componentName.equals(component.getName())) {
502                 throw new FormatException("Unexpected END " + componentName);
503             }
504             return component.getParent();
505         } else {
506             property = new Property(name);
507         }
508 
509         if (c == ';') {
510             Parameter parameter = null;
511             while ((parameter = extractParameter(state)) != null) {
512                 property.addParameter(parameter);
513             }
514         }
515         String value = extractValue(state);
516         property.setValue(value);
517         component.addProperty(property);
518         return component;
519     }
520 
521     /**
522      * Extracts the value ":..." on the current line.  The first character must
523      * be a ':'.
524      */
extractValue(ParserState state)525     private static String extractValue(ParserState state)
526             throws FormatException {
527         String line = state.line;
528         if (state.index >= line.length() || line.charAt(state.index) != ':') {
529             throw new FormatException("Expected ':' before end of line in "
530                     + line);
531         }
532         String value = line.substring(state.index + 1);
533         state.index = line.length() - 1;
534         return value;
535     }
536 
537     /**
538      * Extracts the next parameter from the line, if any.  If there are no more
539      * parameters, returns null.
540      */
extractParameter(ParserState state)541     private static Parameter extractParameter(ParserState state)
542             throws FormatException {
543         String text = state.line;
544         int len = text.length();
545         Parameter parameter = null;
546         int startIndex = -1;
547         int equalIndex = -1;
548         while (state.index < len) {
549             char c = text.charAt(state.index);
550             if (c == ':') {
551                 if (parameter != null) {
552                     if (equalIndex == -1) {
553                         throw new FormatException("Expected '=' within "
554                                 + "parameter in " + text);
555                     }
556                     parameter.value = text.substring(equalIndex + 1,
557                                                      state.index);
558                 }
559                 return parameter; // may be null
560             } else if (c == ';') {
561                 if (parameter != null) {
562                     if (equalIndex == -1) {
563                         throw new FormatException("Expected '=' within "
564                                 + "parameter in " + text);
565                     }
566                     parameter.value = text.substring(equalIndex + 1,
567                                                      state.index);
568                     return parameter;
569                 } else {
570                     parameter = new Parameter();
571                     startIndex = state.index;
572                 }
573             } else if (c == '=') {
574                 equalIndex = state.index;
575                 if ((parameter == null) || (startIndex == -1)) {
576                     throw new FormatException("Expected ';' before '=' in "
577                             + text);
578                 }
579                 parameter.name = text.substring(startIndex + 1, equalIndex);
580             } else if (c == '"') {
581                 if (parameter == null) {
582                     throw new FormatException("Expected parameter before '\"' in " + text);
583                 }
584                 if (equalIndex == -1) {
585                     throw new FormatException("Expected '=' within parameter in " + text);
586                 }
587                 if (state.index > equalIndex + 1) {
588                     throw new FormatException("Parameter value cannot contain a '\"' in " + text);
589                 }
590                 final int endQuote = text.indexOf('"', state.index + 1);
591                 if (endQuote < 0) {
592                     throw new FormatException("Expected closing '\"' in " + text);
593                 }
594                 parameter.value = text.substring(state.index + 1, endQuote);
595                 state.index = endQuote + 1;
596                 return parameter;
597             }
598             ++state.index;
599         }
600         throw new FormatException("Expected ':' before end of line in " + text);
601     }
602 
603     /**
604      * Parses the provided text into an iCalendar object.  The top-level
605      * component must be of type VCALENDAR.
606      * @param text The text to be parsed.
607      * @return The top-level VCALENDAR component.
608      * @throws FormatException Thrown if the text could not be parsed into an
609      * iCalendar VCALENDAR object.
610      */
parseCalendar(String text)611     public static Component parseCalendar(String text) throws FormatException {
612         Component calendar = parseComponent(null, text);
613         if (calendar == null || !Component.VCALENDAR.equals(calendar.getName())) {
614             throw new FormatException("Expected " + Component.VCALENDAR);
615         }
616         return calendar;
617     }
618 
619     /**
620      * Parses the provided text into an iCalendar event.  The top-level
621      * component must be of type VEVENT.
622      * @param text The text to be parsed.
623      * @return The top-level VEVENT component.
624      * @throws FormatException Thrown if the text could not be parsed into an
625      * iCalendar VEVENT.
626      */
parseEvent(String text)627     public static Component parseEvent(String text) throws FormatException {
628         Component event = parseComponent(null, text);
629         if (event == null || !Component.VEVENT.equals(event.getName())) {
630             throw new FormatException("Expected " + Component.VEVENT);
631         }
632         return event;
633     }
634 
635     /**
636      * Parses the provided text into an iCalendar component.
637      * @param text The text to be parsed.
638      * @return The top-level component.
639      * @throws FormatException Thrown if the text could not be parsed into an
640      * iCalendar component.
641      */
parseComponent(String text)642     public static Component parseComponent(String text) throws FormatException {
643         return parseComponent(null, text);
644     }
645 
646     /**
647      * Parses the provided text, adding to the provided component.
648      * @param component The component to which the parsed iCalendar data should
649      * be added.
650      * @param text The text to be parsed.
651      * @return The top-level component.
652      * @throws FormatException Thrown if the text could not be parsed as an
653      * iCalendar object.
654      */
parseComponent(Component component, String text)655     public static Component parseComponent(Component component, String text)
656         throws FormatException {
657         text = normalizeText(text);
658         return parseComponentImpl(component, text);
659     }
660 }
661