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