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}