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}