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}