001    package ezvcard.util;
002    
003    import java.util.Arrays;
004    import java.util.Collections;
005    import java.util.Map;
006    import java.util.TreeMap;
007    import java.util.regex.Matcher;
008    import java.util.regex.Pattern;
009    
010    /*
011     Copyright (c) 2013, Michael Angstadt
012     All rights reserved.
013    
014     Redistribution and use in source and binary forms, with or without
015     modification, are permitted provided that the following conditions are met: 
016    
017     1. Redistributions of source code must retain the above copyright notice, this
018     list of conditions and the following disclaimer. 
019     2. Redistributions in binary form must reproduce the above copyright notice,
020     this list of conditions and the following disclaimer in the documentation
021     and/or other materials provided with the distribution. 
022    
023     THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
024     ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
025     WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
026     DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
027     ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
028     (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
029     LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
030     ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
031     (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
032     SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
033    
034     The views and conclusions contained in the software and documentation are those
035     of the authors and should not be interpreted as representing official policies, 
036     either expressed or implied, of the FreeBSD Project.
037     */
038    
039    /**
040     * <p>
041     * Represents a URI for encoding telephone numbers.
042     * </p>
043     * <p>
044     * Example tel URI: {@code tel:+1-212-555-0101}
045     * </p>
046     * <p>
047     * This class is immutable. Use the {@link Builder} object to construct a new
048     * instance, or the {@link #parse} method to parse a tel URI string.
049     * </p>
050     * 
051     * <p>
052     * <b>Examples:</b>
053     * 
054     * <pre class="brush:java">
055     * TelUri uri = new TelUri.Builder(&quot;+1-212-555-0101&quot;).extension(&quot;123&quot;).build();
056     * TelUri uri = TelUri.parse(&quot;tel:+1-212-555-0101;ext=123&quot;);
057     * TelUri copy = new TelUri.Builder(original).extension(&quot;124&quot;).build();
058     * </pre>
059     * @see <a href="http://tools.ietf.org/html/rfc3966">RFC 3966</a>
060     * @author Michael Angstadt
061     */
062    public final class TelUri {
063            /**
064             * The non-alphanumeric characters which are allowed to exist inside of a
065             * parameter value.
066             */
067            private static final char validParamValueChars[] = "!$&'()*+-.:[]_~/".toCharArray();
068            static {
069                    //make sure the array is sorted for binary search
070                    Arrays.sort(validParamValueChars);
071            }
072    
073            /**
074             * Finds hex values in an encoded parameter value.
075             */
076            private static final Pattern hexPattern = Pattern.compile("(?i)%([0-9a-f]{2})");
077    
078            /**
079             * Regular expression for parsing tel URIs.
080             */
081            private static final Pattern uriPattern = Pattern.compile("(?i)^tel:(.*?)(;(.*))?$");
082    
083            private static final String PARAM_EXTENSION = "ext";
084            private static final String PARAM_ISDN_SUBADDRESS = "isub";
085            private static final String PARAM_PHONE_CONTEXT = "phone-context";
086    
087            private final String number;
088            private final String extension;
089            private final String isdnSubaddress;
090            private final String phoneContext;
091            private final Map<String, String> parameters;
092    
093            private TelUri(Builder builder) {
094                    number = builder.number;
095                    extension = builder.extension;
096                    isdnSubaddress = builder.isdnSubaddress;
097                    phoneContext = builder.phoneContext;
098                    parameters = Collections.unmodifiableMap(builder.parameters);
099            }
100    
101            /**
102             * Parses a tel URI.
103             * @param uri the URI
104             * @return the parsed tel URI
105             * @throws IllegalArgumentException if the URI cannot be parsed
106             */
107            public static TelUri parse(String uri) {
108                    Matcher m = uriPattern.matcher(uri);
109                    if (!m.find()) {
110                            throw new IllegalArgumentException("Invalid tel URI: " + uri);
111                    }
112    
113                    Builder builder = new Builder();
114                    builder.number = m.group(1);
115    
116                    String paramsStr = m.group(3);
117                    if (paramsStr != null) {
118                            String paramsArray[] = paramsStr.split(";");
119    
120                            for (String param : paramsArray) {
121                                    String paramSplit[] = param.split("=", 2);
122                                    String paramName = paramSplit[0];
123                                    String paramValue = paramSplit.length > 1 ? decodeParamValue(paramSplit[1]) : "";
124    
125                                    if (PARAM_EXTENSION.equalsIgnoreCase(paramName)) {
126                                            builder.extension = paramValue;
127                                            continue;
128                                    }
129    
130                                    if (PARAM_ISDN_SUBADDRESS.equalsIgnoreCase(paramName)) {
131                                            builder.isdnSubaddress = paramValue;
132                                            continue;
133                                    }
134    
135                                    if (PARAM_PHONE_CONTEXT.equalsIgnoreCase(paramName)) {
136                                            builder.phoneContext = paramValue;
137                                            continue;
138                                    }
139    
140                                    builder.parameters.put(paramName, paramValue);
141                            }
142                    }
143    
144                    return builder.build();
145            }
146    
147            /**
148             * Gets the phone number.
149             * @return the phone number
150             */
151            public String getNumber() {
152                    return number;
153            }
154    
155            /**
156             * Gets the phone context.
157             * @return the phone context (e.g. "example.com") or null if not set
158             */
159            public String getPhoneContext() {
160                    return phoneContext;
161            }
162    
163            /**
164             * Gets the extension.
165             * @return the extension (e.g. "101") or null if not set
166             */
167            public String getExtension() {
168                    return extension;
169            }
170    
171            /**
172             * Gets the ISDN sub address.
173             * @return the ISDN sub address or null if not set
174             */
175            public String getIsdnSubaddress() {
176                    return isdnSubaddress;
177            }
178    
179            /**
180             * Gets a parameter value.
181             * @param name the parameter name
182             * @return the parameter value or null if not found
183             */
184            public String getParameter(String name) {
185                    return parameters.get(name);
186            }
187    
188            /**
189             * Gets all parameters.
190             * @return all parameters
191             */
192            public Map<String, String> getParameters() {
193                    return parameters;
194            }
195    
196            /**
197             * Converts this tel URI to its string representation.
198             * @return the tel URI's string representation
199             */
200            @Override
201            public String toString() {
202                    StringBuilder sb = new StringBuilder("tel:");
203    
204                    sb.append(number);
205    
206                    if (extension != null) {
207                            writeParameter(PARAM_EXTENSION, extension, sb);
208                    }
209                    if (isdnSubaddress != null) {
210                            writeParameter(PARAM_ISDN_SUBADDRESS, isdnSubaddress, sb);
211                    }
212                    if (phoneContext != null) {
213                            writeParameter(PARAM_PHONE_CONTEXT, phoneContext, sb);
214                    }
215    
216                    for (Map.Entry<String, String> entry : parameters.entrySet()) {
217                            String name = entry.getKey();
218                            String value = entry.getValue();
219                            writeParameter(name, value, sb);
220                    }
221    
222                    return sb.toString();
223            }
224    
225            /**
226             * Writes a parameter to a string.
227             * @param name the parameter name
228             * @param value the parameter value
229             * @param sb the string to write to
230             */
231            private void writeParameter(String name, String value, StringBuilder sb) {
232                    sb.append(';').append(name).append('=').append(encodeParamValue(value));
233            }
234    
235            @Override
236            public int hashCode() {
237                    final int prime = 31;
238                    int result = 1;
239                    result = prime * result + ((extension == null) ? 0 : extension.hashCode());
240                    result = prime * result + ((isdnSubaddress == null) ? 0 : isdnSubaddress.hashCode());
241                    result = prime * result + ((number == null) ? 0 : number.hashCode());
242                    result = prime * result + ((parameters == null) ? 0 : parameters.hashCode());
243                    result = prime * result + ((phoneContext == null) ? 0 : phoneContext.hashCode());
244                    return result;
245            }
246    
247            @Override
248            public boolean equals(Object obj) {
249                    if (this == obj)
250                            return true;
251                    if (obj == null)
252                            return false;
253                    if (getClass() != obj.getClass())
254                            return false;
255                    TelUri other = (TelUri) obj;
256                    if (extension == null) {
257                            if (other.extension != null)
258                                    return false;
259                    } else if (!extension.equals(other.extension))
260                            return false;
261                    if (isdnSubaddress == null) {
262                            if (other.isdnSubaddress != null)
263                                    return false;
264                    } else if (!isdnSubaddress.equals(other.isdnSubaddress))
265                            return false;
266                    if (number == null) {
267                            if (other.number != null)
268                                    return false;
269                    } else if (!number.equals(other.number))
270                            return false;
271                    if (parameters == null) {
272                            if (other.parameters != null)
273                                    return false;
274                    } else if (!parameters.equals(other.parameters))
275                            return false;
276                    if (phoneContext == null) {
277                            if (other.phoneContext != null)
278                                    return false;
279                    } else if (!phoneContext.equals(other.phoneContext))
280                            return false;
281                    return true;
282            }
283    
284            /**
285             * Determines if a given string can be used as a parameter name.
286             * @param text the parameter name
287             * @return true if it contains all valid characters, false if not
288             * @see "RFC 3966 p.5 ('pname' definition)"
289             */
290            private static boolean isParametername(String text) {
291                    return text.matches("(?i)[-a-z0-9]+");
292            }
293    
294            /**
295             * Determines if a given string is a phone digit.
296             * @param text the string
297             * @return true if it's a phone digit, false if not
298             * @see "RFC 3966 p.5 ('phonedigit' definition)"
299             */
300            private static boolean isPhoneDigit(String text) {
301                    return text.matches("[-0-9.()]+");
302            }
303    
304            /**
305             * Encodes a string for safe inclusion in a parameter value.
306             * @param value the string to encode
307             * @return the encoded value
308             */
309            private static String encodeParamValue(String value) {
310                    StringBuilder sb = new StringBuilder(value.length());
311                    for (int i = 0; i < value.length(); i++) {
312                            char c = value.charAt(i);
313                            if ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || Arrays.binarySearch(validParamValueChars, c) >= 0) {
314                                    sb.append(c);
315                            } else {
316                                    int cInt = (int) c;
317                                    sb.append('%');
318                                    sb.append(Integer.toString(cInt, 16));
319                            }
320                    }
321                    return sb.toString();
322            }
323    
324            /**
325             * Decodes escaped characters in a parameter value.
326             * @param value the parameter value
327             * @return the decoded value
328             */
329            private static String decodeParamValue(String value) {
330                    Matcher m = hexPattern.matcher(value);
331                    StringBuffer sb = new StringBuffer();
332                    while (m.find()) {
333                            int hex = Integer.parseInt(m.group(1), 16);
334                            m.appendReplacement(sb, "" + (char) hex);
335                    }
336                    m.appendTail(sb);
337                    return sb.toString();
338            }
339    
340            public static class Builder {
341                    private String number;
342                    private String extension;
343                    private String isdnSubaddress;
344                    private String phoneContext;
345    
346                    //Note: TreeMap is used because parameters should appear in lexicographical order (see RFC 3966 p.5)
347                    private Map<String, String> parameters;
348    
349                    private Builder() {
350                            //for internal use
351                            parameters = new TreeMap<String, String>();
352                    }
353    
354                    /**
355                     * <p>
356                     * Initializes the builder with a global telephone number.
357                     * </p>
358                     * <p>
359                     * Global telephone numbers must:
360                     * <ol>
361                     * <li>Start with "+"</li>
362                     * <li>Contain at least 1 digit</li>
363                     * <li>Limit themselves to the following characters:
364                     * <ul>
365                     * <li>{@code 0-9} (digits)</li>
366                     * <li>{@code -} (hypen)</li>
367                     * <li>{@code .} (period)</li>
368                     * <li>{@code (} (opening paraenthesis)</li>
369                     * <li>{@code )} (closing paraenthesis)</li>
370                     * </ul>
371                     * </li>
372                     * </ol>
373                     * </p>
374                     * @param globalNumber the telephone number (e.g. "+1-212-555-0101")
375                     * @throws IllegalArgumentException if the given telephone number does
376                     * not adhere to the above rules
377                     */
378                    public Builder(String globalNumber) {
379                            this();
380                            globalNumber(globalNumber);
381                    }
382    
383                    /**
384                     * <p>
385                     * Initializes the builder with a local telephone number. Note, however,
386                     * that the global format is preferred.
387                     * </p>
388                     * <p>
389                     * Local telephone numbers must:
390                     * <ol>
391                     * <li>Contain at least 1 of the following characters:
392                     * <ul>
393                     * <li>{@code 0-9} (digit)</li>
394                     * <li>{@code *} (asterisk)</li>
395                     * <li>{@code #} (hash)</li>
396                     * </ul>
397                     * </li>
398                     * <li>Limit themselves to the following characters:
399                     * <ul>
400                     * <li>{@code 0-9} (digits)</li>
401                     * <li>{@code -} (hypen)</li>
402                     * <li>{@code .} (period)</li>
403                     * <li>{@code (} (opening paraenthesis)</li>
404                     * <li>{@code )} (closing paraenthesis)</li>
405                     * <li>{@code *} (asterisk)</li>
406                     * <li>{@code #} (hash)</li>
407                     * </ul>
408                     * </li>
409                     * </ol>
410                     * </p>
411                     * @param localNumber the telephone number (e.g. "7042")
412                     * @param phoneContext the context under which the local number is valid
413                     * (e.g. "example.com")
414                     * @throws IllegalArgumentException if the given telephone number does
415                     * not adhere to the above rules
416                     */
417                    public Builder(String localNumber, String phoneContext) {
418                            this();
419                            localNumber(localNumber, phoneContext);
420                    }
421    
422                    /**
423                     * Creates a new {@link TelUri} builder.
424                     * @param original the {@link TelUri} object to copy from
425                     */
426                    public Builder(TelUri original) {
427                            number = original.number;
428                            extension = original.extension;
429                            isdnSubaddress = original.isdnSubaddress;
430                            phoneContext = original.phoneContext;
431                            parameters = new TreeMap<String, String>(original.parameters);
432                    }
433    
434                    /**
435                     * <p>
436                     * Sets the telephone number as a global number.
437                     * </p>
438                     * <p>
439                     * Global telephone numbers must:
440                     * <ol>
441                     * <li>Start with "+"</li>
442                     * <li>Contain at least 1 digit</li>
443                     * <li>Limit themselves to the following characters:
444                     * <ul>
445                     * <li>{@code 0-9} (digits)</li>
446                     * <li>{@code -} (hypen)</li>
447                     * <li>{@code .} (period)</li>
448                     * <li>{@code (} (opening paraenthesis)</li>
449                     * <li>{@code )} (closing paraenthesis)</li>
450                     * </ul>
451                     * </li>
452                     * </ol>
453                     * </p>
454                     * @param globalNumber the telephone number (e.g. "+1-212-555-0101")
455                     * @return this
456                     * @throws IllegalArgumentException if the given telephone number does
457                     * not adhere to the above rules
458                     */
459                    public Builder globalNumber(String globalNumber) {
460                            if (!globalNumber.matches(".*?[0-9].*")) {
461                                    throw new IllegalArgumentException("Global number must contain at least one digit.");
462                            }
463                            if (!globalNumber.startsWith("+")) {
464                                    throw new IllegalArgumentException("Global number must start with \"+\".");
465                            }
466                            if (!globalNumber.matches("\\+[-0-9.()]*")) {
467                                    throw new IllegalArgumentException("Global number contains invalid characters.");
468                            }
469    
470                            number = globalNumber;
471                            phoneContext = null;
472                            return this;
473                    }
474    
475                    /**
476                     * <p>
477                     * Sets the telephone number as a local number. Note, however, that the
478                     * global format is preferred.
479                     * </p>
480                     * <p>
481                     * Local telephone numbers must:
482                     * <ol>
483                     * <li>Contain at least 1 of the following characters:
484                     * <ul>
485                     * <li>{@code 0-9} (digit)</li>
486                     * <li>{@code *} (asterisk)</li>
487                     * <li>{@code #} (hash)</li>
488                     * </ul>
489                     * </li>
490                     * <li>Limit themselves to the following characters:
491                     * <ul>
492                     * <li>{@code 0-9} (digits)</li>
493                     * <li>{@code -} (hypen)</li>
494                     * <li>{@code .} (period)</li>
495                     * <li>{@code (} (opening paraenthesis)</li>
496                     * <li>{@code )} (closing paraenthesis)</li>
497                     * <li>{@code *} (asterisk)</li>
498                     * <li>{@code #} (hash)</li>
499                     * </ul>
500                     * </li>
501                     * </ol>
502                     * </p>
503                     * @param localNumber the telephone number (e.g. "7042")
504                     * @param phoneContext the context under which the local number is valid
505                     * (e.g. "example.com")
506                     * @return this
507                     * @throws IllegalArgumentException if the given telephone number does
508                     * not adhere to the above rules
509                     */
510                    public Builder localNumber(String localNumber, String phoneContext) {
511                            if (!localNumber.matches(".*?[0-9*#].*") || !localNumber.matches("[0-9\\-.()*#]+")) {
512                                    throw new IllegalArgumentException("Local number contains invalid characters.");
513                            }
514    
515                            number = localNumber;
516                            this.phoneContext = phoneContext;
517                            return this;
518                    }
519    
520                    /**
521                     * Sets the extension.
522                     * @param extension the extension (e.g. "101") or null to remove
523                     * @return this
524                     * @throws IllegalArgumentException if the extension contains characters
525                     * other than the following: digits, hypens, parenthesis, periods
526                     */
527                    public Builder extension(String extension) {
528                            if (extension != null && !isPhoneDigit(extension)) {
529                                    throw new IllegalArgumentException("Extension contains invalid characters.");
530                            }
531    
532                            this.extension = extension;
533                            return this;
534                    }
535    
536                    /**
537                     * Sets the ISDN sub address.
538                     * @param isdnSubaddress the ISDN sub address or null to remove
539                     * @return this
540                     */
541                    public Builder isdnSubaddress(String isdnSubaddress) {
542                            this.isdnSubaddress = isdnSubaddress;
543                            return this;
544                    }
545    
546                    /**
547                     * Adds a parameter.
548                     * @param name the parameter name (can only contain letters, numbers,
549                     * and hyphens)
550                     * @param value the parameter value or null to remove it
551                     * @return this
552                     * @throws IllegalArgumentException if the parameter name contains
553                     * invalid characters
554                     */
555                    public Builder parameter(String name, String value) {
556                            if (!isParametername(name)) {
557                                    throw new IllegalArgumentException("Parameter names can only contain letters, numbers, and hyphens.");
558                            }
559    
560                            if (value == null) {
561                                    parameters.remove(name);
562                            } else {
563                                    parameters.put(name, value);
564                            }
565                            return this;
566                    }
567    
568                    /**
569                     * Builds the final {@link TelUri} object.
570                     * @return the object
571                     */
572                    public TelUri build() {
573                            return new TelUri(this);
574                    }
575            }
576    }