blob: aa473773c43f46e8219575811dfba69f94d3e3ef [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 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.chrome.R;
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.profiles.Profile;
import org.chromium.chrome.browser.tabmodel.TabModelSelector;
import org.chromium.chrome.browser.util.MathUtils;
import org.chromium.chrome.browser.widget.ListMenuButton;
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.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;
/**
* 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).
*/
class ContextualSuggestionsMediator
implements EnabledStateMonitor.Observer, FetchHelper.Delegate, ListMenuButton.Delegate {
private final Profile mProfile;
private final TabModelSelector mTabModelSelector;
private final ContextualSuggestionsCoordinator mCoordinator;
private final ContextualSuggestionsModel mModel;
private final ChromeFullscreenManager mFullscreenManager;
private final View mIphParentView;
private final EnabledStateMonitor mEnabledStateMonitor;
private final Handler mHandler = new Handler();
private @Nullable ContextualSuggestionsSource mSuggestionsSource;
private @Nullable FetchHelper mFetchHelper;
private @Nullable String mCurrentRequestUrl;
private @Nullable BottomSheetObserver mSheetObserver;
private @Nullable TextBubble mHelpBubble;
private boolean mDidSuggestionsShowForTab;
private boolean mHasRecordedPeekEventForTab;
private boolean mHasPeekDelayPassed;
private boolean mUpdateRemainingCountOnNextPeek;
private float mRemainingPeekCount;
/** Whether the content sheet is observed to be opened for the first time. */
private boolean mHasSheetBeenOpened;
/**
* Construct a new {@link ContextualSuggestionsMediator}.
* @param profile The regular {@link Profile}.
* @param tabModelSelector The {@link TabModelSelector} for the containing activity.
* @param fullscreenManager The {@link ChromeFullscreenManager} to listen for browser controls
* events.
* @param coordinator The {@link ContextualSuggestionsCoordinator} for the component.
* @param model The {@link ContextualSuggestionsModel} for the component.
* @param iphParentView The parent {@link View} used to anchor an in-product help bubble.
*/
ContextualSuggestionsMediator(Profile profile, TabModelSelector tabModelSelector,
ChromeFullscreenManager fullscreenManager, ContextualSuggestionsCoordinator coordinator,
ContextualSuggestionsModel model, View iphParentView) {
mProfile = profile;
mTabModelSelector = tabModelSelector;
mCoordinator = coordinator;
mModel = model;
mFullscreenManager = fullscreenManager;
mIphParentView = iphParentView;
// Create a state monitor that will alert this mediator if the enabled state for contextual
// suggestions changes.
mEnabledStateMonitor =
ContextualSuggestionsDependencyFactory.getInstance().createEnabledStateMonitor(
this);
fullscreenManager.addListener(new FullscreenListener() {
@Override
public void onContentOffsetChanged(float offset) {}
@Override
public void onControlsOffsetChanged(
float topOffset, float bottomOffset, boolean needsAnimate) {
// When the controls scroll completely off-screen, the suggestions are "shown" but
// remain hidden since their offset from the bottom of the screen is determined by
// the top controls.
if (!mDidSuggestionsShowForTab && mModel.hasSuggestions()
&& areBrowserControlsHidden() && mSuggestionsSource != null) {
showContentInSheet();
}
}
@Override
public void onToggleOverlayVideoMode(boolean enabled) {}
@Override
public void onBottomControlsHeightChanged(int bottomControlsHeight) {}
});
}
/** Destroys the mediator. */
void destroy() {
mEnabledStateMonitor.destroy();
if (mFetchHelper != null) {
mFetchHelper.destroy();
mFetchHelper = null;
}
if (mSuggestionsSource != null) {
mSuggestionsSource.destroy();
mSuggestionsSource = null;
}
if (mHelpBubble != null) mHelpBubble.dismiss();
}
/** Called when accessibility mode changes. */
void onAccessibilityModeChanged() {
mEnabledStateMonitor.onAccessibilityModeChanged();
}
/**
* @return Whether the browser controls are currently completely hidden.
*/
private boolean areBrowserControlsHidden() {
return MathUtils.areFloatsEqual(-mFullscreenManager.getTopControlOffset(),
mFullscreenManager.getTopControlsHeight());
}
@Override
public void onEnabledStateChanged(boolean enabled) {
if (enabled) {
mSuggestionsSource = ContextualSuggestionsDependencyFactory.getInstance()
.createContextualSuggestionsSource(mProfile);
mFetchHelper = ContextualSuggestionsDependencyFactory.getInstance().createFetchHelper(
this, mTabModelSelector);
mFetchHelper.initialize();
} else {
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 (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();
PeekConditions peekConditions = suggestionsResult.getPeekConditions();
mRemainingPeekCount = peekConditions.getMaximumNumberOfPeeks();
long remainingDelay =
mFetchHelper.getFetchTimeBaselineMillis(mTabModelSelector.getCurrentTab())
+ Math.round(peekConditions.getMinimumSecondsOnPage() * 1000)
- SystemClock.uptimeMillis();
Runnable runnable = () -> mHasPeekDelayPassed = true;
if (remainingDelay <= 0) {
// Don't postDelayed if the minimum delay has passed so that the suggestions may
// be shown through the following #showContentInSheet() call.
runnable.run();
} else {
// Once delay expires, the bottom sheet can be peeked the next time the browser
// controls are fully hidden and reshown. Note that this triggering is handled by
// FullscreenListener#onControlsOffsetChanged() in this class.
mHandler.postDelayed(runnable, remainingDelay);
}
if (clusters.size() > 0 && clusters.get(0).getSuggestions().size() > 0) {
preloadContentInSheet(
generateClusterList(clusters), suggestionsResult.getPeekText());
// If the controls are already off-screen, show the suggestions immediately so they
// are available on reverse scroll.
if (areBrowserControlsHidden()) showContentInSheet();
}
});
}
@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();
return new ListMenuButton.Item[] {
new ListMenuButton.Item(context, R.string.menu_preferences, true),
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.";
}
}
/**
* 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() {
// TODO(twellington): Does this signal need to go back to FetchHelper?
mDidSuggestionsShowForTab = false;
mHasRecordedPeekEventForTab = false;
mHasSheetBeenOpened = false;
mHandler.removeCallbacksAndMessages(null);
mHasPeekDelayPassed = false;
mUpdateRemainingCountOnNextPeek = false;
mRemainingPeekCount = 0f;
mModel.setClusterList(new ClusterList(Collections.emptyList()));
mModel.setCloseButtonOnClickListener(null);
mModel.setMenuButtonVisibility(false);
mModel.setMenuButtonAlpha(0f);
mModel.setMenuButtonDelegate(null);
mModel.setDefaultToolbarClickListener(null);
mModel.setTitle(null);
mCoordinator.removeSuggestions();
mCurrentRequestUrl = "";
if (mSuggestionsSource != null) mSuggestionsSource.clearState();
if (mSheetObserver != null) {
mCoordinator.removeBottomSheetObserver(mSheetObserver);
}
if (mHelpBubble != null) mHelpBubble.dismiss();
}
private void preloadContentInSheet(ClusterList 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);
clearSuggestions();
});
mModel.setMenuButtonVisibility(false);
mModel.setMenuButtonAlpha(0f);
mModel.setMenuButtonDelegate(this);
mModel.setDefaultToolbarClickListener(view -> mCoordinator.expandBottomSheet());
mModel.setTitle(title);
mCoordinator.preloadContentInSheet();
}
private void showContentInSheet() {
if (!mHasPeekDelayPassed || !hasRemainingPeek()) return;
mDidSuggestionsShowForTab = true;
mUpdateRemainingCountOnNextPeek = true;
mSheetObserver = new EmptyBottomSheetObserver() {
@Override
public void onSheetFullyPeeked() {
if (mUpdateRemainingCountOnNextPeek) {
mUpdateRemainingCountOnNextPeek = false;
--mRemainingPeekCount;
}
if (mHasRecordedPeekEventForTab) return;
assert !mHasSheetBeenOpened;
mHasRecordedPeekEventForTab = true;
TrackerFactory.getTrackerForProfile(mProfile).notifyEvent(
EventConstants.CONTEXTUAL_SUGGESTIONS_PEEKED);
reportEvent(ContextualSuggestionsEvent.UI_PEEK_REVERSE_SCROLL);
maybeShowHelpBubble();
}
@Override
public void onSheetOffsetChanged(float heightFraction) {
if (mHelpBubble != null) mHelpBubble.dismiss();
// When sheet is fully hidden, clear suggestions if the sheet is not allowed to peek
// anymore or reset mUpdateCountOnNextPeek so mRemainingPeekCount is updated the
// next time the sheet fully peeks.
if (Float.compare(0f, heightFraction) == 0) {
if (hasRemainingPeek()) {
mUpdateRemainingCountOnNextPeek = true;
} else {
clearSuggestions();
}
}
}
@Override
public void onSheetOpened(@StateChangeReason int reason) {
if (!mHasSheetBeenOpened) {
mHasSheetBeenOpened = true;
TrackerFactory.getTrackerForProfile(mProfile).notifyEvent(
EventConstants.CONTEXTUAL_SUGGESTIONS_OPENED);
mCoordinator.showSuggestions(mSuggestionsSource);
reportEvent(ContextualSuggestionsEvent.UI_OPENED);
}
mModel.setMenuButtonVisibility(true);
}
@Override
public void onSheetClosed(int reason) {
mModel.setMenuButtonVisibility(false);
}
@Override
public void onTransitionPeekToHalf(float transitionFraction) {
mModel.setMenuButtonAlpha(transitionFraction);
}
};
mCoordinator.addBottomSheetObserver(mSheetObserver);
mCoordinator.showContentInSheet();
}
private boolean hasRemainingPeek() {
return Float.compare(mRemainingPeekCount, 1f) >= 0;
}
private void maybeShowHelpBubble() {
Tracker tracker = TrackerFactory.getTrackerForProfile(mProfile);
if (!tracker.shouldTriggerHelpUI(FeatureConstants.CONTEXTUAL_SUGGESTIONS_FEATURE)) {
return;
}
ViewRectProvider rectProvider = new ViewRectProvider(mIphParentView);
rectProvider.setInsetPx(0,
mIphParentView.getResources().getDimensionPixelSize(R.dimen.toolbar_shadow_height),
0, 0);
mHelpBubble = new TextBubble(mIphParentView.getContext(), mIphParentView,
R.string.contextual_suggestions_in_product_help,
R.string.contextual_suggestions_in_product_help, rectProvider);
mHelpBubble.setDismissOnTouchInteraction(true);
mHelpBubble.addOnDismissListener(() -> {
tracker.dismissed(FeatureConstants.CONTEXTUAL_SUGGESTIONS_FEATURE);
mHelpBubble = null;
});
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 ClusterList generateClusterList(List<ContextualSuggestionsCluster> clusters) {
for (ContextualSuggestionsCluster cluster : clusters) {
cluster.buildChildren();
}
return new ClusterList(clusters);
}
@VisibleForTesting
void showContentInSheetForTesting(boolean disablePeekDelay) {
if (disablePeekDelay) mHasPeekDelayPassed = true;
showContentInSheet();
}
@VisibleForTesting
TextBubble getHelpBubbleForTesting() {
return mHelpBubble;
}
}