chromium/android_webview/java/src/org/chromium/android_webview/client_hints/AwUserAgentMetadata.java

// Copyright 2023 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

package org.chromium.android_webview.client_hints;

import androidx.annotation.NonNull;
import androidx.annotation.StringDef;

import org.jni_zero.CalledByNative;
import org.jni_zero.JNINamespace;

import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.util.Arrays;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;

/**
 * A class that defines user-agent metadata, it's used to override user-agent client hints.
 *
 * To provide a better experience on using the WebView public API to override user-agent client
 * hints API, this class is implemented a little different from the existing blink Chromium
 * UserAgentMetadata. See: third_party/blink/public/common/user_agent/user_agent_metadata.h.
 */
@JNINamespace("android_webview")
public class AwUserAgentMetadata {
    private String[][] mBrandVersionList;
    private String mFullVersion;
    private String mPlatform;
    private String mPlatformVersion;
    private String mArchitecture;
    private String mModel;
    private boolean mMobile;
    private int mBitness;
    private boolean mWow64;
    private @FormFactors String[] mFormFactors;

    /** Key for the user-agent metadata properties. */
    @StringDef({
        MetadataKeys.BRAND_VERSION_LIST,
        MetadataKeys.FULL_VERSION,
        MetadataKeys.PLATFORM,
        MetadataKeys.PLATFORM_VERSION,
        MetadataKeys.ARCHITECTURE,
        MetadataKeys.MODEL,
        MetadataKeys.MOBILE,
        MetadataKeys.BITNESS,
        MetadataKeys.WOW64,
        MetadataKeys.FORM_FACTORS
    })
    @Retention(RetentionPolicy.SOURCE)
    public @interface MetadataKeys {
        String BRAND_VERSION_LIST = "BRAND_VERSION_LIST";
        String FULL_VERSION = "FULL_VERSION";
        String PLATFORM = "PLATFORM";
        String PLATFORM_VERSION = "PLATFORM_VERSION";
        String ARCHITECTURE = "ARCHITECTURE";
        String MODEL = "MODEL";
        String MOBILE = "MOBILE";
        String BITNESS = "BITNESS";
        String WOW64 = "WOW64";
        String FORM_FACTORS = "FORM_FACTORS";
    };

    public static final int BITNESS_DEFAULT = 0;

    /** each brand should contains brand, major version and full version. */
    public static final int BRAND_VERSION_LENGTH = 3;

    /**
     * Values for the Sec-CH-UA-Form-Factors header.
     * https://wicg.github.io/ua-client-hints/#sec-ch-ua-form-factors
     */
    // LINT.IfChange
    @StringDef({
        FormFactors.DESKTOP,
        FormFactors.AUTOMOTIVE,
        FormFactors.MOBILE,
        FormFactors.TABLET,
        FormFactors.XR,
        FormFactors.EINK,
        FormFactors.WATCH
    })
    // LINT.ThenChange(/third_party/blink/public/common/user_agent/user_agent_metadata.h)
    @Retention(RetentionPolicy.SOURCE)
    public @interface FormFactors {
        String DESKTOP = "Desktop";
        String AUTOMOTIVE = "Automotive";
        String MOBILE = "Mobile";
        String TABLET = "Tablet";
        String XR = "XR";
        String EINK = "EInk";
        String WATCH = "Watch";
    };

    // To better manage the data within this class, make the constructor as private to avoid
    // creating instances outside of the class.
    private AwUserAgentMetadata() {}

    public AwUserAgentMetadata shallowCopy() {
        AwUserAgentMetadata copy = new AwUserAgentMetadata();
        copy.mBrandVersionList = mBrandVersionList;
        copy.mFullVersion = mFullVersion;
        copy.mPlatform = mPlatform;
        copy.mPlatformVersion = mPlatformVersion;
        copy.mArchitecture = mArchitecture;
        copy.mModel = mModel;
        copy.mMobile = mMobile;
        copy.mBitness = mBitness;
        copy.mWow64 = mWow64;
        copy.mFormFactors = mFormFactors;
        return copy;
    }

    private static int getIntBitnessFromString(String bitness) {
        try {
            return Integer.parseInt(bitness);
        } catch (NumberFormatException e) {
            return BITNESS_DEFAULT;
        }
    }

    private static String getFullVersionFromBandList(
            String[][] brandFullVersionList, String brand) {
        if (brandFullVersionList == null) {
            return "";
        }

        for (String[] bv : brandFullVersionList) {
            if (bv != null && bv.length == 2 && Objects.equals(bv[0], brand)) {
                return bv[1];
            }
        }
        return "";
    }

    @CalledByNative
    private String[][] getBrandVersionList() {
        return mBrandVersionList;
    }

    @CalledByNative
    private String getFullVersion() {
        return mFullVersion;
    }

    @CalledByNative
    private String getPlatform() {
        return mPlatform;
    }

    @CalledByNative
    private String getPlatformVersion() {
        return mPlatformVersion;
    }

    @CalledByNative
    private String getArchitecture() {
        return mArchitecture;
    }

    @CalledByNative
    private String getModel() {
        return mModel;
    }

    @CalledByNative
    private boolean isMobile() {
        return mMobile;
    }

    @CalledByNative
    private int getBitness() {
        return mBitness;
    }

    @CalledByNative
    private boolean isWow64() {
        return mWow64;
    }

    @CalledByNative
    private @FormFactors String[] getFormFactors() {
        return mFormFactors;
    }

    /**
     * Construct a AwUserAgentMetadata instance, and low-entropy client hints should not be null.
     */
    @CalledByNative
    private static AwUserAgentMetadata create(
            @NonNull String[][] brandVersionList,
            String[][] brandFullVersionList,
            String fullVersion,
            @NonNull String platform,
            String platformVersion,
            String architecture,
            String model,
            boolean mobile,
            String bitness,
            boolean wow64,
            @FormFactors String[] formFactors) {
        AwUserAgentMetadata result = new AwUserAgentMetadata();
        result.mBrandVersionList = new String[brandVersionList.length][BRAND_VERSION_LENGTH];
        for (int i = 0; i < brandVersionList.length; i++) {
            result.mBrandVersionList[i][0] = brandVersionList[i][0]; // brand
            result.mBrandVersionList[i][1] = brandVersionList[i][1]; // majorVersion
            result.mBrandVersionList[i][2] =
                    getFullVersionFromBandList(
                            brandFullVersionList, brandVersionList[i][0]); // fullVersion
        }
        result.mFullVersion = fullVersion;
        result.mPlatform = platform;
        result.mPlatformVersion = platformVersion;
        result.mArchitecture = architecture;
        result.mModel = model;
        result.mMobile = mobile;
        result.mBitness = getIntBitnessFromString(bitness);
        result.mWow64 = wow64;
        result.mFormFactors = formFactors;
        return result;
    }

    private static String getValueAsString(
            Map<String, Object> map, @MetadataKeys String key, String defaultValue) {
        Object value = map.get(key);
        if (value != null && !(value instanceof String)) {
            throw new IllegalArgumentException(
                    "AwUserAgentMetadata map does not have "
                            + "right type of value for key: "
                            + key);
        }
        if (value != null) {
            return (String) value;
        }
        return defaultValue;
    }

    private static boolean getValueAsBoolean(
            Map<String, Object> map, @MetadataKeys String key, boolean defaultValue) {
        Object value = map.get(key);
        if (value != null && !(value instanceof Boolean)) {
            throw new IllegalArgumentException(
                    "AwUserAgentMetadata map does not have "
                            + "right type of value for key: "
                            + key);
        }
        if (value != null) {
            return (Boolean) value;
        }
        return defaultValue;
    }

    private static int getValueAsInt(
            Map<String, Object> map, @MetadataKeys String key, int defaultValue) {
        Object value = map.get(key);
        if (value != null && !(value instanceof Integer)) {
            throw new IllegalArgumentException(
                    "AwUserAgentMetadata map does not have "
                            + "right type of value for key: "
                            + key);
        }
        if (value != null) {
            return (Integer) value;
        }
        return defaultValue;
    }

    /**
     * Return an instance based on the provided override user-agent metadata map and the default
     * user-agent metadata settings.
     *
     * Here we only validate some basic requirements for the input, we need to do more strictly
     * validation on Android public API, like check whether brand full version either all empty or
     * all non-empty. Return a boolean indicate whether it needs to update the user-agent metadata.
     *
     * @param uaMetadataMap an object represent what users intend to override user-agent metadata
     *         setting.
     * @param defaultData an object represent system default user-agent metadata.
     * @return For system default override settings, we maintain a shallow copy instance of
     *         AwUserAgentMetadata, while for outside override settings(e.g brand version array)
     *         we will deep copy them when constructing a new instance of AwUserAgentMetadata.
     */
    public static AwUserAgentMetadata fromMap(
            Map<String, Object> uaMetadataMap, @NonNull AwUserAgentMetadata defaultData) {
        if (uaMetadataMap == null || uaMetadataMap.isEmpty()) {
            return defaultData.shallowCopy();
        }

        Object brandVersionValue = uaMetadataMap.get(MetadataKeys.BRAND_VERSION_LIST);
        String[][] brandVersionList = defaultData.mBrandVersionList;
        if (brandVersionValue != null) {
            if (!(brandVersionValue instanceof String[][])) {
                throw new IllegalArgumentException(
                        "AwUserAgentMetadata map does not have "
                                + "right type of value for key: "
                                + MetadataKeys.BRAND_VERSION_LIST);
            }
            String[][] overrideBrandVersionList = (String[][]) brandVersionValue;
            brandVersionList = new String[overrideBrandVersionList.length][];
            for (int i = 0; i < overrideBrandVersionList.length; i++) {
                String[] brandVersionInfo = overrideBrandVersionList[i];
                if (brandVersionInfo.length != BRAND_VERSION_LENGTH) {
                    throw new IllegalArgumentException(
                            "AwUserAgentMetadata map does not have "
                                    + "right type of value for key: "
                                    + MetadataKeys.BRAND_VERSION_LIST
                                    + ", expect brand item length:"
                                    + BRAND_VERSION_LENGTH
                                    + ", actual:"
                                    + brandVersionInfo.length);
                }
                if (brandVersionInfo[0] == null
                        || brandVersionInfo[1] == null
                        || brandVersionInfo[2] == null) {
                    throw new IllegalArgumentException(
                            "AwUserAgentMetadata map does not have "
                                    + "right type of value for key: "
                                    + MetadataKeys.BRAND_VERSION_LIST
                                    + ", brand item should not set as null");
                }
                brandVersionList[i] = Arrays.copyOf(brandVersionInfo, brandVersionInfo.length);
            }
        }

        Object formFactorsValue = uaMetadataMap.get(MetadataKeys.FORM_FACTORS);
        @FormFactors String[] formFactors = defaultData.mFormFactors;
        if (formFactorsValue != null) {
            if (!(formFactorsValue instanceof String[])) {
                throw new IllegalArgumentException(
                        "AwUserAgentMetadata map does not have "
                                + "right type of value for key: "
                                + MetadataKeys.FORM_FACTORS);
            }
            @FormFactors String[] asArray = (String[]) formFactorsValue;
            formFactors = Arrays.copyOf(asArray, asArray.length);
        }

        AwUserAgentMetadata result = new AwUserAgentMetadata();
        result.mBrandVersionList = brandVersionList;
        result.mFullVersion =
                getValueAsString(
                        uaMetadataMap, MetadataKeys.FULL_VERSION, defaultData.mFullVersion);
        result.mPlatform =
                getValueAsString(uaMetadataMap, MetadataKeys.PLATFORM, defaultData.mPlatform);
        result.mPlatformVersion =
                getValueAsString(
                        uaMetadataMap, MetadataKeys.PLATFORM_VERSION, defaultData.mPlatformVersion);
        result.mArchitecture =
                getValueAsString(
                        uaMetadataMap, MetadataKeys.ARCHITECTURE, defaultData.mArchitecture);
        result.mModel = getValueAsString(uaMetadataMap, MetadataKeys.MODEL, defaultData.mModel);
        result.mMobile = getValueAsBoolean(uaMetadataMap, MetadataKeys.MOBILE, defaultData.mMobile);
        result.mBitness = getValueAsInt(uaMetadataMap, MetadataKeys.BITNESS, defaultData.mBitness);
        result.mWow64 = getValueAsBoolean(uaMetadataMap, MetadataKeys.WOW64, defaultData.mWow64);
        result.mFormFactors = formFactors;
        return result;
    }

    public Map<String, Object> toMapObject() {
        Map<String, Object> item = new HashMap<>();
        item.put(MetadataKeys.BRAND_VERSION_LIST, mBrandVersionList);
        item.put(MetadataKeys.FULL_VERSION, mFullVersion);
        item.put(MetadataKeys.PLATFORM, mPlatform);
        item.put(MetadataKeys.PLATFORM_VERSION, mPlatformVersion);
        item.put(MetadataKeys.ARCHITECTURE, mArchitecture);
        item.put(MetadataKeys.MODEL, mModel);
        item.put(MetadataKeys.MOBILE, mMobile);
        item.put(MetadataKeys.BITNESS, mBitness);
        item.put(MetadataKeys.WOW64, mWow64);
        item.put(MetadataKeys.FORM_FACTORS, mFormFactors);
        return item;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) {
            return true;
        }
        if (!(o instanceof AwUserAgentMetadata)) {
            return false;
        }
        AwUserAgentMetadata that = (AwUserAgentMetadata) o;
        return mMobile == that.mMobile
                && mWow64 == that.mWow64
                && mBitness == that.mBitness
                && Arrays.deepEquals(mBrandVersionList, that.mBrandVersionList)
                && Objects.equals(mFullVersion, that.mFullVersion)
                && Objects.equals(mPlatform, that.mPlatform)
                && Objects.equals(mPlatformVersion, that.mPlatformVersion)
                && Objects.equals(mArchitecture, that.mArchitecture)
                && Objects.equals(mModel, that.mModel)
                && Arrays.deepEquals(mFormFactors, that.mFormFactors);
    }

    @Override
    public int hashCode() {
        return Objects.hash(
                Arrays.deepHashCode(mBrandVersionList),
                mFullVersion,
                mPlatform,
                mPlatformVersion,
                mArchitecture,
                mModel,
                mMobile,
                mBitness,
                mWow64,
                Arrays.deepHashCode(mFormFactors));
    }
}