001    package ezvcard.util;
002    
003    import java.net.URI;
004    import java.util.Arrays;
005    import java.util.Collections;
006    import java.util.LinkedHashMap;
007    import java.util.Map;
008    import java.util.regex.Matcher;
009    import java.util.regex.Pattern;
010    
011    /*
012     Copyright (c) 2013, 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 a geographical position.
043     * </p>
044     * <p>
045     * Example geo URI: {@code geo:40.714623,-74.006605}
046     * </p>
047     * <p>
048     * This class is immutable. Use the {@link Builder} object to construct a new
049     * instance, or the {@link #parse} method to parse a geo URI string.
050     * </p>
051     * 
052     * <p>
053     * <b>Examples:</b>
054     * 
055     * <pre class="brush:java">
056     * GeoUri uri = new GeoUri.Builder(40.714623, -74.006605).coordC(1.1).build();
057     * GeoUri uri = GeoUri.parse(&quot;geo:40.714623,-74.006605,1.1&quot;);
058     * GeoUri copy = new GeoUri.Builder(original).coordC(2.1).build();
059     * </pre>
060     * 
061     * </p>
062     * @author Michael Angstadt
063     * @see <a href="http://tools.ietf.org/html/rfc5870">RFC 5870</a>
064     */
065    public final class GeoUri {
066            /**
067             * The coordinate reference system used by GPS (the default).
068             */
069            public static final String CRS_WGS84 = "wgs84";
070    
071            /**
072             * The non-alphanumeric characters which are allowed to exist inside of a
073             * parameter value.
074             */
075            private static final char validParamValueChars[] = "!$&'()*+-.:[]_~".toCharArray();
076            static {
077                    //make sure the array is sorted for binary search
078                    Arrays.sort(validParamValueChars);
079            }
080    
081            /**
082             * Finds hex values in a parameter value.
083             */
084            private static final Pattern hexPattern = Pattern.compile("(?i)%([0-9a-f]{2})");
085    
086            /**
087             * Validates parameter names.
088             */
089            private static final Pattern labelTextPattern = Pattern.compile("(?i)^[-a-z0-9]+$");
090    
091            /**
092             * Parses geo URIs.
093             */
094            private static final Pattern uriPattern = Pattern.compile("(?i)^geo:(-?\\d+(\\.\\d+)?),(-?\\d+(\\.\\d+)?)(,(-?\\d+(\\.\\d+)?))?(;(.*))?$");
095    
096            private static final String PARAM_CRS = "crs";
097            private static final String PARAM_UNCERTAINTY = "u";
098    
099            private final Double coordA;
100            private final Double coordB;
101            private final Double coordC;
102            private final String crs;
103            private final Double uncertainty;
104            private final Map<String, String> parameters;
105    
106            private GeoUri(Builder builder) {
107                    this.coordA = builder.coordA;
108                    this.coordB = builder.coordB;
109                    this.coordC = builder.coordC;
110                    this.crs = builder.crs;
111                    this.uncertainty = builder.uncertainty;
112                    this.parameters = Collections.unmodifiableMap(builder.parameters);
113            }
114    
115            /**
116             * Parses a geo URI string.
117             * @param uri the URI string (e.g. "geo:40.714623,-74.006605")
118             * @return the parsed geo URI
119             * @throws IllegalArgumentException if the string is not a valid geo URI
120             */
121            public static GeoUri parse(String uri) {
122                    Matcher m = uriPattern.matcher(uri);
123                    if (!m.find()) {
124                            throw new IllegalArgumentException("Invalid geo URI: " + uri);
125                    }
126    
127                    Builder builder = new Builder();
128                    builder.coordA = Double.parseDouble(m.group(1));
129                    builder.coordB = Double.parseDouble(m.group(3));
130    
131                    String coordCStr = m.group(6);
132                    if (coordCStr != null) {
133                            builder.coordC = Double.valueOf(coordCStr);
134                    }
135    
136                    String paramsStr = m.group(9);
137                    if (paramsStr != null) {
138                            String paramsArray[] = paramsStr.split(";");
139    
140                            for (String param : paramsArray) {
141                                    String paramSplit[] = param.split("=", 2);
142                                    String paramName = paramSplit[0];
143                                    String paramValue = (paramSplit.length > 1) ? decodeParamValue(paramSplit[1]) : "";
144    
145                                    if (PARAM_CRS.equalsIgnoreCase(paramName)) {
146                                            builder.crs = paramValue;
147                                            continue;
148                                    }
149    
150                                    if (PARAM_UNCERTAINTY.equalsIgnoreCase(paramName)) {
151                                            try {
152                                                    builder.uncertainty = Double.valueOf(paramValue);
153                                                    continue;
154                                            } catch (NumberFormatException e) {
155                                                    //if it can't be parsed, then treat it as an ordinary parameter
156                                            }
157                                    }
158    
159                                    builder.parameters.put(paramName, paramValue);
160                            }
161                    }
162    
163                    return builder.build();
164            }
165    
166            /**
167             * Gets the first coordinate (latitude).
168             * @return the first coordinate or null if there is none
169             */
170            public Double getCoordA() {
171                    return coordA;
172            }
173    
174            /**
175             * Gets the second coordinate (longitude).
176             * @return the second coordinate or null if there is none
177             */
178            public Double getCoordB() {
179                    return coordB;
180            }
181    
182            /**
183             * Gets the third coordinate (altitude).
184             * @return the third coordinate or null if there is none
185             */
186            public Double getCoordC() {
187                    return coordC;
188            }
189    
190            /**
191             * Gets the coordinate reference system.
192             * @return the coordinate reference system or null if using the default
193             * (WGS-84)
194             */
195            public String getCrs() {
196                    return crs;
197            }
198    
199            /**
200             * Gets the uncertainty (how accurate the coordinates are).
201             * @return the uncertainty (in meters) or null if not set
202             */
203            public Double getUncertainty() {
204                    return uncertainty;
205            }
206    
207            /**
208             * Gets a parameter value.
209             * @param name the parameter name
210             * @return the parameter value or null if not found
211             */
212            public String getParameter(String name) {
213                    return parameters.get(name);
214            }
215    
216            /**
217             * Gets all the parameters.
218             * @return all the parameters
219             */
220            public Map<String, String> getParameters() {
221                    return parameters;
222            }
223    
224            /**
225             * Creates a {@link URI} object from this geo URI.
226             * @return the {@link URI} object
227             */
228            public URI toUri() {
229                    return URI.create(toString());
230            }
231    
232            /**
233             * Converts this geo URI to its string representation.
234             * @return the geo URI's string representation
235             */
236            @Override
237            public String toString() {
238                    return toString(6);
239            }
240    
241            /**
242             * Converts this geo URI to its string representation.
243             * @param decimals the number of decimals to display for floating point
244             * values
245             * @return the geo URI's string representation
246             */
247            public String toString(int decimals) {
248                    VCardFloatFormatter formatter = new VCardFloatFormatter(decimals);
249                    StringBuilder sb = new StringBuilder("geo:");
250    
251                    sb.append(formatter.format(coordA));
252                    sb.append(',');
253                    sb.append(formatter.format(coordB));
254    
255                    if (coordC != null) {
256                            sb.append(',');
257                            sb.append(coordC);
258                    }
259    
260                    //if the CRS is WGS-84, then it doesn't have to be displayed
261                    if (crs != null && !crs.equalsIgnoreCase(CRS_WGS84)) {
262                            writeParameter(PARAM_CRS, crs, sb);
263                    }
264    
265                    if (uncertainty != null) {
266                            writeParameter(PARAM_UNCERTAINTY, formatter.format(uncertainty), sb);
267                    }
268    
269                    for (Map.Entry<String, String> entry : parameters.entrySet()) {
270                            String name = entry.getKey();
271                            String value = entry.getValue();
272                            writeParameter(name, value, sb);
273                    }
274    
275                    return sb.toString();
276            }
277    
278            /**
279             * Writes a parameter to a string.
280             * @param name the parameter name
281             * @param value the parameter value
282             * @param sb the string to write to
283             */
284            private void writeParameter(String name, String value, StringBuilder sb) {
285                    sb.append(';').append(name).append('=').append(encodeParamValue(value));
286            }
287    
288            private static boolean isLabelText(String text) {
289                    return labelTextPattern.matcher(text).find();
290            }
291    
292            private static String encodeParamValue(String value) {
293                    StringBuilder sb = new StringBuilder(value.length());
294                    for (char c : value.toCharArray()) {
295                            if ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || Arrays.binarySearch(validParamValueChars, c) >= 0) {
296                                    sb.append(c);
297                            } else {
298                                    int i = (int) c;
299                                    sb.append('%');
300                                    sb.append(Integer.toString(i, 16));
301                            }
302                    }
303                    return sb.toString();
304            }
305    
306            private static String decodeParamValue(String value) {
307                    Matcher m = hexPattern.matcher(value);
308                    StringBuffer sb = new StringBuffer();
309                    while (m.find()) {
310                            int hex = Integer.parseInt(m.group(1), 16);
311                            m.appendReplacement(sb, "" + (char) hex);
312                    }
313                    m.appendTail(sb);
314                    return sb.toString();
315            }
316    
317            /**
318             * Builder class for {@link GeoUri}.
319             * @author Michael Angstadt
320             */
321            public static class Builder {
322                    private Double coordA;
323                    private Double coordB;
324                    private Double coordC;
325                    private String crs;
326                    private Double uncertainty;
327                    private Map<String, String> parameters;
328    
329                    private Builder() {
330                            //for internal use
331                            parameters = new LinkedHashMap<String, String>(0); //set initial size to 0 because parameters are rarely used
332                    }
333    
334                    /**
335                     * Creates a new {@link GeoUri} builder.
336                     * @param coordA the first coordinate (i.e. latitude)
337                     * @param coordB the second coordinate (i.e. longitude)
338                     */
339                    public Builder(Double coordA, Double coordB) {
340                            this();
341                            coordA(coordA);
342                            coordB(coordB);
343                    }
344    
345                    /**
346                     * Creates a new {@link GeoUri} builder.
347                     * @param original the {@link GeoUri} object to copy from
348                     */
349                    public Builder(GeoUri original) {
350                            coordA(original.coordA);
351                            coordB(original.coordB);
352                            this.coordC = original.coordC;
353                            this.crs = original.crs;
354                            this.uncertainty = original.uncertainty;
355                            this.parameters = new LinkedHashMap<String, String>(original.parameters);
356                    }
357    
358                    /**
359                     * Sets the first coordinate (latitude).
360                     * @param coordA the first coordinate
361                     * @return this
362                     */
363                    public Builder coordA(Double coordA) {
364                            this.coordA = (coordA == null) ? 0.0 : coordA;
365                            return this;
366                    }
367    
368                    /**
369                     * Sets the second coordinate (longitude).
370                     * @param coordB the second coordinate
371                     * @return this
372                     */
373                    public Builder coordB(Double coordB) {
374                            this.coordB = (coordB == null) ? 0.0 : coordB;
375                            return this;
376                    }
377    
378                    /**
379                     * Sets the third coordinate (altitude).
380                     * @param coordC the third coordinate or null to remove
381                     * @return this
382                     */
383                    public Builder coordC(Double coordC) {
384                            this.coordC = coordC;
385                            return this;
386                    }
387    
388                    /**
389                     * Sets the coordinate reference system.
390                     * @param crs the coordinate reference system (can only contain letters,
391                     * numbers, and hyphens) or null to use the default (WGS-84)
392                     * @throws IllegalArgumentException if the CRS name contains invalid
393                     * characters
394                     * @return this
395                     */
396                    public Builder crs(String crs) {
397                            if (crs != null && !isLabelText(crs)) {
398                                    throw new IllegalArgumentException("CRS can only contain letters, numbers, and hypens.");
399                            }
400                            this.crs = crs;
401                            return this;
402                    }
403    
404                    /**
405                     * Sets the uncertainty (how accurate the coordinates are).
406                     * @param uncertainty the uncertainty (in meters) or null to remove
407                     * @return this
408                     */
409                    public Builder uncertainty(Double uncertainty) {
410                            this.uncertainty = uncertainty;
411                            return this;
412                    }
413    
414                    /**
415                     * Adds a parameter.
416                     * @param name the parameter name (can only contain letters, numbers,
417                     * and hyphens)
418                     * @param value the parameter value or null to remove the parameter
419                     * @throws IllegalArgumentException if the parameter name contains
420                     * invalid characters
421                     * @return this
422                     */
423                    public Builder parameter(String name, String value) {
424                            if (!isLabelText(name)) {
425                                    throw new IllegalArgumentException("Parameter names can only contain letters, numbers, and hyphens.");
426                            }
427    
428                            if (value == null) {
429                                    parameters.remove(name);
430                            } else {
431                                    parameters.put(name, value);
432                            }
433                            return this;
434                    }
435    
436                    /**
437                     * Builds the final {@link GeoUri} object.
438                     * @return the object
439                     */
440                    public GeoUri build() {
441                            return new GeoUri(this);
442                    }
443            }
444    }