chromium/chrome/android/java/src/org/chromium/chrome/browser/offlinepages/OfflinePageOrigin.java

// Copyright 2015 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.chrome.browser.offlinepages;

import android.annotation.SuppressLint;
import android.content.Context;
import android.content.pm.PackageManager;
import android.content.pm.PackageManager.NameNotFoundException;
import android.content.pm.Signature;
import android.os.Process;
import android.text.TextUtils;
import android.util.Base64;

import androidx.annotation.VisibleForTesting;

import org.json.JSONArray;
import org.json.JSONException;

import org.chromium.chrome.browser.tab.Tab;
import org.chromium.chrome.browser.tab.TabAssociatedApp;

import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Arrays;

/** Class encapsulating the application origin of a particular offline page request. */
public class OfflinePageOrigin {
    private final String mAppName;
    private final String[] mSignatures;

    /** Creates origin based on the context and tab. */
    public OfflinePageOrigin(Context context, Tab tab) {
        this(context, TabAssociatedApp.getAppId(tab));
    }

    /** Creates origin based on the context and an app name. */
    public OfflinePageOrigin(Context context, String appName) {
        if (TextUtils.isEmpty(appName)) {
            mAppName = "";
            mSignatures = null;
        } else {
            mSignatures = getAppSignaturesFor(context, appName);
            // If signatures returned null, the app probably doesn't exist. Assume Chrome.
            if (mSignatures == null) {
                mAppName = "";
            } else {
                mAppName = appName;
            }
        }
    }

    /** Creates origin based on a qualified string. Assumes Chrome if invalid. */
    public OfflinePageOrigin(String jsonString) {
        String name = "";
        String[] signatures = null;
        try {
            JSONArray info = new JSONArray(jsonString);
            if (info.length() == 2) {
                name = info.getString(0);
                JSONArray signatureInfo = info.getJSONArray(1);
                signatures = new String[signatureInfo.length()];
                for (int i = 0; i < signatures.length; i++) {
                    signatures[i] = signatureInfo.getString(i);
                }
            }
        } catch (JSONException e) {
            // JSON malformed. Set name and signature to default.
            name = "";
            signatures = null;
        } finally {
            mAppName = name;
            mSignatures = signatures;
        }
    }

    /** Creates origin based on uid and context. */
    public OfflinePageOrigin(Context context, int uid) {
        if (uid == Process.myUid()) {
            mAppName = "";
            mSignatures = null;
            return;
        }
        PackageManager pm = context.getPackageManager();
        String[] packages = pm.getPackagesForUid(uid);
        if (packages.length != 1) {
            mAppName = "";
            mSignatures = null;
        } else {
            mAppName = packages[0];
            mSignatures = getAppSignaturesFor(context, mAppName);
        }
    }

    /** Creates a Chrome origin. */
    public OfflinePageOrigin() {
        this("", null);
    }

    @VisibleForTesting
    OfflinePageOrigin(String appName, String[] signatures) {
        mAppName = appName;
        mSignatures = signatures;
    }

    /**
     * Encode the origin information into a JSON string of
     * [appName, [SHA-256 encoded signature, SHA-256 encoded signature...]]
     *
     * @return The JSON encoded origin information or empty string if there is
     *         no app information (ie assuming chrome).
     */
    public String encodeAsJsonString() {
        // We default to "", implying chrome-only if inputs invalid.
        if (isChrome()) return "";
        // JSONArray(Object[]) requires API 19
        JSONArray signatureArray = new JSONArray();
        for (String s : mSignatures) signatureArray.put(s);
        return new JSONArray().put(mAppName).put(signatureArray).toString();
    }

    /**
     * Returns whether the signature recorded in this origin matches the signature
     * in the context.
     *
     * Returns true if this origin is Chrome.
     */
    public boolean doesSignatureMatch(Context context) {
        String[] currentSignatures = getAppSignaturesFor(context, mAppName);
        return Arrays.equals(mSignatures, currentSignatures);
    }

    /** Returns whether this origin is chrome. */
    public boolean isChrome() {
        return TextUtils.isEmpty(mAppName) || mSignatures == null;
    }

    /** Returns the application package name of this origin. */
    public String getAppName() {
        return mAppName;
    }

    @Override
    public String toString() {
        return encodeAsJsonString();
    }

    @Override
    public boolean equals(Object other) {
        if (other != null && other instanceof OfflinePageOrigin) {
            OfflinePageOrigin o = (OfflinePageOrigin) other;
            return mAppName.equals(o.mAppName) && Arrays.equals(mSignatures, o.mSignatures);
        }
        return false;
    }

    @Override
    public int hashCode() {
        return Arrays.deepHashCode(new Object[] {mAppName, mSignatures});
    }

    /**
     * @param context The context to look up signatures.
     * @param appName The name of the application to look up.
     * @return a sorted list of strings representing the signatures of an app.
     *          Null if the app name is invalid or cannot be found.
     */
    @SuppressLint("PackageManagerGetSignatures")
    // https://stackoverflow.com/questions/39192844/android-studio-warning-when-using-packagemanager-get-signatures
    private static String[] getAppSignaturesFor(Context context, String appName) {
        if (TextUtils.isEmpty(appName)) return null;
        try {
            PackageManager packageManager = context.getPackageManager();
            Signature[] signatureList =
                    packageManager.getPackageInfo(appName, PackageManager.GET_SIGNATURES)
                            .signatures;
            MessageDigest messageDigest = MessageDigest.getInstance("SHA-256");
            String[] sigStrings = new String[signatureList.length];
            for (int i = 0; i < sigStrings.length; i++) {
                messageDigest.update(signatureList[i].toByteArray());

                // The digest is reset after completing the hash computation.
                sigStrings[i] = byteArrayToString(messageDigest.digest());
            }
            Arrays.sort(sigStrings);
            return sigStrings;
        } catch (NameNotFoundException e) {
            return null; // Cannot find the app anymore. No signatures.
        } catch (NoSuchAlgorithmException e) {
            return null; // Cannot find the SHA-256 encryption algorithm. Shouldn't happen.
        }
    }

    /**
     * Formats bytes into a string for easier comparison.
     *
     * @param input Input bytes.
     * @return A string representation of the input bytes, e.g., "0123456789abcdefg"
     */
    private static String byteArrayToString(byte[] input) {
        if (input == null) return null;

        return Base64.encodeToString(input, Base64.DEFAULT);
    }
}