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