// Copyright 2015 The Chromium Authors. All rights reserved. // 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.text.TextUtils; import org.chromium.base.VisibleForTesting; import org.chromium.chrome.browser.ChromeVersionInfo; import org.chromium.chrome.browser.compositor.bottombar.contextualsearch.ContextualSearchPanel; import org.chromium.chrome.browser.contextualsearch.ContextualSearchSelectionController.SelectionType; import org.chromium.chrome.browser.preferences.ChromePreferenceManager; import org.chromium.chrome.browser.preferences.PrefServiceBridge; import java.net.URL; import java.util.List; import java.util.Locale; import java.util.regex.Pattern; import javax.annotation.Nullable; /** * Handles policy decisions for the {@code ContextualSearchManager}. */ class ContextualSearchPolicy { private static final Pattern CONTAINS_WHITESPACE_PATTERN = Pattern.compile("\\s"); 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 ONE_DAY_IN_MILLIS = 24 * 60 * 60 * 1000; private static final int TAP_TRIGGERED_PROMO_LIMIT = 50; private static final int TAP_RESOLVE_PREFETCH_LIMIT_FOR_DECIDED = 50; private static final int TAP_RESOLVE_PREFETCH_LIMIT_FOR_UNDECIDED = 20; private final ChromePreferenceManager mPreferenceManager; private final ContextualSearchSelectionController mSelectionController; private ContextualSearchNetworkCommunicator mNetworkCommunicator; private ContextualSearchPanel mSearchPanel; // Members used only for testing purposes. private boolean mDidOverrideDecidedStateForTesting; private boolean mDecidedStateForTesting; private Integer mTapTriggeredPromoLimitForTesting; private Integer mTapLimitForDecided; private Integer mTapLimitForUndecided; /** * @param context The Android Context. */ public ContextualSearchPolicy(Context context, ContextualSearchSelectionController selectionController, ContextualSearchNetworkCommunicator networkCommunicator) { mPreferenceManager = ChromePreferenceManager.getInstance(context); mSelectionController = selectionController; mNetworkCommunicator = networkCommunicator; } /** * Sets the handle to the ContextualSearchPanel. * @param panel The ContextualSearchPanel. */ public void setContextualSearchPanel(ContextualSearchPanel panel) { mSearchPanel = panel; } // TODO(donnd): Consider adding a test-only constructor that uses dependency injection of a // preference manager and PrefServiceBridge. Currently this is not possible because the // PrefServiceBridge is final. /** * @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() { if (mTapTriggeredPromoLimitForTesting != null) { return mTapTriggeredPromoLimitForTesting.intValue(); } return TAP_TRIGGERED_PROMO_LIMIT; } /** * @return the {@link DisableablePromoTapCounter}. */ DisableablePromoTapCounter getPromoTapCounter() { return DisableablePromoTapCounter.getInstance(mPreferenceManager); } /** * @return Whether a Tap gesture is currently supported as a trigger for the feature. */ boolean isTapSupported() { if (!isUserUndecided()) return true; return getPromoTapsRemaining() != 0; } /** * @return whether or not the Contextual Search Result should be preloaded before the user * explicitly interacts with the feature. */ boolean shouldPrefetchSearchResult() { if (isMandatoryPromoAvailable()) return false; if (!PrefServiceBridge.getInstance().getNetworkPredictionEnabled()) return false; // We may not be prefetching due to the resolve/prefetch limit. if (isTapBeyondTheLimit()) return false; // We never preload on long-press so users can cut & paste without hitting the servers. return mSelectionController.getSelectionType() == SelectionType.TAP; } /** * Returns whether the previous tap (the tap last counted) should resolve. * @return Whether the previous tap should resolve. */ boolean shouldPreviousTapResolve() { if (isMandatoryPromoAvailable()) return false; if (!ContextualSearchFieldTrial.isSearchTermResolutionEnabled()) return false; // We may not be resolving the tap due to the resolve/prefetch limit. if (isTapBeyondTheLimit()) return false; if (isPromoAvailable()) return isBasePageHTTP(mNetworkCommunicator.getBasePageUrl()); return true; } /** * Returns whether surrounding context can be accessed by other systems or not. * @return Whether surroundings are available. */ boolean canSendSurroundings() { if (isUserUndecided()) return false; if (isPromoAvailable()) return isBasePageHTTP(mNetworkCommunicator.getBasePageUrl()); return true; } /** * @return Whether the Mandatory Promo is enabled. */ boolean isMandatoryPromoAvailable() { if (!isUserUndecided()) return false; if (!ContextualSearchFieldTrial.isMandatoryPromoEnabled()) return false; return getPromoOpenCount() >= ContextualSearchFieldTrial.getMandatoryPromoLimit(); } /** * @return Whether the Opt-out promo is available to be shown in any panel. */ boolean isPromoAvailable() { return isUserUndecided(); } /** * @return Whether the Peek promo is available to be shown above the Search Bar. */ public boolean isPeekPromoAvailable() { // Allow Promo to be forcefully enabled for testing. if (ContextualSearchFieldTrial.isPeekPromoForced()) return true; // Enabled by Finch. if (!ContextualSearchFieldTrial.isPeekPromoEnabled()) return false; return isPeekPromoConditionSatisfied(); } /** * @return Whether the condition to show the Peek promo is satisfied. */ public boolean isPeekPromoConditionSatisfied() { // Check for several conditions to determine whether the Peek Promo can be shown. // 1) If the Panel was never opened. if (getPromoOpenCount() > 0) return false; // 2) User has not opted in. if (!isUserUndecided()) return false; // 3) Selection was caused by a long press. if (mSelectionController.getSelectionType() != SelectionType.LONG_PRESS) return false; // 4) Promo was not shown more than the maximum number of times defined by Finch. final int maxShowCount = ContextualSearchFieldTrial.getPeekPromoMaxShowCount(); final int peekPromoShowCount = mPreferenceManager.getContextualSearchPeekPromoShowCount(); if (peekPromoShowCount >= maxShowCount) return false; // 5) Only then, show the promo. return true; } /** * Register that the Peek Promo was seen. */ public void registerPeekPromoSeen() { final int peekPromoShowCount = mPreferenceManager.getContextualSearchPeekPromoShowCount(); mPreferenceManager.setContextualSearchPeekPromoShowCount(peekPromoShowCount + 1); } /** * Logs metrics related to the Peek Promo. * @param wasPromoSeen Whether the Peek Promo was seen. * @param wouldHaveShownPromo Whether the Promo would have shown. */ public void logPeekPromoMetrics(boolean wasPromoSeen, boolean wouldHaveShownPromo) { final boolean hasOpenedPanel = getPromoOpenCount() > 0; ContextualSearchUma.logPeekPromoOutcome(wasPromoSeen, wouldHaveShownPromo, hasOpenedPanel); if (wasPromoSeen) { final int showCount = mPreferenceManager.getContextualSearchPeekPromoShowCount(); ContextualSearchUma.logPeekPromoShowCount(showCount, hasOpenedPanel); } } /** * 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(); } } int tapsSinceOpen = mPreferenceManager.getContextualSearchTapCount(); mPreferenceManager.setContextualSearchTapCount(++tapsSinceOpen); if (isUserUndecided()) { ContextualSearchUma.logTapsSinceOpenForUndecided(tapsSinceOpen); } else { ContextualSearchUma.logTapsSinceOpenForDecided(tapsSinceOpen); } } /** * Updates all the counters to account for an open-action on the panel. */ void updateCountersForOpen() { // Always completely reset the tap counter, since it just counts taps // since the last open. mPreferenceManager.setContextualSearchTapCount(0); mPreferenceManager.setContextualSearchTapQuickAnswerCount(0); // 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(); // Bump the total-promo-opens counter. int count = mPreferenceManager.getContextualSearchPromoOpenCount(); mPreferenceManager.setContextualSearchPromoOpenCount(++count); ContextualSearchUma.logPromoOpenCount(count); } } /** * Updates Tap counters to account for a quick-answer caption shown on the panel. * @param wasActivatedByTap Whether the triggering gesture was a Tap or not. * @param doesAnswer Whether the caption is considered an answer rather than just * informative. */ void updateCountersForQuickAnswer(boolean wasActivatedByTap, boolean doesAnswer) { if (wasActivatedByTap && doesAnswer) { int tapsWithAnswerSinceOpen = mPreferenceManager.getContextualSearchTapQuickAnswerCount(); mPreferenceManager.setContextualSearchTapQuickAnswerCount(++tapsWithAnswerSinceOpen); } } /** * @return Whether a verbatim request should be made for the given base page, assuming there * is no exiting request. */ boolean shouldCreateVerbatimRequest() { SelectionType selectionType = mSelectionController.getSelectionType(); return (mSelectionController.getSelectedText() != null && (selectionType == SelectionType.LONG_PRESS || (selectionType == SelectionType.TAP && !shouldPreviousTapResolve()))); } /** * 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 !(ChromeVersionInfo.isStableBuild() || ChromeVersionInfo.isBetaBuild()); } /** * Logs the current user's state, including preference, tap and open counters, etc. */ void logCurrentState() { ContextualSearchUma.logPreferenceState(); // Log the number of promo taps remaining. int promoTapsRemaining = getPromoTapsRemaining(); if (promoTapsRemaining >= 0) ContextualSearchUma.logPromoTapsRemaining(promoTapsRemaining); // Also log the total number of taps before opening the promo, even for those // that are no longer tap limited. That way we'll know the distribution of the // number of taps needed before opening the promo. DisableablePromoTapCounter promoTapCounter = getPromoTapCounter(); boolean wasOpened = !promoTapCounter.isEnabled(); int count = promoTapCounter.getCount(); if (wasOpened) { ContextualSearchUma.logPromoTapsBeforeFirstOpen(count); } else { ContextualSearchUma.logPromoTapsForNeverOpened(count); } } /** * Logs details about the Search Term Resolution. * Should only be called when a search term has been resolved. * @param searchTerm The Resolved Search Term. */ void logSearchTermResolutionDetails(String searchTerm) { // Only log for decided users so the data reflect fully-enabled behavior. // Otherwise we'll get skewed data; more HTTP pages than HTTPS (since those don't resolve), // and it's also possible that public pages, e.g. news, have more searches for multi-word // entities like people. if (!isUserUndecided()) { URL url = mNetworkCommunicator.getBasePageUrl(); ContextualSearchUma.logBasePageProtocol(isBasePageHTTP(url)); boolean isSingleWord = !CONTAINS_WHITESPACE_PATTERN.matcher(searchTerm.trim()).find(); ContextualSearchUma.logSearchTermResolvedWords(isSingleWord); } } /** * Whether sending the URL of the base page to the server may be done for policy reasons. * NOTE: There may be additional privacy reasons why the base page URL should not be sent. * TODO(donnd): Update this API to definitively determine if it's OK to send the URL, * by merging the checks in the native contextual_search_delegate here. * @return {@code true} if the URL may be sent for policy reasons. * Note that a return value of {@code true} may still require additional checks * to see if all privacy-related conditions are met to send the base page URL. */ boolean maySendBasePageUrl() { return !isUserUndecided(); } /** * The search provider icon is animated every time on long press if the user has never opened * the panel before and once a day on tap. * * @return Whether the search provider icon should be animated. */ boolean shouldAnimateSearchProviderIcon() { if (mSearchPanel.isShowing()) { return false; } SelectionType selectionType = mSelectionController.getSelectionType(); if (selectionType == SelectionType.TAP) { long currentTimeMillis = System.currentTimeMillis(); long lastAnimatedTimeMillis = mPreferenceManager.getContextualSearchLastAnimationTime(); if (Math.abs(currentTimeMillis - lastAnimatedTimeMillis) > ONE_DAY_IN_MILLIS) { mPreferenceManager.setContextualSearchLastAnimationTime(currentTimeMillis); return true; } else { return false; } } else if (selectionType == SelectionType.LONG_PRESS) { // If the panel has never been opened before, getPromoOpenCount() will be 0. // Once the panel has been opened, regardless of whether or not the user has opted-in or // opted-out, the promo open count will be greater than zero. return isUserUndecided() && getPromoOpenCount() == 0; } return false; } /** * @return Whether Contextual Search should enable its JavaScript API in the overlay panel. */ boolean isContextualSearchJsApiEnabled() { // Quick answers requires the JS API. return ContextualSearchFieldTrial.isQuickAnswersEnabled(); } /** * @return Whether the given URL is used for Accelerated Mobile Pages by Google. */ boolean isAmpUrl(String url) { Uri uri = Uri.parse(url); return uri.getHost().contains(DOMAIN_GOOGLE) && uri.getPath().startsWith(PATH_AMP); } // -------------------------------------------------------------------------------------------- // Testing support. // -------------------------------------------------------------------------------------------- /** * Overrides the decided/undecided state for the user preference. * @param decidedState Whether the user has decided or not. */ @VisibleForTesting void overrideDecidedStateForTesting(boolean decidedState) { mDidOverrideDecidedStateForTesting = true; mDecidedStateForTesting = decidedState; } /** * @return count of times the panel with the promo has been opened. */ @VisibleForTesting int getPromoOpenCount() { return mPreferenceManager.getContextualSearchPromoOpenCount(); } /** * @return The number of times the user has tapped since the last panel open. */ @VisibleForTesting int getTapCount() { return mPreferenceManager.getContextualSearchTapCount(); } // -------------------------------------------------------------------------------------------- // Translation support. // -------------------------------------------------------------------------------------------- /** * Determines whether translation is needed between the given languages. * @param sourceLanguage The source language code; language we're translating from. * @param targetLanguages A list of target language codes; languages we might translate to. * @return Whether translation is needed or not. */ boolean needsTranslation(String sourceLanguage, List<String> targetLanguages) { // For now, we just look for a language match. for (String targetLanguage : targetLanguages) { if (TextUtils.equals(sourceLanguage, targetLanguage)) { return false; } } return true; } /** * @return The best target language from the ordered list, or the empty string if * none is available. */ String bestTargetLanguage(List<String> targetLanguages) { // For now, we just return the first language, unless it's English // (due to over-usage). // TODO(donnd): Improve this logic. Determining the right language seems non-trivial. // E.g. If this language doesn't match the user's server preferences, they might see a page // in one language and the one box translation in another, which might be confusing. // Also this logic should only apply on Android, where English setup is over used. if (targetLanguages.size() > 1 && TextUtils.equals(targetLanguages.get(0), Locale.ENGLISH.getLanguage()) && !ContextualSearchFieldTrial.isEnglishTargetTranslationEnabled()) { return targetLanguages.get(1); } else if (targetLanguages.size() > 0) { return targetLanguages.get(0); } else { return ""; } } /** * @return Whether any translation feature for Contextual Search is enabled. */ boolean isTranslationEnabled() { return ContextualSearchFieldTrial.isTranslationEnabled(); } /** * @return Whether forcing a translation Onebox is disabled. */ boolean isForceTranslationOneboxDisabled() { return ContextualSearchFieldTrial.isForceTranslationOneboxDisabled(); } /** * @return Whether forcing a translation Onebox based on auto-detection of the source language * is disabled. */ boolean isAutoDetectTranslationOneboxDisabled() { if (isForceTranslationOneboxDisabled()) return true; return ContextualSearchFieldTrial.isAutoDetectTranslationOneboxDisabled(); } /** * Sets the limit for the tap triggered promo. */ @VisibleForTesting void setPromoTapTriggeredLimitForTesting(int limit) { mTapTriggeredPromoLimitForTesting = limit; } @VisibleForTesting void setTapLimitForDecidedForTesting(int limit) { mTapLimitForDecided = limit; } @VisibleForTesting void setTapLimitForUndecidedForTesting(int limit) { mTapLimitForUndecided = limit; } // -------------------------------------------------------------------------------------------- // Private helpers. // -------------------------------------------------------------------------------------------- /** * @return Whether a promo is needed because the user is still undecided * on enabling or disabling the feature. */ private boolean isUserUndecided() { // TODO(donnd) use dependency injection for the PrefServiceBridge instead! if (mDidOverrideDecidedStateForTesting) return !mDecidedStateForTesting; return PrefServiceBridge.getInstance().isContextualSearchUninitialized(); } /** * @param url The URL of the base page. * @return Whether the given content view is for an HTTP page. */ private boolean isBasePageHTTP(@Nullable URL url) { return url != null && "http".equals(url.getProtocol()); } /** * @return Whether the tap resolve/prefetch limit has been exceeded. */ private boolean isTapBeyondTheLimit() { // Discount taps that caused a Quick Answer since the tap may not have been totally ignored. return getTapCount() - mPreferenceManager.getContextualSearchTapQuickAnswerCount() > getTapLimit(); } /** * @return The limit of the number of taps to resolve or prefetch. */ private int getTapLimit() { return isUserUndecided() ? getTapLimitForUndecided() : getTapLimitForDecided(); } private int getTapLimitForDecided() { if (mTapLimitForDecided != null) { return mTapLimitForDecided.intValue(); } else { return TAP_RESOLVE_PREFETCH_LIMIT_FOR_DECIDED; } } private int getTapLimitForUndecided() { if (mTapLimitForUndecided != null) { return mTapLimitForUndecided.intValue(); } else { return TAP_RESOLVE_PREFETCH_LIMIT_FOR_UNDECIDED; } } // -------------------------------------------------------------------------------------------- // 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; } }