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("geo:40.714623,-74.006605,1.1");
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 }