chromium/chrome/android/java/src/org/chromium/chrome/browser/contextualsearch/ContextualSearchPolicy.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.contextualsearch;

import android.content.Context;
import android.net.Uri;
import android.telephony.TelephonyManager;
import android.text.TextUtils;

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

import org.jni_zero.JniType;
import org.jni_zero.NativeMethods;

import org.chromium.base.Log;
import org.chromium.base.shared_preferences.SharedPreferencesManager;
import org.chromium.base.version_info.VersionInfo;
import org.chromium.chrome.browser.compositor.bottombar.contextualsearch.ContextualSearchPanel;
import org.chromium.chrome.browser.contextualsearch.ContextualSearchInternalStateController.InternalState;
import org.chromium.chrome.browser.contextualsearch.ContextualSearchSelectionController.SelectionType;
import org.chromium.chrome.browser.contextualsearch.ContextualSearchUma.ContextualSearchPreference;
import org.chromium.chrome.browser.preferences.ChromeSharedPreferences;
import org.chromium.chrome.browser.preferences.Pref;
import org.chromium.chrome.browser.prefetch.settings.PreloadPagesSettingsBridge;
import org.chromium.chrome.browser.prefetch.settings.PreloadPagesState;
import org.chromium.chrome.browser.profiles.Profile;
import org.chromium.chrome.browser.search_engines.TemplateUrlServiceFactory;
import org.chromium.chrome.browser.signin.services.UnifiedConsentServiceBridge;
import org.chromium.components.embedder_support.util.UrlConstants;
import org.chromium.components.embedder_support.util.UrlUtilities;
import org.chromium.components.prefs.PrefService;
import org.chromium.components.user_prefs.UserPrefs;
import org.chromium.url.GURL;

/** Handles business decision policy for the {@code ContextualSearchManager}. */
class ContextualSearchPolicy {
    private static final String TAG = "ContextualSearch";
    private static final String DOMAIN_GOOGLE = "google";
    private static final String PATH_AMP = "/amp/";
    private static final int REMAINING_NOT_APPLICABLE = -1;
    private static final int TAP_TRIGGERED_PROMO_LIMIT = 50;
    private static final int PROMO_DEFAULT_LIMIT = 3;

    // Constants related to the Contextual Search preference.
    private static final String CONTEXTUAL_SEARCH_DISABLED = "false";
    private static final String CONTEXTUAL_SEARCH_ENABLED = "true";

    private final SharedPreferencesManager mPreferencesManager;
    private final Profile mProfile;
    private final ContextualSearchSelectionController mSelectionController;
    private final RelatedSearchesStamp mRelatedSearchesStamp;
    private ContextualSearchNetworkCommunicator mNetworkCommunicator;
    private ContextualSearchPanel mSearchPanel;

    // Members used only for testing purposes.
    private boolean mDidOverrideFullyEnabledForTesting;
    private boolean mFullyEnabledForTesting;
    private Integer mTapTriggeredPromoLimitForTesting;
    private boolean mDidOverrideAllowSendingPageUrlForTesting;
    private boolean mAllowSendingPageUrlForTesting;
    private Boolean mContextualSearchResolutionUrlValid;

    /** ContextualSearchPolicy constructor. */
    public ContextualSearchPolicy(
            Profile profile,
            ContextualSearchSelectionController selectionController,
            ContextualSearchNetworkCommunicator networkCommunicator) {
        mPreferencesManager = ChromeSharedPreferences.getInstance();

        mProfile = profile;
        mSelectionController = selectionController;
        mNetworkCommunicator = networkCommunicator;
        mRelatedSearchesStamp = new RelatedSearchesStamp(this);
    }

    /**
     * Sets the handle to the ContextualSearchPanel.
     *
     * @param panel The ContextualSearchPanel.
     */
    public void setContextualSearchPanel(ContextualSearchPanel panel) {
        mSearchPanel = panel;
    }

    /**
     * @return The number of additional times to show the promo on tap, 0 if it should not be shown,
     *     or a negative value if the counter has been disabled or the user has accepted the promo.
     */
    int getPromoTapsRemaining() {
        if (!isUserUndecided()) return REMAINING_NOT_APPLICABLE;

        // Return a non-negative value if opt-out promo counter is enabled, and there's a limit.
        DisableablePromoTapCounter counter = getPromoTapCounter();
        if (counter.isEnabled()) {
            int limit = getPromoTapTriggeredLimit();
            if (limit >= 0) return Math.max(0, limit - counter.getCount());
        }

        return REMAINING_NOT_APPLICABLE;
    }

    private int getPromoTapTriggeredLimit() {
        return mTapTriggeredPromoLimitForTesting != null
                ? mTapTriggeredPromoLimitForTesting.intValue()
                : TAP_TRIGGERED_PROMO_LIMIT;
    }

    /**
     * @return the {@link DisableablePromoTapCounter}.
     */
    DisableablePromoTapCounter getPromoTapCounter() {
        return DisableablePromoTapCounter.getInstance(mPreferencesManager);
    }

    /**
     * @return Whether a Tap gesture is currently supported as a trigger for the feature.
     */
    boolean isTapSupported() {
        return isContextualSearchFullyEnabled() ? true : (getPromoTapsRemaining() != 0);
    }

    /**
     * @return whether or not the Contextual Search Result should be preloaded before the user
     *     explicitly interacts with the feature.
     */
    boolean shouldPrefetchSearchResult() {
        if (PreloadPagesSettingsBridge.getState(mProfile) == PreloadPagesState.NO_PRELOADING) {
            return false;
        }

        // We never preload unless we have sent page context (done through a Resolve request).
        // Only some gestures can resolve, and only when resolve privacy rules are met.
        return isResolvingGesture() && shouldPreviousGestureResolve();
    }

    /**
     * Determines whether the current gesture can trigger a resolve request to use page context.
     * This only checks the gesture, not privacy status -- {@see #shouldPreviousGestureResolve}.
     */
    boolean isResolvingGesture() {
        return mSelectionController.getSelectionType() == SelectionType.TAP
                || mSelectionController.getSelectionType() == SelectionType.RESOLVING_LONG_PRESS;
    }

    /**
     * Determines whether the gesture being processed is allowed to resolve.
     * TODO(donnd): rename to be more descriptive. Maybe isGestureAllowedToResolve?
     * @return Whether the previous gesture should resolve.
     */
    boolean shouldPreviousGestureResolve() {
        // The user must have decided on privacy to resolve page content on HTTPS.
        return isContextualSearchFullyEnabled();
    }

    /**
     * Returns whether surrounding context can be accessed by other systems or not.
     * @return Whether surroundings are available.
     */
    boolean canSendSurroundings() {
        // The user must have decided on privacy to send page content on HTTPS.
        return isContextualSearchFullyEnabled();
    }

    /**
     * @return Whether the Opt-out promo is available to be shown in any panel.
     */
    boolean isPromoAvailable() {
        // Only show promo card a limited number of times.
        return isUserUndecided()
                && getContextualSearchPromoCardShownCount(mProfile) < PROMO_DEFAULT_LIMIT;
    }

    /**
     * Returns whether conditions are right for an IPH for Longpress to be shown.
     * We only show this for users that have already opted-in because it's all about using page
     * context with the right gesture.
     */
    boolean isLongpressInPanelHelpCondition() {
        // We no longer support an IPH in the panel for promoting a Longpress instead of a Tap.
        return false;
    }

    /** Registers that a tap has taken place by incrementing tap-tracking counters. */
    void registerTap() {
        if (isPromoAvailable()) {
            DisableablePromoTapCounter promoTapCounter = getPromoTapCounter();
            // Bump the counter only when it is still enabled.
            if (promoTapCounter.isEnabled()) promoTapCounter.increment();
        }
    }

    /** Updates all the counters to account for an open-action on the panel. */
    void updateCountersForOpen() {
        // Disable the "promo tap" counter, but only if we're using the Opt-out onboarding.
        // For Opt-in, we never disable the promo tap counter.
        if (isPromoAvailable()) {
            getPromoTapCounter().disable();
        }
    }

    /**
     * @return Whether a verbatim request should be made for the given base page, assuming there
     *         is no existing request.
     */
    boolean shouldCreateVerbatimRequest() {
        @SelectionType int selectionType = mSelectionController.getSelectionType();
        return (mSelectionController.getSelectedText() != null
                && (selectionType == SelectionType.LONG_PRESS || !shouldPreviousGestureResolve()));
    }

    /**
     * Determines whether an error from a search term resolution request should
     * be shown to the user, or not.
     */
    boolean shouldShowErrorCodeInBar() {
        // Builds with lots of real users should not see raw error codes.
        return !(VersionInfo.isStableBuild() || VersionInfo.isBetaBuild());
    }

    /** Logs the current user's state, including preference, tap and open counters, etc. */
    void logCurrentState() {
        ContextualSearchUma.logPreferenceState(mProfile);
        RelatedSearchesUma.logRelatedSearchesPermissionsForAllUsers(
                hasSendUrlPermissions(), canSendSurroundings());
    }

    /**
     * Logs whether the current user is qualified to do Related Searches requests. This does not
     * check if Related Searches is actually enabled for the current user, only whether they are
     * qualified. We use this to gauge whether each group has a balanced number of qualified users.
     * Can be logged multiple times since we'll just look at the user-count of this histogram.
     * @param basePageLanguage The language of the page, to check if supported by the server.
     */
    void logRelatedSearchesQualifiedUsers(String basePageLanguage) {
        if (mRelatedSearchesStamp.isQualifiedForRelatedSearches(basePageLanguage)) {
            RelatedSearchesUma.logRelatedSearchesQualifiedUsers();
        }
    }

    /**
     * Whether this request should include sending the URL of the base page to the server.
     * Several conditions are checked to make sure it's OK to send the URL, but primarily this is
     * based on whether the user has checked the setting for "Make searches and browsing better".
     * @return {@code true} if the URL should be sent.
     */
    boolean doSendBasePageUrl() {
        if (!isContextualSearchFullyEnabled()) return false;

        // Ensure that the default search provider is Google.
        if (!TemplateUrlServiceFactory.getForProfile(mProfile).isDefaultSearchEngineGoogle()) {
            return false;
        }

        // Only allow HTTP or HTTPS URLs.
        GURL url = mNetworkCommunicator.getBasePageUrl();

        if (url == null || !UrlUtilities.isHttpOrHttps(url)) {
            return false;
        }

        return hasSendUrlPermissions();
    }

    /**
     * Determines whether the user has given permission to send URLs through the "Make searches and
     * browsing better" user setting.
     * @return Whether we can send a URL.
     */
    boolean hasSendUrlPermissions() {
        if (mDidOverrideAllowSendingPageUrlForTesting) return mAllowSendingPageUrlForTesting;

        // Check whether the user has enabled anonymous URL-keyed data collection.
        // This is surfaced on the relatively new "Make searches and browsing better" user setting.
        // In case an experiment is active for the legacy UI call through the unified consent
        // service.
        return UnifiedConsentServiceBridge.isUrlKeyedAnonymizedDataCollectionEnabled(mProfile);
    }

    /**
     * Returns whether a transition that is both from and to the given state should be done.
     * This allows prevention of the short-circuiting that ignores a state transition to the current
     * state in cases where rerunning the current state might safeguard against problematic
     * behavior.
     * @param state The current state, which is also the state being transitioned into.
     * @return {@code true} to go ahead with the logic for that state transition even though we're
     *     already in that state. {@code false} indicates that ignoring this redundant state
     *     transition is fine.
     */
    boolean shouldRetryCurrentState(@InternalState int state) {
        // Make sure we don't get stuck in the IDLE state if the panel is still showing.
        // See https://crbug.com/1251774
        return state == InternalState.IDLE
                && mSearchPanel != null
                && (mSearchPanel.isShowing() || mSearchPanel.isActive());
    }

    /**
     * @return Whether the given URL is used for Accelerated Mobile Pages by Google.
     */
    boolean isAmpUrl(String url) {
        Uri uri = Uri.parse(url);
        if (uri == null || uri.getHost() == null || uri.getPath() == null) return false;

        return uri.getHost().contains(DOMAIN_GOOGLE) && uri.getPath().startsWith(PATH_AMP);
    }

    /**
     * @param profile The {@link Profile} associated with this Contextual Search session.
     * @return Whether the Contextual Search feature was disabled by the user explicitly.
     */
    static boolean isContextualSearchDisabled(Profile profile) {
        return UserPrefs.get(profile)
                .getString(Pref.CONTEXTUAL_SEARCH_ENABLED)
                .equals(CONTEXTUAL_SEARCH_DISABLED);
    }

    /**
     * @param profile The {@link Profile} associated with this Contextual Search session.
     * @return Whether the Contextual Search feature was enabled by the user explicitly.
     */
    static boolean isContextualSearchEnabled(Profile profile) {
        return UserPrefs.get(profile)
                .getString(Pref.CONTEXTUAL_SEARCH_ENABLED)
                .equals(CONTEXTUAL_SEARCH_ENABLED);
    }

    /**
     * @param profile The {@link Profile} associated with this Contextual Search session.
     * @return Whether the Contextual Search feature is uninitialized (preference unset by the
     *     user).
     */
    static boolean isContextualSearchUninitialized(Profile profile) {
        return UserPrefs.get(profile).getString(Pref.CONTEXTUAL_SEARCH_ENABLED).isEmpty();
    }

    /**
     * @param profile The {@link Profile} associated with this Contextual Search session.
     * @return Whether the Contextual Search fully privacy opt-in was disabled by the user
     *     explicitly.
     */
    static boolean isContextualSearchOptInDisabled(Profile profile) {
        return !UserPrefs.get(profile).getBoolean(Pref.CONTEXTUAL_SEARCH_WAS_FULLY_PRIVACY_ENABLED);
    }

    /**
     * @param profile The {@link Profile} associated with this Contextual Search session.
     * @return Whether the Contextual Search fully privacy opt-in was enabled by the user
     *     explicitly.
     */
    static boolean isContextualSearchOptInEnabled(Profile profile) {
        return UserPrefs.get(profile).getBoolean(Pref.CONTEXTUAL_SEARCH_WAS_FULLY_PRIVACY_ENABLED);
    }

    /**
     * @param profile The {@link Profile} associated with this Contextual Search session.
     * @return Whether the Contextual Search fully privacy opt-in is uninitialized (preference unset
     *     by the user).
     */
    static boolean isContextualSearchOptInUninitialized(Profile profile) {
        return !UserPrefs.get(profile)
                .hasPrefPath(Pref.CONTEXTUAL_SEARCH_WAS_FULLY_PRIVACY_ENABLED);
    }

    /**
     * @param profile The {@link Profile} associated with this Contextual Search session.
     * @return Count of times the promo card has been shown.
     */
    static int getContextualSearchPromoCardShownCount(Profile profile) {
        return UserPrefs.get(profile).getInteger(Pref.CONTEXTUAL_SEARCH_PROMO_CARD_SHOWN_COUNT);
    }

    /**
     * Sets Count of times the promo card has been shown.
     *
     * @param profile The {@link Profile} associated with this Contextual Search session.
     */
    private static void setContextualSearchPromoCardShownCount(Profile profile, int count) {
        UserPrefs.get(profile).setInteger(Pref.CONTEXTUAL_SEARCH_PROMO_CARD_SHOWN_COUNT, count);
    }

    /**
     * @param profile The {@link Profile} associated with this Contextual Search session.
     * @return Whether the Contextual Search feature is disabled when the prefs service considers it
     *     managed.
     */
    static boolean isContextualSearchDisabledByPolicy(Profile profile) {
        return UserPrefs.get(profile).isManagedPreference(Pref.CONTEXTUAL_SEARCH_ENABLED)
                && isContextualSearchDisabled(profile);
    }

    /**
     * Explicitly set whether Contextual Search is enabled or not, with the enabled state being
     * either fully or default-enabled based on previous state. 'enabled' is true - fully opt in or
     * default-enabled based on previous state. 'enabled' is false - the feature is disabled.
     *
     * @param profile The {@link Profile} associated with this Contextual Search session.
     * @param enabled Whether Contextual Search should be enabled.
     */
    static void setContextualSearchState(Profile profile, boolean enabled) {
        @ContextualSearchPreference
        int onState =
                isContextualSearchOptInEnabled(profile)
                        ? ContextualSearchPreference.ENABLED
                        : ContextualSearchPreference.UNINITIALIZED;
        setContextualSearchStateInternal(
                profile, enabled ? onState : ContextualSearchPreference.DISABLED);
    }

    /**
     * @param profile The {@link Profile} associated with this Contextual Search session.
     * @return Whether the Contextual Search feature was fully opted in based on the preference
     *     itself.
     */
    static boolean isContextualSearchPrefFullyOptedIn(Profile profile) {
        return isContextualSearchOptInUninitialized(profile)
                ? isContextualSearchEnabled(profile)
                : isContextualSearchOptInEnabled(profile);
    }

    /**
     * Sets whether the user is fully opted in for Contextual Search Privacy. 'enabled' is true -
     * fully opt in. 'enabled' is false - remain undecided.
     *
     * @param profile The {@link Profile} associated with this Contextual Search session.
     * @param enabled Whether Contextual Search privacy is opted in.
     */
    static void setContextualSearchFullyOptedIn(Profile profile, boolean enabled) {
        UserPrefs.get(profile)
                .setBoolean(Pref.CONTEXTUAL_SEARCH_WAS_FULLY_PRIVACY_ENABLED, enabled);
        setContextualSearchStateInternal(
                profile,
                enabled
                        ? ContextualSearchPreference.ENABLED
                        : ContextualSearchPreference.UNINITIALIZED);
    }

    /** Notifies that a promo card has been shown. */
    static void onPromoShown(Profile profile) {
        int count = getContextualSearchPromoCardShownCount(profile);
        count++;
        setContextualSearchPromoCardShownCount(profile, count);
        ContextualSearchUma.logRevisedPromoOpenCount(count);
    }

    /**
     * @param profile The {@link Profile} associated with this Contextual Search session.
     * @param state The state for the Contextual Search.
     */
    private static void setContextualSearchStateInternal(
            Profile profile, @ContextualSearchPreference int state) {
        PrefService prefs = UserPrefs.get(profile);
        switch (state) {
            case ContextualSearchPreference.UNINITIALIZED:
                prefs.clearPref(Pref.CONTEXTUAL_SEARCH_ENABLED);
                break;
            case ContextualSearchPreference.ENABLED:
                prefs.setString(Pref.CONTEXTUAL_SEARCH_ENABLED, CONTEXTUAL_SEARCH_ENABLED);
                break;
            case ContextualSearchPreference.DISABLED:
                prefs.setString(Pref.CONTEXTUAL_SEARCH_ENABLED, CONTEXTUAL_SEARCH_DISABLED);
                break;
            default:
                Log.e(TAG, "Unexpected state for ContextualSearchPreference state=" + state);
                break;
        }
    }

    // --------------------------------------------------------------------------------------------
    // Testing support.
    // --------------------------------------------------------------------------------------------

    /**
     * Overrides the decided/undecided state for the user preference.
     * @param decidedState Whether the user has decided to opt-in to sending page content or not.
     * @return whether the previous decided state was fully enabled or not.
     */
    boolean overrideDecidedStateForTesting(boolean decidedState) {
        boolean wasEnabled = mFullyEnabledForTesting;
        mDidOverrideFullyEnabledForTesting = true;
        mFullyEnabledForTesting = decidedState;
        return wasEnabled;
    }

    /**
     * Overrides the user preference for sending the page URL to Google.
     * @param doAllowSendingPageUrl Whether to allow sending the page URL or not, for tests.
     */
    void overrideAllowSendingPageUrlForTesting(boolean doAllowSendingPageUrl) {
        mDidOverrideAllowSendingPageUrlForTesting = true;
        mAllowSendingPageUrlForTesting = doAllowSendingPageUrl;
    }

    // --------------------------------------------------------------------------------------------
    // Additional considerations.
    // --------------------------------------------------------------------------------------------

    /**
     * @return The ISO country code for the user's home country, or an empty string if not
     *         available or privacy-enabled.
     */
    @NonNull
    String getHomeCountry(Context context) {
        TelephonyManager telephonyManager =
                (TelephonyManager) context.getSystemService(Context.TELEPHONY_SERVICE);
        if (telephonyManager == null) return "";

        String simCountryIso = telephonyManager.getSimCountryIso();
        return TextUtils.isEmpty(simCountryIso) ? "" : simCountryIso;
    }

    /**
     * @return Whether a promo is needed because the user is still undecided
     *         on enabling or disabling the feature.
     */
    boolean isUserUndecided() {
        if (mDidOverrideFullyEnabledForTesting) return !mFullyEnabledForTesting;

        return isContextualSearchUninitialized(mProfile)
                && isContextualSearchOptInUninitialized(mProfile);
    }

    /**
     * @return Whether a user explicitly enabled the Contextual Search feature.
     */
    boolean isContextualSearchFullyEnabled() {
        if (mDidOverrideFullyEnabledForTesting) return mFullyEnabledForTesting;

        return isContextualSearchResolutionUrlValid() && isContextualSearchEnabled(mProfile);
    }

    /**
     * @return Whether the contextual search resolution URL is valid and can be used to resolve
     *     highlight.
     */
    boolean isContextualSearchResolutionUrlValid() {
        // This function is needed because certain DMA implementations supply a persistent set of
        // Template URL overrides. These overrides are in effect until the user performs a factory
        // data reset of their device, and occasionally miss relevant information, such as - in this
        // particular case - "contextual_search_url" value.
        if (mContextualSearchResolutionUrlValid == null) {
            if (ContextualSearchPolicyJni.get() == null) {
                // JNI is not initialized.
                return false;
            }
            mContextualSearchResolutionUrlValid =
                    ContextualSearchPolicyJni.get().isContextualSearchResolutionUrlValid(mProfile);
        }
        return mContextualSearchResolutionUrlValid;
    }

    /**
     * @param url The URL of the base page.
     * @return Whether the given content view is for an HTTP page.
     */
    boolean isBasePageHTTP(@Nullable GURL url) {
        return url != null && UrlConstants.HTTP_SCHEME.equals(url.getScheme());
    }

    // --------------------------------------------------------------------------------------------
    // Related Searches Support.
    // --------------------------------------------------------------------------------------------

    /**
     * Gets the runtime processing stamp for Related Searches. This typically gets the value from
     * a param from a Field Trial Feature.
     * @param basePageLanguage The language of the page, to check for server support.
     * @return A {@code String} whose value describes the schema version and current processing
     *         of Related Searches, or an empty string if the user is not qualified to request
     *         Related Searches or the feature is not enabled.
     */
    String getRelatedSearchesStamp(String basePageLanguage) {
        return mRelatedSearchesStamp.getRelatedSearchesStamp(basePageLanguage);
    }

    // --------------------------------------------------------------------------------------------
    // Testing helpers.
    // --------------------------------------------------------------------------------------------

    /**
     * Sets the {@link ContextualSearchNetworkCommunicator} to use for server requests.
     * @param networkCommunicator The communicator for all future requests.
     */
    @VisibleForTesting
    public void setNetworkCommunicator(ContextualSearchNetworkCommunicator networkCommunicator) {
        mNetworkCommunicator = networkCommunicator;
    }

    @NativeMethods
    interface Natives {
        boolean isContextualSearchResolutionUrlValid(@JniType("Profile*") Profile profile);
    }
}