chromium/chrome/browser/ui/android/omnibox/java/src/org/chromium/chrome/browser/omnibox/SearchEngineUtils.java

// Copyright 2019 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.omnibox;

import androidx.annotation.IntDef;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;

import org.chromium.base.ContextUtils;
import org.chromium.base.Log;
import org.chromium.base.ResettersForTesting;
import org.chromium.base.ThreadUtils;
import org.chromium.base.lifetime.Destroyable;
import org.chromium.base.metrics.RecordHistogram;
import org.chromium.chrome.browser.locale.LocaleManager;
import org.chromium.chrome.browser.omnibox.status.StatusProperties.StatusIconResource;
import org.chromium.chrome.browser.profiles.Profile;
import org.chromium.chrome.browser.profiles.ProfileKeyedMap;
import org.chromium.chrome.browser.search_engines.TemplateUrlServiceFactory;
import org.chromium.chrome.browser.theme.ThemeUtils;
import org.chromium.chrome.browser.ui.favicon.FaviconHelper;
import org.chromium.chrome.browser.ui.theme.BrandedColorScheme;
import org.chromium.components.search_engines.TemplateUrlService;
import org.chromium.components.search_engines.TemplateUrlService.TemplateUrlServiceObserver;
import org.chromium.url.GURL;

import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;

/** Common Default Search Engine functions. */
public class SearchEngineUtils implements Destroyable, TemplateUrlServiceObserver {
    private static final String TAG = "DSEUtils";
    private static ProfileKeyedMap<SearchEngineUtils> sProfileKeyedUtils =
            ProfileKeyedMap.createMapOfDestroyables();
    private static SearchEngineUtils sInstanceForTesting;

    private final @NonNull Profile mProfile;
    private final boolean mIsOffTheRecord;
    private final @NonNull TemplateUrlService mTemplateUrlService;
    private final @NonNull FaviconHelper mFaviconHelper;
    private final int mSearchEngineLogoTargetSizePixels;
    private Boolean mNeedToCheckForSearchEnginePromo;
    private boolean mDoesDefaultSearchEngineHaveLogo;
    private @Nullable StatusIconResource mSearchEngineLogo;

    /**
     * AndroidSearchEngineLogoEvents defined in tools/metrics/histograms/enums.xml. These values are
     * persisted to logs. Entries should not be renumbered and numeric values should never be
     * reused.
     */
    @VisibleForTesting
    @IntDef({
        Events.FETCH_NON_GOOGLE_LOGO_REQUEST,
        Events.FETCH_FAILED_NULL_URL,
        Events.FETCH_FAILED_FAVICON_HELPER_ERROR,
        Events.FETCH_FAILED_RETURNED_BITMAP_NULL,
        Events.FETCH_SUCCESS_CACHE_HIT,
        Events.FETCH_SUCCESS
    })
    @Retention(RetentionPolicy.SOURCE)
    @interface Events {
        int FETCH_NON_GOOGLE_LOGO_REQUEST = 0;
        int FETCH_FAILED_NULL_URL = 1;
        int FETCH_FAILED_FAVICON_HELPER_ERROR = 2;
        int FETCH_FAILED_RETURNED_BITMAP_NULL = 3;
        int FETCH_SUCCESS_CACHE_HIT = 4;
        int FETCH_SUCCESS = 5;

        int MAX = 6;
    }

    @VisibleForTesting
    SearchEngineUtils(Profile profile, FaviconHelper faviconHelper) {
        mProfile = profile;
        mIsOffTheRecord = profile.isOffTheRecord();
        mFaviconHelper = faviconHelper;

        mSearchEngineLogoTargetSizePixels =
                ContextUtils.getApplicationContext()
                        .getResources()
                        .getDimensionPixelSize(R.dimen.omnibox_search_engine_logo_favicon_size);

        mTemplateUrlService = TemplateUrlServiceFactory.getForProfile(profile);
        mTemplateUrlService.addObserver(this);
        onTemplateURLServiceChanged();
    }

    /** Get the instance of SearchEngineUtils associated with the supplied Profile. */
    public static SearchEngineUtils getForProfile(Profile profile) {
        ThreadUtils.assertOnUiThread();
        if (sInstanceForTesting != null) return sInstanceForTesting;

        assert profile != null;
        return sProfileKeyedUtils.getForProfile(profile, SearchEngineUtils::buildForProfile);
    }

    private static SearchEngineUtils buildForProfile(Profile profile) {
        return new SearchEngineUtils(profile, new FaviconHelper());
    }

    @Override
    public void destroy() {
        mTemplateUrlService.removeObserver(this);
    }

    @Override
    public void onTemplateURLServiceChanged() {
        mDoesDefaultSearchEngineHaveLogo = mTemplateUrlService.doesDefaultSearchEngineHaveLogo();

        if (mTemplateUrlService.isDefaultSearchEngineGoogle()) {
            mSearchEngineLogo = new StatusIconResource(R.drawable.ic_logo_googleg_20dp, 0);
        } else {
            mSearchEngineLogo = null;
            recordEvent(Events.FETCH_NON_GOOGLE_LOGO_REQUEST);

            var templateUrl = mTemplateUrlService.getDefaultSearchEngineTemplateUrl();
            if (templateUrl == null) {
                recordEvent(Events.FETCH_FAILED_NULL_URL);
                return;
            }

            var logoUrl = new GURL(templateUrl.getURL()).getOrigin();

            boolean willCall =
                    mFaviconHelper.getLocalFaviconImageForURL(
                            mProfile,
                            logoUrl,
                            mSearchEngineLogoTargetSizePixels,
                            (image, iconUrl) -> {
                                if (image == null) {
                                    recordEvent(Events.FETCH_FAILED_RETURNED_BITMAP_NULL);
                                    return;
                                }
                                mSearchEngineLogo =
                                        new StatusIconResource(logoUrl.getSpec(), image, 0);
                                recordEvent(Events.FETCH_SUCCESS);
                            });

            if (!willCall) recordEvent(Events.FETCH_FAILED_FAVICON_HELPER_ERROR);
        }
    }

    /** Returns whether the search engine logo should be shown. */
    public boolean shouldShowSearchEngineLogo() {
        return !mIsOffTheRecord;
    }

    /**
     * Get the search engine logo favicon. This can return a null bitmap under certain
     * circumstances, such as: no logo url found, network/cache error, etc.
     *
     * @param brandedColorScheme The {@link BrandedColorScheme}, used to tint icons.
     */
    public StatusIconResource getSearchEngineLogo(@BrandedColorScheme int brandedColorScheme) {
        if (needToCheckForSearchEnginePromo() || mSearchEngineLogo == null) {
            return getFallbackSearchIcon(brandedColorScheme);
        }
        recordEvent(Events.FETCH_SUCCESS_CACHE_HIT);
        return mSearchEngineLogo;
    }

    /** Returns an icon to be shown as a fallback Search icon. */
    public static StatusIconResource getFallbackSearchIcon(
            @BrandedColorScheme int brandedColorScheme) {
        return new StatusIconResource(
                R.drawable.ic_search, ThemeUtils.getThemedToolbarIconTintRes(brandedColorScheme));
    }

    /** Returns an icon to be shown as a fallback Navigation icon. */
    public static StatusIconResource getFallbackNavigationIcon(
            @BrandedColorScheme int brandedColorScheme) {
        return new StatusIconResource(
                R.drawable.ic_globe_24dp,
                ThemeUtils.getThemedToolbarIconTintRes(brandedColorScheme));
    }

    /**
     * Returns whether the search engine promo is complete. Once fetchCheckForSearchEnginePromo()
     * returns false the first time, this method will cache that result as it's presumed we don't
     * need to re-run the promo during the process lifetime.
     */
    @VisibleForTesting
    boolean needToCheckForSearchEnginePromo() {
        if (mNeedToCheckForSearchEnginePromo == null || mNeedToCheckForSearchEnginePromo) {
            mNeedToCheckForSearchEnginePromo = fetchCheckForSearchEnginePromo();
            // getCheckForSearchEnginePromo can fail; if it does, we'll stay in the uncached
            // state and return false.
            if (mNeedToCheckForSearchEnginePromo == null) return false;
        }
        return mNeedToCheckForSearchEnginePromo;
    }

    /**
     * Performs a (potentially expensive) lookup of whether we need to check for a search engine
     * promo. In rare cases this can fail; in these cases it will return null.
     */
    private Boolean fetchCheckForSearchEnginePromo() {
        // LocaleManager#needToCheckForSearchEnginePromo() checks several system features which
        // risk throwing exceptions. See the exception cases below for details.
        try {
            return LocaleManager.getInstance().needToCheckForSearchEnginePromo();
        } catch (SecurityException e) {
            Log.e(TAG, "Can be thrown by a failed IPC, see crbug.com/1027709\n", e);
            return null;
        } catch (RuntimeException e) {
            Log.e(TAG, "Can be thrown if underlying services are dead, see crbug.com/1121602\n", e);
            return null;
        }
    }

    /**
     * Records an event to the search engine logo histogram. See {@link Events} and histograms.xml
     * for more details.
     *
     * @param event The {@link Events} to be reported.
     */
    @VisibleForTesting
    void recordEvent(@Events int event) {
        RecordHistogram.recordEnumeratedHistogram(
                "AndroidSearchEngineLogo.Events", event, Events.MAX);
    }

    /** Set the instance for testing. */
    public static void setInstanceForTesting(SearchEngineUtils instance) {
        sInstanceForTesting = instance;
        ResettersForTesting.register(() -> sInstanceForTesting = null);
    }

    /*
     * Returns whether the current search provider has Logo.
     */
    boolean doesDefaultSearchEngineHaveLogo() {
        return mDoesDefaultSearchEngineHaveLogo;
    }
}