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 }