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 }