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