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