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("+1-212-555-0101").extension("123").build();
056 * TelUri uri = TelUri.parse("tel:+1-212-555-0101;ext=123");
057 * TelUri copy = new TelUri.Builder(original).extension("124").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 }