/* * Copyright 2022 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.android.libraries.mobiledatadownload.file.common; import static com.google.android.libraries.mobiledatadownload.file.common.internal.Charsets.UTF_8; import android.net.Uri; import android.text.TextUtils; import com.google.android.libraries.mobiledatadownload.file.common.internal.Preconditions; import com.google.errorprone.annotations.CanIgnoreReturnValue; import java.io.UnsupportedEncodingException; import java.net.URLDecoder; import java.net.URLEncoder; import java.util.ArrayList; import java.util.Collections; import java.util.List; import javax.annotation.Nullable; /** * A URI fragment parser and builder. Parses fragment params is similar to parsing of query params, * and are delimited by "&". For example, #foo=bar&us=them * *

produces two params: one "foo" with value "bar", and another "us" with value "them". * *

Each fragment param must have at least one value; multiple values are delimited by "+". For * example, #foo=bar+baz * *

produces one param, "foo", with two values "bar" and "baz". * *

Furthermore, fragment values can have subparams, which are additional information scoped to * the value. Subparams have keys and optional values, and are delimited by ",". For example, * #foo=bar(x=1)+baz(this=that,when) * *

produces one param, "foo", with two values "bar" and "baz" where "bar" has subparam "x" set at * 1, baz has subparam "this" set at "that", and "when" is unset. * *

While the spec requires that keys and values are [0-9a-zA-Z-_]+, this class * encodes/decodes keys/values, including subparams using java.net.URLEncoder/decoder. In * particular, this class is responsible for producing strings suitable for being appended verbatim * to the fragment part of an RFC3986 URI. */ public final class Fragment { public static final Fragment EMPTY_FRAGMENT = new Fragment(null); public static final Fragment.ParamValue EMPTY_FRAGMENT_PARAM_VALUE = new ParamValue(null, null); private final List params = new ArrayList(); private Fragment(@Nullable List params) { if (params != null) { this.params.addAll(params); } } /** Create a new, empty builder. */ public static Fragment.Builder builder() { return new Fragment.Builder(null); } /** Return a builder based on this Fragment. */ public Fragment.Builder toBuilder() { Fragment.Builder builder = builder(); for (Param param : params) { builder.addParam(param.toBuilder()); } return builder; } /** Iterate over the params. */ public List params() { return Collections.unmodifiableList(params); } /** Finds the param with the given key. Returns null if not found. */ @Nullable public Param findParam(String key) { for (Param param : params) { if (param.key.equals(key)) { return param; } } return null; } @Override public String toString() { return TextUtils.join("&", params); } /** Builder for the whole URI fragment. */ public static final class Builder { private final List params = new ArrayList(); private Builder(@Nullable List params) { if (params == null) { return; } for (Param.Builder param : params) { addParam(param); } } /** Get all of the params as a list. */ public List params() { return Collections.unmodifiableList(params); } /** Finds the param with the given key. Returns null if not found. */ @Nullable public Param.Builder findParam(String key) { for (Param.Builder param : params) { if (param.key.equals(key)) { return param; } } return null; } /** Adds a param. If a param with same key already exists, this replaces it. */ @CanIgnoreReturnValue public Builder addParam(Param param) { addParam(param.toBuilder()); return this; } /** Adds a param. If a param with the same key already exist, this replaces it. */ @CanIgnoreReturnValue public Builder addParam(Param.Builder param) { for (int i = 0; i < params.size(); i++) { if (params.get(i).key.equals(param.key)) { params.set(i, param); return this; } } params.add(param); return this; } /** Adds a simple param with no value. */ @CanIgnoreReturnValue public Builder addParam(String key) { return addParam(Param.builder(key)); } /** Return an immutable Fragment. Unset params are ignored. */ public Fragment build() { List params = new ArrayList(); for (Param.Builder builder : this.params) { Param param = builder.build(); if (param != null) { params.add(param); } } return new Fragment(params); } } /** A fragment param. */ public static final class Param { private final String key; private final List values = new ArrayList(); /** * @throws IllegalArgumentException if {@code values} is empty. */ private Param(String key, List values) { Preconditions.checkArgument(!values.isEmpty(), "Missing param values"); this.key = key; this.values.addAll(values); } /** Gets the key for the param. */ public String key() { return key; } /** Iterate over the values. */ public List values() { return Collections.unmodifiableList(values); } /** Find a value by name. Returns null if not found. */ @Nullable public ParamValue findValue(String name) { for (ParamValue value : values) { if (value.name.equals(name)) { return value; } } return null; } /** * Create a new param identified with a key. * * @param key The unique key. * @return The param. */ public static Param.Builder builder(String key) { return new Param.Builder(key, null); } /** Return a builder based on this Param. */ public Param.Builder toBuilder() { Param.Builder builder = builder(key); for (ParamValue value : values) { builder.addValue(value.toBuilder()); } return builder; } @Override public String toString() { StringBuilder builder = new StringBuilder(); builder.append(urlEncode(key)); builder.append("="); builder.append(TextUtils.join("+", values)); return builder.toString(); } /** Builder for a fragment param. */ public static final class Builder { private final String key; private final List values = new ArrayList(); private Builder(String key, @Nullable List values) { this.key = key; if (values == null) { return; } for (ParamValue.Builder value : values) { addValue(value); } } /** Gets the key for the param. */ public String key() { return key; } /** Get all of the param values as a list. */ public List values() { return Collections.unmodifiableList(values); } /** Find a value by name. Returns null if not found. */ @Nullable public ParamValue.Builder findValue(String name) { for (ParamValue.Builder value : values) { if (value.name.equals(name)) { return value; } } return null; } /** * Adds a value to this param. If a value already exists with the same name, this will replace * it. */ @CanIgnoreReturnValue public Builder addValue(ParamValue value) { addValue(value.toBuilder()); return this; } /** * Adds a value to this param. If a value already exists with the same name, this will replace * it. */ @CanIgnoreReturnValue public Builder addValue(ParamValue.Builder value) { for (int i = 0; i < values.size(); i++) { if (values.get(i).name.equals(value.name)) { values.set(i, value); return this; } } values.add(value); return this; } /** Adds a value that has no subparams. Also replaces existing value if present. */ @CanIgnoreReturnValue public Builder addValue(String name) { return addValue(new ParamValue.Builder(name, null)); } /** Return a new immutable Param from this builder, or null if the param is unset. */ @Nullable public Param build() { if (this.values.isEmpty()) { return null; } List values = new ArrayList(); for (ParamValue.Builder value : this.values) { values.add(value.build()); } return new Param(key, values); } } } /** A value of a fragment param. Each fragment param can have multiple values. */ public static final class ParamValue { private final String name; private final List subparams = new ArrayList(); private ParamValue(String name, @Nullable List subparams) { this.name = name; if (subparams != null) { this.subparams.addAll(subparams); } } /** Creates a new param value with the given name. */ public static ParamValue.Builder builder(String name) { return new ParamValue.Builder(name, null); } /** Return a builder based on this ParamValue. */ public ParamValue.Builder toBuilder() { ParamValue.Builder builder = builder(name); for (SubParam subparam : subparams) { builder.addSubParam(subparam); } return builder; } /** The name of the param value. */ public String name() { return name; } /** Iterate over the subparams. */ public List subParams() { return Collections.unmodifiableList(subparams); } /** Finds a subparam with the given key. If not found, returns null. */ @Nullable public SubParam findSubParam(String key) { for (SubParam subparam : subparams) { if (subparam.key.equals(key)) { return subparam; } } return null; } /** * Finds the subparam value with the given key. If the subparam or value is null, returns null. * * @param key * @return The value of the subparam or null. */ @Nullable public String findSubParamValue(String key) { SubParam subparam = findSubParam(key); return (subparam == null) ? null : subparam.value; } @Override public String toString() { StringBuilder builder = new StringBuilder(); builder.append(urlEncode(name)); if (subparams.isEmpty()) { return builder.toString(); } builder.append("("); builder.append(TextUtils.join(",", subparams)); builder.append(")"); return builder.toString(); } /** Builder for a fragment param value. */ public static final class Builder { private final String name; private final List subparams = new ArrayList(); private Builder(String name, @Nullable List subparams) { this.name = name; if (subparams == null) { return; } for (SubParam subparam : subparams) { addSubParam(subparam); } } /** The name of the param value. */ public String name() { return name; } /** Get all of the subparams as a list. */ public List subparams() { return Collections.unmodifiableList(subparams); } /** Finds a subparam with the given key. If not found, returns null. */ @Nullable public SubParam findSubParam(String key) { for (SubParam subparam : subparams) { if (subparam.key.equals(key)) { return subparam; } } return null; } /** * Finds the subparam value with the given key. If the subparam or value is null, returns * null. * * @param key * @return The value of the subparam or null. */ @Nullable public String findSubParamValue(String key) { SubParam subparam = findSubParam(key); return (subparam == null) ? null : subparam.value; } /** * Adds a subparam. If an existing subparam exists with the same key, this will replace it. * * @param subparam * @return The subparam or null if not found. */ @CanIgnoreReturnValue public Builder addSubParam(SubParam subparam) { for (int i = 0; i < subparams.size(); i++) { if (subparams.get(i).key.equals(subparam.key)) { subparams.set(i, subparam); return this; } } subparams.add(subparam); return this; } /** * Shortcut to add a subparam with this key and value. Replaces existing subparam with same * key if present. * * @param key The subparam key. * @param value The subparam value. */ @CanIgnoreReturnValue public Builder addSubParam(String key, String value) { return addSubParam(new SubParam(key, value)); } /** Build an immutable ParamValue from this builder. */ public ParamValue build() { return new ParamValue(name, subparams); } } } /** A fragment param value subparam. */ public static final class SubParam { private final String key; @Nullable private final String value; /** Creates a new subparam with the given key and value. */ public static SubParam build(String key, String value) { return new SubParam(key, value); } /** Creates a new subparam with the given key and no value. */ public static SubParam build(String key) { return new SubParam(key, null); } private SubParam(String key, @Nullable String value) { this.key = key; this.value = value; } /** Returns the subparam key. */ public String key() { return key; } /** Returns the subparam value, or null if not set. */ @Nullable public String value() { return value; } /** Returns true if the subparam has a value set. */ public boolean hasValue() { return value != null; } @Override public String toString() { if (hasValue()) { return urlEncode(key) + "=" + urlEncode(value); } else { return urlEncode(key); } } } /** Parses a fragment from the uri as described in {@link Fragment}. */ public static Fragment parse(Uri uri) { return parse(uri.getEncodedFragment()); } /** Parses a fragment from an encoded string as described in {@link Fragment}. */ public static Fragment parse(@Nullable String encodedFragment) { if (TextUtils.isEmpty(encodedFragment)) { return EMPTY_FRAGMENT; } List params = new ArrayList(); for (String kvPair : encodedFragment.split("&")) { String[] kv = kvPair.split("=", 2); List values = new ArrayList(); String key = kv[0]; Preconditions.checkArgument(!TextUtils.isEmpty(key), "malformed key: %s", encodedFragment); Preconditions.checkArgument( kv.length == 2 && !TextUtils.isEmpty(kv[1]), "missing param value: %s", encodedFragment); String rawValues = kv[1]; String[] splitValues = rawValues.split("\\+"); for (int i = 0; i < splitValues.length; i++) { String value = splitValues[i]; if (value.isEmpty()) { continue; } List subparams = null; int lparen = value.indexOf("("); if (lparen != -1) { String rawSubparams = value.substring(lparen); Preconditions.checkArgument( rawSubparams.charAt(0) == '(' && rawSubparams.charAt(rawSubparams.length() - 1) == ')', "malformed fragment subparams: %s", encodedFragment); subparams = parseSubParams(rawSubparams.substring(1, rawSubparams.length() - 1)); value = value.substring(0, lparen); } else { Preconditions.checkArgument( !value.contains(")"), "malformed fragment subparams: %s", encodedFragment); } values.add(new ParamValue.Builder(urlDecode(value), subparams)); } params.add(new Param.Builder(urlDecode(key), values)); } return new Fragment.Builder(params).build(); } // TODO: This method probably should be elsewhere, perhaps in a lite fragment helper class. @Nullable public static String getTransformSubParam(Uri uri, String transformName, String subParamKey) { Fragment.ParamValue value = getTransformParamValue(uri, transformName); if (value == null) { return null; } String result = value.findSubParamValue(subParamKey); return !TextUtils.isEmpty(result) ? result : null; } @Nullable public static Fragment.ParamValue getTransformParamValue(Uri uri, String transformName) { Fragment.Param param = Fragment.parse(uri).findParam("transform"); if (param == null) { return null; } return param.findValue(transformName); } private static List parseSubParams(String rawSubparams) { List subparams = new ArrayList(); String[] pairs = rawSubparams.split(","); for (int i = 0; i < pairs.length; i++) { String[] kv = pairs[i].split("=", 2); String key = kv[0]; Preconditions.checkArgument( !TextUtils.isEmpty(key), "missing fragment subparam key: %s", rawSubparams); if (kv.length == 2 && !TextUtils.isEmpty(kv[1])) { subparams.add(new SubParam(urlDecode(key), urlDecode(kv[1]))); } else { subparams.add(new SubParam(urlDecode(key), null)); } } return subparams; } private static final String urlEncode(String str) { try { return URLEncoder.encode(str, UTF_8.displayName()); } catch (UnsupportedEncodingException e) { throw new IllegalStateException(); // Not really } } private static final String urlDecode(String str) { try { return URLDecoder.decode(str, UTF_8.displayName()); } catch (UnsupportedEncodingException e) { throw new IllegalStateException(); // Not really } } }