1 /* 2 * Copyright 2022 Google LLC 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 package com.google.android.libraries.mobiledatadownload.file.common; 17 18 import static com.google.android.libraries.mobiledatadownload.file.common.internal.Charsets.UTF_8; 19 20 import android.net.Uri; 21 import android.text.TextUtils; 22 import com.google.android.libraries.mobiledatadownload.file.common.internal.Preconditions; 23 import com.google.errorprone.annotations.CanIgnoreReturnValue; 24 import java.io.UnsupportedEncodingException; 25 import java.net.URLDecoder; 26 import java.net.URLEncoder; 27 import java.util.ArrayList; 28 import java.util.Collections; 29 import java.util.List; 30 import javax.annotation.Nullable; 31 32 /** 33 * A URI fragment parser and builder. Parses fragment params is similar to parsing of query params, 34 * and are delimited by "&". For example, <code>#foo=bar&us=them</code> 35 * 36 * <p>produces two params: one "foo" with value "bar", and another "us" with value "them". 37 * 38 * <p>Each fragment param must have at least one value; multiple values are delimited by "+". For 39 * example, <code>#foo=bar+baz</code> 40 * 41 * <p>produces one param, "foo", with two values "bar" and "baz". 42 * 43 * <p>Furthermore, fragment values can have subparams, which are additional information scoped to 44 * the value. Subparams have keys and optional values, and are delimited by ",". For example, <code> 45 * #foo=bar(x=1)+baz(this=that,when)</code> 46 * 47 * <p>produces one param, "foo", with two values "bar" and "baz" where "bar" has subparam "x" set at 48 * 1, baz has subparam "this" set at "that", and "when" is unset. 49 * 50 * <p>While the <internal> spec requires that keys and values are [0-9a-zA-Z-_]+, this class 51 * encodes/decodes keys/values, including subparams using java.net.URLEncoder/decoder. In 52 * particular, this class is responsible for producing strings suitable for being appended verbatim 53 * to the fragment part of an RFC3986 URI. 54 */ 55 public final class Fragment { 56 public static final Fragment EMPTY_FRAGMENT = new Fragment(null); 57 public static final Fragment.ParamValue EMPTY_FRAGMENT_PARAM_VALUE = new ParamValue(null, null); 58 59 private final List<Param> params = new ArrayList<Param>(); 60 Fragment(@ullable List<Param> params)61 private Fragment(@Nullable List<Param> params) { 62 if (params != null) { 63 this.params.addAll(params); 64 } 65 } 66 67 /** Create a new, empty builder. */ builder()68 public static Fragment.Builder builder() { 69 return new Fragment.Builder(null); 70 } 71 72 /** Return a builder based on this Fragment. */ toBuilder()73 public Fragment.Builder toBuilder() { 74 Fragment.Builder builder = builder(); 75 for (Param param : params) { 76 builder.addParam(param.toBuilder()); 77 } 78 return builder; 79 } 80 81 /** Iterate over the params. */ params()82 public List<Param> params() { 83 return Collections.unmodifiableList(params); 84 } 85 86 /** Finds the param with the given key. Returns null if not found. */ 87 @Nullable findParam(String key)88 public Param findParam(String key) { 89 for (Param param : params) { 90 if (param.key.equals(key)) { 91 return param; 92 } 93 } 94 return null; 95 } 96 97 @Override toString()98 public String toString() { 99 return TextUtils.join("&", params); 100 } 101 102 /** Builder for the whole URI fragment. */ 103 public static final class Builder { 104 private final List<Param.Builder> params = new ArrayList<Param.Builder>(); 105 Builder(@ullable List<Param.Builder> params)106 private Builder(@Nullable List<Param.Builder> params) { 107 if (params == null) { 108 return; 109 } 110 for (Param.Builder param : params) { 111 addParam(param); 112 } 113 } 114 115 /** Get all of the params as a list. */ params()116 public List<Param.Builder> params() { 117 return Collections.unmodifiableList(params); 118 } 119 120 /** Finds the param with the given key. Returns null if not found. */ 121 @Nullable findParam(String key)122 public Param.Builder findParam(String key) { 123 for (Param.Builder param : params) { 124 if (param.key.equals(key)) { 125 return param; 126 } 127 } 128 return null; 129 } 130 131 /** Adds a param. If a param with same key already exists, this replaces it. */ 132 @CanIgnoreReturnValue addParam(Param param)133 public Builder addParam(Param param) { 134 addParam(param.toBuilder()); 135 return this; 136 } 137 138 /** Adds a param. If a param with the same key already exist, this replaces it. */ 139 @CanIgnoreReturnValue addParam(Param.Builder param)140 public Builder addParam(Param.Builder param) { 141 for (int i = 0; i < params.size(); i++) { 142 if (params.get(i).key.equals(param.key)) { 143 params.set(i, param); 144 return this; 145 } 146 } 147 params.add(param); 148 return this; 149 } 150 151 /** Adds a simple param with no value. */ 152 @CanIgnoreReturnValue addParam(String key)153 public Builder addParam(String key) { 154 return addParam(Param.builder(key)); 155 } 156 157 /** Return an immutable Fragment. Unset params are ignored. */ build()158 public Fragment build() { 159 List<Param> params = new ArrayList<Param>(); 160 for (Param.Builder builder : this.params) { 161 Param param = builder.build(); 162 if (param != null) { 163 params.add(param); 164 } 165 } 166 return new Fragment(params); 167 } 168 } 169 170 /** A fragment param. */ 171 public static final class Param { 172 private final String key; 173 private final List<ParamValue> values = new ArrayList<ParamValue>(); 174 175 /** 176 * @throws IllegalArgumentException if {@code values} is empty. 177 */ Param(String key, List<ParamValue> values)178 private Param(String key, List<ParamValue> values) { 179 Preconditions.checkArgument(!values.isEmpty(), "Missing param values"); 180 this.key = key; 181 this.values.addAll(values); 182 } 183 184 /** Gets the key for the param. */ key()185 public String key() { 186 return key; 187 } 188 189 /** Iterate over the values. */ values()190 public List<ParamValue> values() { 191 return Collections.unmodifiableList(values); 192 } 193 194 /** Find a value by name. Returns null if not found. */ 195 @Nullable findValue(String name)196 public ParamValue findValue(String name) { 197 for (ParamValue value : values) { 198 if (value.name.equals(name)) { 199 return value; 200 } 201 } 202 return null; 203 } 204 205 /** 206 * Create a new param identified with a key. 207 * 208 * @param key The unique key. 209 * @return The param. 210 */ builder(String key)211 public static Param.Builder builder(String key) { 212 return new Param.Builder(key, null); 213 } 214 215 /** Return a builder based on this Param. */ toBuilder()216 public Param.Builder toBuilder() { 217 Param.Builder builder = builder(key); 218 for (ParamValue value : values) { 219 builder.addValue(value.toBuilder()); 220 } 221 return builder; 222 } 223 224 @Override toString()225 public String toString() { 226 StringBuilder builder = new StringBuilder(); 227 builder.append(urlEncode(key)); 228 builder.append("="); 229 builder.append(TextUtils.join("+", values)); 230 return builder.toString(); 231 } 232 233 /** Builder for a fragment param. */ 234 public static final class Builder { 235 private final String key; 236 private final List<ParamValue.Builder> values = new ArrayList<ParamValue.Builder>(); 237 Builder(String key, @Nullable List<ParamValue.Builder> values)238 private Builder(String key, @Nullable List<ParamValue.Builder> values) { 239 this.key = key; 240 if (values == null) { 241 return; 242 } 243 for (ParamValue.Builder value : values) { 244 addValue(value); 245 } 246 } 247 248 /** Gets the key for the param. */ key()249 public String key() { 250 return key; 251 } 252 253 /** Get all of the param values as a list. */ values()254 public List<ParamValue.Builder> values() { 255 return Collections.unmodifiableList(values); 256 } 257 258 /** Find a value by name. Returns null if not found. */ 259 @Nullable findValue(String name)260 public ParamValue.Builder findValue(String name) { 261 for (ParamValue.Builder value : values) { 262 if (value.name.equals(name)) { 263 return value; 264 } 265 } 266 return null; 267 } 268 269 /** 270 * Adds a value to this param. If a value already exists with the same name, this will replace 271 * it. 272 */ 273 @CanIgnoreReturnValue addValue(ParamValue value)274 public Builder addValue(ParamValue value) { 275 addValue(value.toBuilder()); 276 return this; 277 } 278 279 /** 280 * Adds a value to this param. If a value already exists with the same name, this will replace 281 * it. 282 */ 283 @CanIgnoreReturnValue addValue(ParamValue.Builder value)284 public Builder addValue(ParamValue.Builder value) { 285 for (int i = 0; i < values.size(); i++) { 286 if (values.get(i).name.equals(value.name)) { 287 values.set(i, value); 288 return this; 289 } 290 } 291 values.add(value); 292 return this; 293 } 294 295 /** Adds a value that has no subparams. Also replaces existing value if present. */ 296 @CanIgnoreReturnValue addValue(String name)297 public Builder addValue(String name) { 298 return addValue(new ParamValue.Builder(name, null)); 299 } 300 301 /** Return a new immutable Param from this builder, or null if the param is unset. */ 302 @Nullable build()303 public Param build() { 304 if (this.values.isEmpty()) { 305 return null; 306 } 307 List<ParamValue> values = new ArrayList<ParamValue>(); 308 for (ParamValue.Builder value : this.values) { 309 values.add(value.build()); 310 } 311 return new Param(key, values); 312 } 313 } 314 } 315 316 /** A value of a fragment param. Each fragment param can have multiple values. */ 317 public static final class ParamValue { 318 319 private final String name; 320 private final List<SubParam> subparams = new ArrayList<SubParam>(); 321 ParamValue(String name, @Nullable List<SubParam> subparams)322 private ParamValue(String name, @Nullable List<SubParam> subparams) { 323 this.name = name; 324 if (subparams != null) { 325 this.subparams.addAll(subparams); 326 } 327 } 328 329 /** Creates a new param value with the given name. */ builder(String name)330 public static ParamValue.Builder builder(String name) { 331 return new ParamValue.Builder(name, null); 332 } 333 334 /** Return a builder based on this ParamValue. */ toBuilder()335 public ParamValue.Builder toBuilder() { 336 ParamValue.Builder builder = builder(name); 337 for (SubParam subparam : subparams) { 338 builder.addSubParam(subparam); 339 } 340 return builder; 341 } 342 343 /** The name of the param value. */ name()344 public String name() { 345 return name; 346 } 347 348 /** Iterate over the subparams. */ subParams()349 public List<SubParam> subParams() { 350 return Collections.unmodifiableList(subparams); 351 } 352 353 /** Finds a subparam with the given key. If not found, returns null. */ 354 @Nullable findSubParam(String key)355 public SubParam findSubParam(String key) { 356 for (SubParam subparam : subparams) { 357 if (subparam.key.equals(key)) { 358 return subparam; 359 } 360 } 361 return null; 362 } 363 364 /** 365 * Finds the subparam value with the given key. If the subparam or value is null, returns null. 366 * 367 * @param key 368 * @return The value of the subparam or null. 369 */ 370 @Nullable findSubParamValue(String key)371 public String findSubParamValue(String key) { 372 SubParam subparam = findSubParam(key); 373 return (subparam == null) ? null : subparam.value; 374 } 375 376 @Override toString()377 public String toString() { 378 StringBuilder builder = new StringBuilder(); 379 builder.append(urlEncode(name)); 380 if (subparams.isEmpty()) { 381 return builder.toString(); 382 } 383 builder.append("("); 384 builder.append(TextUtils.join(",", subparams)); 385 builder.append(")"); 386 return builder.toString(); 387 } 388 389 /** Builder for a fragment param value. */ 390 public static final class Builder { 391 private final String name; 392 private final List<SubParam> subparams = new ArrayList<SubParam>(); 393 Builder(String name, @Nullable List<SubParam> subparams)394 private Builder(String name, @Nullable List<SubParam> subparams) { 395 this.name = name; 396 if (subparams == null) { 397 return; 398 } 399 for (SubParam subparam : subparams) { 400 addSubParam(subparam); 401 } 402 } 403 404 /** The name of the param value. */ name()405 public String name() { 406 return name; 407 } 408 409 /** Get all of the subparams as a list. */ subparams()410 public List<SubParam> subparams() { 411 return Collections.unmodifiableList(subparams); 412 } 413 414 /** Finds a subparam with the given key. If not found, returns null. */ 415 @Nullable findSubParam(String key)416 public SubParam findSubParam(String key) { 417 for (SubParam subparam : subparams) { 418 if (subparam.key.equals(key)) { 419 return subparam; 420 } 421 } 422 return null; 423 } 424 425 /** 426 * Finds the subparam value with the given key. If the subparam or value is null, returns 427 * null. 428 * 429 * @param key 430 * @return The value of the subparam or null. 431 */ 432 @Nullable findSubParamValue(String key)433 public String findSubParamValue(String key) { 434 SubParam subparam = findSubParam(key); 435 return (subparam == null) ? null : subparam.value; 436 } 437 438 /** 439 * Adds a subparam. If an existing subparam exists with the same key, this will replace it. 440 * 441 * @param subparam 442 * @return The subparam or null if not found. 443 */ 444 @CanIgnoreReturnValue addSubParam(SubParam subparam)445 public Builder addSubParam(SubParam subparam) { 446 for (int i = 0; i < subparams.size(); i++) { 447 if (subparams.get(i).key.equals(subparam.key)) { 448 subparams.set(i, subparam); 449 return this; 450 } 451 } 452 subparams.add(subparam); 453 return this; 454 } 455 456 /** 457 * Shortcut to add a subparam with this key and value. Replaces existing subparam with same 458 * key if present. 459 * 460 * @param key The subparam key. 461 * @param value The subparam value. 462 */ 463 @CanIgnoreReturnValue addSubParam(String key, String value)464 public Builder addSubParam(String key, String value) { 465 return addSubParam(new SubParam(key, value)); 466 } 467 468 /** Build an immutable ParamValue from this builder. */ build()469 public ParamValue build() { 470 return new ParamValue(name, subparams); 471 } 472 } 473 } 474 475 /** A fragment param value subparam. */ 476 public static final class SubParam { 477 478 private final String key; 479 @Nullable private final String value; 480 481 /** Creates a new subparam with the given key and value. */ build(String key, String value)482 public static SubParam build(String key, String value) { 483 return new SubParam(key, value); 484 } 485 486 /** Creates a new subparam with the given key and no value. */ build(String key)487 public static SubParam build(String key) { 488 return new SubParam(key, null); 489 } 490 SubParam(String key, @Nullable String value)491 private SubParam(String key, @Nullable String value) { 492 this.key = key; 493 this.value = value; 494 } 495 496 /** Returns the subparam key. */ key()497 public String key() { 498 return key; 499 } 500 501 /** Returns the subparam value, or null if not set. */ 502 @Nullable value()503 public String value() { 504 return value; 505 } 506 507 /** Returns true if the subparam has a value set. */ hasValue()508 public boolean hasValue() { 509 return value != null; 510 } 511 512 @Override toString()513 public String toString() { 514 if (hasValue()) { 515 return urlEncode(key) + "=" + urlEncode(value); 516 } else { 517 return urlEncode(key); 518 } 519 } 520 } 521 522 /** Parses a fragment from the uri as described in {@link Fragment}. */ parse(Uri uri)523 public static Fragment parse(Uri uri) { 524 return parse(uri.getEncodedFragment()); 525 } 526 527 /** Parses a fragment from an encoded string as described in {@link Fragment}. */ parse(@ullable String encodedFragment)528 public static Fragment parse(@Nullable String encodedFragment) { 529 if (TextUtils.isEmpty(encodedFragment)) { 530 return EMPTY_FRAGMENT; 531 } 532 List<Param.Builder> params = new ArrayList<Param.Builder>(); 533 for (String kvPair : encodedFragment.split("&")) { 534 String[] kv = kvPair.split("=", 2); 535 List<ParamValue.Builder> values = new ArrayList<ParamValue.Builder>(); 536 String key = kv[0]; 537 Preconditions.checkArgument(!TextUtils.isEmpty(key), "malformed key: %s", encodedFragment); 538 Preconditions.checkArgument( 539 kv.length == 2 && !TextUtils.isEmpty(kv[1]), "missing param value: %s", encodedFragment); 540 String rawValues = kv[1]; 541 String[] splitValues = rawValues.split("\\+"); 542 for (int i = 0; i < splitValues.length; i++) { 543 String value = splitValues[i]; 544 if (value.isEmpty()) { 545 continue; 546 } 547 List<SubParam> subparams = null; 548 int lparen = value.indexOf("("); 549 if (lparen != -1) { 550 String rawSubparams = value.substring(lparen); 551 Preconditions.checkArgument( 552 rawSubparams.charAt(0) == '(' 553 && rawSubparams.charAt(rawSubparams.length() - 1) == ')', 554 "malformed fragment subparams: %s", 555 encodedFragment); 556 subparams = parseSubParams(rawSubparams.substring(1, rawSubparams.length() - 1)); 557 value = value.substring(0, lparen); 558 } else { 559 Preconditions.checkArgument( 560 !value.contains(")"), "malformed fragment subparams: %s", encodedFragment); 561 } 562 values.add(new ParamValue.Builder(urlDecode(value), subparams)); 563 } 564 params.add(new Param.Builder(urlDecode(key), values)); 565 } 566 return new Fragment.Builder(params).build(); 567 } 568 569 // TODO: This method probably should be elsewhere, perhaps in a lite fragment helper class. 570 @Nullable getTransformSubParam(Uri uri, String transformName, String subParamKey)571 public static String getTransformSubParam(Uri uri, String transformName, String subParamKey) { 572 Fragment.ParamValue value = getTransformParamValue(uri, transformName); 573 if (value == null) { 574 return null; 575 } 576 String result = value.findSubParamValue(subParamKey); 577 return !TextUtils.isEmpty(result) ? result : null; 578 } 579 580 @Nullable getTransformParamValue(Uri uri, String transformName)581 public static Fragment.ParamValue getTransformParamValue(Uri uri, String transformName) { 582 Fragment.Param param = Fragment.parse(uri).findParam("transform"); 583 if (param == null) { 584 return null; 585 } 586 return param.findValue(transformName); 587 } 588 parseSubParams(String rawSubparams)589 private static List<SubParam> parseSubParams(String rawSubparams) { 590 List<SubParam> subparams = new ArrayList<SubParam>(); 591 String[] pairs = rawSubparams.split(","); 592 for (int i = 0; i < pairs.length; i++) { 593 String[] kv = pairs[i].split("=", 2); 594 String key = kv[0]; 595 Preconditions.checkArgument( 596 !TextUtils.isEmpty(key), "missing fragment subparam key: %s", rawSubparams); 597 if (kv.length == 2 && !TextUtils.isEmpty(kv[1])) { 598 subparams.add(new SubParam(urlDecode(key), urlDecode(kv[1]))); 599 } else { 600 subparams.add(new SubParam(urlDecode(key), null)); 601 } 602 } 603 return subparams; 604 } 605 urlEncode(String str)606 private static final String urlEncode(String str) { 607 try { 608 return URLEncoder.encode(str, UTF_8.displayName()); 609 } catch (UnsupportedEncodingException e) { 610 throw new IllegalStateException(); // Not really 611 } 612 } 613 urlDecode(String str)614 private static final String urlDecode(String str) { 615 try { 616 return URLDecoder.decode(str, UTF_8.displayName()); 617 } catch (UnsupportedEncodingException e) { 618 throw new IllegalStateException(); // Not really 619 } 620 } 621 } 622