/*
* 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
}
}
}