blob: 1cc025f749d2e5ff42e4a662fb3dac2693e39193 [file] [log] [blame]
// Copyright 2018 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.contextual_suggestions;
import static org.chromium.chrome.browser.dependency_injection.ChromeCommonQualifiers.LAST_USED_PROFILE;
import android.content.Context;
import android.os.Handler;
import android.os.SystemClock;
import android.support.annotation.Nullable;
import android.text.TextUtils;
import android.view.View;
import org.chromium.base.ContextUtils;
import org.chromium.base.VisibleForTesting;
import org.chromium.base.metrics.RecordHistogram;
import org.chromium.base.metrics.RecordUserAction;
import org.chromium.chrome.R;
import org.chromium.chrome.browser.ChromeFeatureList;
import org.chromium.chrome.browser.compositor.layouts.EmptyOverviewModeObserver;
import org.chromium.chrome.browser.compositor.layouts.LayoutManager;
import org.chromium.chrome.browser.compositor.layouts.LayoutManagerChrome;
import org.chromium.chrome.browser.compositor.layouts.OverviewModeBehavior;
import org.chromium.chrome.browser.contextual_suggestions.ContextualSuggestionsBridge.ContextualSuggestionsResult;
import org.chromium.chrome.browser.dependency_injection.ActivityScope;
import org.chromium.chrome.browser.feature_engagement.TrackerFactory;
import org.chromium.chrome.browser.fullscreen.ChromeFullscreenManager;
import org.chromium.chrome.browser.fullscreen.ChromeFullscreenManager.FullscreenListener;
import org.chromium.chrome.browser.fullscreen.FullscreenManager;
import org.chromium.chrome.browser.profiles.Profile;
import org.chromium.chrome.browser.tabmodel.TabModelSelector;
import org.chromium.chrome.browser.toolbar.ToolbarManager;
import org.chromium.chrome.browser.toolbar.top.ToolbarPhone;
import org.chromium.chrome.browser.util.AccessibilityUtil;
import org.chromium.chrome.browser.widget.ListMenuButton;
import org.chromium.chrome.browser.widget.bottomsheet.BottomSheet;
import org.chromium.chrome.browser.widget.bottomsheet.BottomSheet.StateChangeReason;
import org.chromium.chrome.browser.widget.bottomsheet.BottomSheetObserver;
import org.chromium.chrome.browser.widget.bottomsheet.EmptyBottomSheetObserver;
import org.chromium.chrome.browser.widget.textbubble.ImageTextBubble;
import org.chromium.chrome.browser.widget.textbubble.TextBubble;
import org.chromium.components.feature_engagement.EventConstants;
import org.chromium.components.feature_engagement.FeatureConstants;
import org.chromium.components.feature_engagement.Tracker;
import org.chromium.content_public.browser.WebContents;
import org.chromium.ui.widget.ViewRectProvider;
import java.util.Collections;
import java.util.List;
import javax.inject.Inject;
import javax.inject.Named;
import javax.inject.Provider;
/**
* A mediator for the contextual suggestions UI component responsible for interacting with
* the contextual suggestions C++ components (via a bridge), updating the model, and communicating
* with the component coordinator(s).
*/
@ActivityScope
class ContextualSuggestionsMediator
implements EnabledStateMonitor.Observer, FetchHelper.Delegate, ListMenuButton.Delegate {
@VisibleForTesting
static final String IPH_CONFIDENCE_THRESHOLD_PARAM = "iph_confidence_threshold";
private static final int IPH_AUTO_DISMISS_TIMEOUT_MS = 6000;
private static final int IPH_AUTO_DISMISS_ACCESSIBILITY_TIMEOUT_MS = 10000;
private static boolean sOverrideIPHTimeoutForTesting;
private final Profile mProfile;
private final TabModelSelector mTabModelSelector;
private final ContextualSuggestionsModel mModel;
private final ChromeFullscreenManager mFullscreenManager;
private final ToolbarManager mToolbarManager;
private final EnabledStateMonitor mEnabledStateMonitor;
private final Handler mHandler = new Handler();
private final Provider<ContextualSuggestionsSource> mSuggestionSourceProvider;
private @Nullable final OverviewModeBehavior mOverviewModeBehavior;
private final boolean mRequireReverseScrollForIPH;
private ContextualSuggestionsCoordinator mCoordinator;
private @Nullable ContextualSuggestionsSource mSuggestionsSource;
private @Nullable FetchHelper mFetchHelper;
private @Nullable String mCurrentRequestUrl;
private @Nullable BottomSheetObserver mSheetObserver;
private @Nullable TextBubble mHelpBubble;
private boolean mModelPreparedForCurrentTab;
private boolean mSuggestionsSetOnBottomSheet;
private boolean mHasRecordedButtonShownForTab;
/**
* Whether the browser controls have fully hidden at least once since the last time
* #clearSuggestions() was called. This is used as a proxy for whether the user has scrolled
* down on the current page.
*/
private boolean mHaveBrowserControlsFullyHidden;
private int mFullscreenToken = FullscreenManager.INVALID_TOKEN;
private boolean mHasPeekDelayPassed;
/** Whether the content sheet is observed to be opened for the first time. */
private boolean mHasSheetBeenOpened;
/**
* Whether in-product help may be shown. This is set to false if the IPH system indicates that
* it wouldn't trigger our IPH if requested and when we attempt to show the IPH bubble.
*/
private boolean mCanShowIph;
/**
* Whether the current results are eligible for IPH based on the confidence of the results and
* the confidence threshold for showing IPH. Should be used in combination with other criteria
* e.g. {@link #mCanShowIph} to determine whether to request to show IPH.
*/
private boolean mCanShowIphForCurrentResults;
/**
* Construct a new {@link ContextualSuggestionsMediator}.
* @param profile The last used {@link Profile}.
* @param tabModelSelector The {@link TabModelSelector} for the containing activity.
* @param fullscreenManager The {@link ChromeFullscreenManager} to listen for browser controls
* events.
* @param model The {@link ContextualSuggestionsModel} for the component.
* @param toolbarManager The {@link ToolbarManager} for the containing activity.
* @param layoutManager The {@link LayoutManager} used to retrieve the
* {@link OverviewModeBehavior} if it exists.
* @param enabledStateMonitor The state monitor that will alert the mediator if the enabled
* state for contextual suggestions changes.
* @param suggestionSourceProvider The provider of {@link ContextualSuggestionsSource}
* instances.
*/
@Inject
ContextualSuggestionsMediator(@Named(LAST_USED_PROFILE) Profile profile,
TabModelSelector tabModelSelector, ChromeFullscreenManager fullscreenManager,
ContextualSuggestionsModel model, ToolbarManager toolbarManager,
LayoutManager layoutManager, EnabledStateMonitor enabledStateMonitor,
Provider<ContextualSuggestionsSource> suggestionSourceProvider) {
mProfile = profile.getOriginalProfile();
mTabModelSelector = tabModelSelector;
mModel = model;
mFullscreenManager = fullscreenManager;
mToolbarManager = toolbarManager;
mSuggestionSourceProvider = suggestionSourceProvider;
mEnabledStateMonitor = enabledStateMonitor;
mEnabledStateMonitor.addObserver(this);
if (mEnabledStateMonitor.getEnabledState()) {
enable();
}
mFullscreenManager.addListener(new FullscreenListener() {
@Override
public void onContentOffsetChanged(int offset) {}
@Override
public void onControlsOffsetChanged(
int topOffset, int bottomOffset, boolean needsAnimate) {
if (!mHaveBrowserControlsFullyHidden) {
mHaveBrowserControlsFullyHidden =
mFullscreenManager.areBrowserControlsOffScreen();
} else if (mCanShowIph && mCanShowIphForCurrentResults
&& mRequireReverseScrollForIPH
&& mFullscreenManager.areBrowserControlsFullyVisible()) {
mHandler.postDelayed(() -> maybeShowHelpBubble(),
ToolbarPhone.LOC_BAR_WIDTH_CHANGE_ANIMATION_DURATION_MS);
}
reportToolbarButtonShown();
}
@Override
public void onToggleOverlayVideoMode(boolean enabled) {}
@Override
public void onBottomControlsHeightChanged(int bottomControlsHeight) {}
});
if (layoutManager instanceof LayoutManagerChrome) {
mOverviewModeBehavior = (LayoutManagerChrome) layoutManager;
mOverviewModeBehavior.addOverviewModeObserver(new EmptyOverviewModeObserver() {
@Override
public void onOverviewModeFinishedHiding() {
reportToolbarButtonShown();
}
});
} else {
mOverviewModeBehavior = null;
}
mRequireReverseScrollForIPH = ChromeFeatureList.isEnabled(
ChromeFeatureList.CONTEXTUAL_SUGGESTIONS_IPH_REVERSE_SCROLL);
}
/**
* Sets the {@link ContextualSuggestionsCoordinator} for bidirectional communication.
*/
void initialize(ContextualSuggestionsCoordinator coordinator) {
// TODO(pshmakov): get rid of this circular dependency by establishing an observer-observable
// relationship between Mediator and Coordinator;
mCoordinator = coordinator;
}
/** Destroys the mediator. */
void destroy() {
if (mFetchHelper != null) {
mFetchHelper.destroy();
mFetchHelper = null;
}
if (mSuggestionsSource != null) {
mSuggestionsSource.destroy();
mSuggestionsSource = null;
}
if (mHelpBubble != null) mHelpBubble.dismiss();
mEnabledStateMonitor.removeObserver(this);
}
@Override
public void onEnabledStateChanged(boolean enabled) {
if (enabled) {
enable();
} else {
disable();
}
}
private void enable() {
mSuggestionsSource = mSuggestionSourceProvider.get();
mFetchHelper = new FetchHelper(this, mTabModelSelector);
Tracker tracker = TrackerFactory.getTrackerForProfile(mProfile);
tracker.addOnInitializedCallback(success -> {
if (!success) return;
mCanShowIph =
tracker.wouldTriggerHelpUI(FeatureConstants.CONTEXTUAL_SUGGESTIONS_FEATURE);
});
}
private void disable() {
clearSuggestions();
if (mFetchHelper != null) {
mFetchHelper.destroy();
mFetchHelper = null;
}
if (mSuggestionsSource != null) {
mSuggestionsSource.destroy();
mSuggestionsSource = null;
}
}
@Override
public void onSettingsStateChanged(boolean enabled) {}
@Override
public void requestSuggestions(String url) {
// Guard against null tabs when requesting suggestions. https://crbug.com/836672.
if (mTabModelSelector.getCurrentTab() == null
|| mTabModelSelector.getCurrentTab().getWebContents() == null) {
assert false;
return;
}
reportEvent(ContextualSuggestionsEvent.FETCH_REQUESTED);
mCurrentRequestUrl = url;
mSuggestionsSource.fetchSuggestions(url, (suggestionsResult) -> {
if (mTabModelSelector.getCurrentTab() == null
|| mTabModelSelector.getCurrentTab().getWebContents() == null
|| mSuggestionsSource == null) {
return;
}
assert mFetchHelper != null;
// Avoiding double fetches causing suggestions for incorrect context.
if (!TextUtils.equals(url, mCurrentRequestUrl)) return;
List<ContextualSuggestionsCluster> clusters = suggestionsResult.getClusters();
if (clusters.isEmpty() || clusters.get(0).getSuggestions().isEmpty()) return;
int globalSuggestionsCount = 0;
for (ContextualSuggestionsCluster cluster : clusters) {
cluster.buildChildren(globalSuggestionsCount);
globalSuggestionsCount += cluster.getSuggestions().size();
}
prepareModel(clusters, suggestionsResult.getPeekText());
// By default, we will show IPH if results have been sent to the UI layer unless
// otherwise configured via a field trial.
mCanShowIphForCurrentResults = suggestionsResult.getPeekConditions().getConfidence()
>= (float) ChromeFeatureList.getFieldTrialParamByFeatureAsDouble(
ChromeFeatureList.CONTEXTUAL_SUGGESTIONS_BUTTON,
IPH_CONFIDENCE_THRESHOLD_PARAM, 0.d);
mToolbarManager.enableExperimentalButton(
view -> onToolbarButtonClicked(),
R.drawable.contextual_suggestions,
R.string.contextual_suggestions_button_description);
RecordHistogram.recordBooleanHistogram(
"ContextualSuggestions.ResultsReturnedInOverviewMode", isOverviewModeVisible());
reportToolbarButtonShown();
});
}
private void onToolbarButtonClicked() {
if (mSuggestionsSetOnBottomSheet || !mModelPreparedForCurrentTab) return;
maybeShowContentInSheet();
RecordUserAction.record("ContextualSuggestions.ToolbarButtonClicked");
mCoordinator.showSuggestions(mSuggestionsSource);
mCoordinator.expandBottomSheet();
}
// TODO(twellington): Use peek criteria to determine when to show toolbar button or remove
// entirely.
private void setPeekConditions(ContextualSuggestionsResult suggestionsResult) {
PeekConditions peekConditions = suggestionsResult.getPeekConditions();
long remainingDelay =
mFetchHelper.getFetchTimeBaselineMillis(mTabModelSelector.getCurrentTab())
+ Math.round(peekConditions.getMinimumSecondsOnPage() * 1000)
- SystemClock.uptimeMillis();
if (remainingDelay <= 0) {
// Don't postDelayed if the minimum delay has passed so that the suggestions may
// be shown through the following call to show contents in the bottom sheet.
mHasPeekDelayPassed = true;
} else {
// Once delay expires, the bottom sheet can be peeked if the browser controls are
// already hidden, or the next time the browser controls are fully hidden and
// reshown. Note that this triggering on the latter case is handled by
// FullscreenListener#onControlsOffsetChanged() in this class.
mHandler.postDelayed(() -> {
mHasPeekDelayPassed = true;
maybeShowContentInSheet();
}, remainingDelay);
}
}
private void reportToolbarButtonShown() {
if (mHasRecordedButtonShownForTab || !mFullscreenManager.areBrowserControlsFullyVisible()
|| isOverviewModeVisible() || mSuggestionsSource == null
|| !mModel.hasSuggestions()) {
return;
}
mHasRecordedButtonShownForTab = true;
reportEvent(ContextualSuggestionsEvent.UI_BUTTON_SHOWN);
TrackerFactory.getTrackerForProfile(mProfile).notifyEvent(
EventConstants.CONTEXTUAL_SUGGESTIONS_BUTTON_SHOWN);
if (mCanShowIph && mCanShowIphForCurrentResults && !mRequireReverseScrollForIPH) {
mHandler.postDelayed(() -> maybeShowHelpBubble(),
ToolbarPhone.LOC_BAR_WIDTH_CHANGE_ANIMATION_DURATION_MS);
}
}
@Override
public void clearState() {
clearSuggestions();
}
@Override
public void reportFetchDelayed(WebContents webContents) {
if (mTabModelSelector.getCurrentTab() != null
&& mTabModelSelector.getCurrentTab().getWebContents() == webContents) {
reportEvent(ContextualSuggestionsEvent.FETCH_DELAYED);
}
}
// ListMenuButton.Delegate implementation.
@Override
public ListMenuButton.Item[] getItems() {
final Context context = ContextUtils.getApplicationContext();
if (ChromeFeatureList.isEnabled(ChromeFeatureList.CONTEXTUAL_SUGGESTIONS_OPT_OUT)) {
return new ListMenuButton.Item[] {
new ListMenuButton.Item(context, R.string.menu_preferences, true),
new ListMenuButton.Item(context, R.string.menu_send_feedback, true)};
} else {
return new ListMenuButton.Item[] {
new ListMenuButton.Item(context, R.string.menu_send_feedback, true)};
}
}
@Override
public void onItemSelected(ListMenuButton.Item item) {
if (item.getTextId() == R.string.menu_preferences) {
mCoordinator.showSettings();
} else if (item.getTextId() == R.string.menu_send_feedback) {
mCoordinator.showFeedback();
} else {
assert false : "Unhandled item detected.";
}
}
private void removeSuggestionsFromSheet() {
if (mSheetObserver != null) {
mCoordinator.removeBottomSheetObserver(mSheetObserver);
mSheetObserver = null;
}
mCoordinator.removeSuggestions();
// Wait until suggestions are fully removed to reset {@code mSuggestionsSetOnBottomSheet}.
mCoordinator.addBottomSheetObserver(new EmptyBottomSheetObserver() {
@Override
public void onSheetContentChanged(@Nullable BottomSheet.BottomSheetContent newContent) {
if (!(newContent instanceof ContextualSuggestionsBottomSheetContent)) {
mSuggestionsSetOnBottomSheet = false;
mCoordinator.removeBottomSheetObserver(this);
}
}
});
}
/**
* Called when suggestions are cleared either due to the user explicitly dismissing
* suggestions via the close button or due to the FetchHelper signaling state should
* be cleared.
*/
private void clearSuggestions() {
mModelPreparedForCurrentTab = false;
// Remove suggestions before clearing model state so that views don't respond to model
// changes while suggestions are hiding. See https://crbug.com/840579.
removeSuggestionsFromSheet();
mToolbarManager.disableExperimentalButton();
mHasRecordedButtonShownForTab = false;
mHasSheetBeenOpened = false;
mHandler.removeCallbacksAndMessages(null);
mHasPeekDelayPassed = false;
mHaveBrowserControlsFullyHidden = false;
mModel.setClusterList(Collections.emptyList());
mModel.setCloseButtonOnClickListener(null);
mModel.setMenuButtonDelegate(null);
mModel.setTitle(null);
mCurrentRequestUrl = "";
if (mSuggestionsSource != null) mSuggestionsSource.clearState();
if (mHelpBubble != null) mHelpBubble.dismiss();
}
private void prepareModel(List<ContextualSuggestionsCluster> clusters, String title) {
if (mSuggestionsSource == null) return;
mModel.setClusterList(clusters);
mModel.setCloseButtonOnClickListener(view -> {
TrackerFactory.getTrackerForProfile(mProfile).notifyEvent(
EventConstants.CONTEXTUAL_SUGGESTIONS_DISMISSED);
@ContextualSuggestionsEvent
int openedEvent =
mHasSheetBeenOpened ? ContextualSuggestionsEvent.UI_DISMISSED_AFTER_OPEN
: ContextualSuggestionsEvent.UI_DISMISSED_WITHOUT_OPEN;
reportEvent(openedEvent);
removeSuggestionsFromSheet();
});
mModel.setMenuButtonDelegate(this);
mModel.setTitle(!TextUtils.isEmpty(title)
? title
: ContextUtils.getApplicationContext().getResources().getString(
R.string.contextual_suggestions_button_description));
mModelPreparedForCurrentTab = true;
}
private void maybeShowContentInSheet() {
if (!mModel.hasSuggestions() || mSuggestionsSource == null) return;
mSuggestionsSetOnBottomSheet = true;
mSheetObserver = new EmptyBottomSheetObserver() {
@Override
public void onSheetOffsetChanged(float heightFraction, float offsetPx) {
if (mHelpBubble != null) mHelpBubble.dismiss();
}
@Override
public void onSheetOpened(@StateChangeReason int reason) {
if (!mHasSheetBeenOpened) {
mHasSheetBeenOpened = true;
TrackerFactory.getTrackerForProfile(mProfile).notifyEvent(
EventConstants.CONTEXTUAL_SUGGESTIONS_OPENED);
reportEvent(ContextualSuggestionsEvent.UI_OPENED);
}
}
@Override
public void onSheetClosed(@StateChangeReason int reason) {
removeSuggestionsFromSheet();
}
};
mCoordinator.addBottomSheetObserver(mSheetObserver);
mCoordinator.showContentInSheet();
}
private void maybeShowHelpBubble() {
View anchorView = mToolbarManager.getExperimentalButtonView();
if (!mCanShowIph || !mCanShowIphForCurrentResults || mToolbarManager.isUrlBarFocused()
|| anchorView == null || anchorView.getVisibility() != View.VISIBLE
|| !mFullscreenManager.areBrowserControlsFullyVisible()
|| mSuggestionsSource == null || !mModel.hasSuggestions()) {
return;
}
// Either we'll fail to show or we'll successfully show. Either way, we can't show IPH
// after this attempt.
mCanShowIph = false;
Tracker tracker = TrackerFactory.getTrackerForProfile(mProfile);
if (!tracker.shouldTriggerHelpUI(FeatureConstants.CONTEXTUAL_SUGGESTIONS_FEATURE)) {
return;
}
ViewRectProvider rectProvider = new ViewRectProvider(anchorView);
rectProvider.setInsetPx(0, 0, 0,
anchorView.getResources().getDimensionPixelOffset(
R.dimen.text_bubble_menu_anchor_y_inset));
mHelpBubble = new ImageTextBubble(anchorView.getContext(), anchorView,
R.string.contextual_suggestions_in_product_help,
R.string.contextual_suggestions_in_product_help_accessibility, true, rectProvider,
R.drawable.ic_logo_googleg_24dp);
mHelpBubble.setDismissOnTouchInteraction(false);
if (!sOverrideIPHTimeoutForTesting) {
mHelpBubble.setAutoDismissTimeout(AccessibilityUtil.isAccessibilityEnabled()
? IPH_AUTO_DISMISS_ACCESSIBILITY_TIMEOUT_MS
: IPH_AUTO_DISMISS_TIMEOUT_MS);
}
mHelpBubble.addOnDismissListener(() -> {
tracker.dismissed(FeatureConstants.CONTEXTUAL_SUGGESTIONS_FEATURE);
mFullscreenManager.getBrowserVisibilityDelegate().releasePersistentShowingToken(
mFullscreenToken);
mHelpBubble = null;
});
mFullscreenToken =
mFullscreenManager.getBrowserVisibilityDelegate().showControlsPersistent();
mHelpBubble.show();
}
private void reportEvent(@ContextualSuggestionsEvent int event) {
if (mTabModelSelector.getCurrentTab() == null
|| mTabModelSelector.getCurrentTab().getWebContents() == null) {
// This method is not expected to be called if the current tab or webcontents are null.
// If this assert is hit, please alert someone on the Chrome Explore on Content team.
// See https://crbug.com/836672.
assert false;
return;
}
mSuggestionsSource.reportEvent(mTabModelSelector.getCurrentTab().getWebContents(), event);
}
private boolean isOverviewModeVisible() {
return mOverviewModeBehavior != null && mOverviewModeBehavior.overviewVisible();
}
@VisibleForTesting
void showContentInSheetForTesting(boolean disablePeekDelay) {
if (disablePeekDelay) mHasPeekDelayPassed = true;
maybeShowContentInSheet();
}
@VisibleForTesting
TextBubble getHelpBubbleForTesting() {
return mHelpBubble;
}
@VisibleForTesting
static void setOverrideIPHTimeoutForTesting(boolean override) {
sOverrideIPHTimeoutForTesting = override;
}
@VisibleForTesting
boolean getCanShowIphForCurrentResults() {
return mCanShowIphForCurrentResults;
}
}