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}