// 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.dom_distiller;
import android.content.Context;
import android.text.TextUtils;
import org.chromium.base.CommandLine;
import org.chromium.base.SysUtils;
import org.chromium.base.VisibleForTesting;
import org.chromium.base.library_loader.LibraryLoader;
import org.chromium.base.metrics.RecordHistogram;
import org.chromium.chrome.browser.ChromeActivity;
import org.chromium.chrome.browser.ChromeSwitches;
import org.chromium.chrome.browser.compositor.bottombar.OverlayPanel.PanelState;
import org.chromium.chrome.browser.compositor.bottombar.OverlayPanel.StateChangeReason;
import org.chromium.chrome.browser.compositor.bottombar.readermode.ReaderModePanel;
import org.chromium.chrome.browser.device.DeviceClassManager;
import org.chromium.chrome.browser.infobar.InfoBar;
import org.chromium.chrome.browser.infobar.InfoBarContainer;
import org.chromium.chrome.browser.infobar.InfoBarContainer.InfoBarContainerObserver;
import org.chromium.chrome.browser.rappor.RapporServiceBridge;
import org.chromium.chrome.browser.tab.Tab;
import org.chromium.chrome.browser.tabmodel.TabCreatorManager;
import org.chromium.chrome.browser.tabmodel.TabModel;
import org.chromium.chrome.browser.tabmodel.TabModelSelector;
import org.chromium.chrome.browser.tabmodel.TabModelSelectorTabObserver;
import org.chromium.chrome.browser.widget.findinpage.FindToolbarObserver;
import org.chromium.components.dom_distiller.content.DistillablePageUtils;
import org.chromium.components.dom_distiller.core.DomDistillerUrlUtils;
import org.chromium.content_public.browser.LoadUrlParams;
import org.chromium.content_public.browser.WebContents;
import org.chromium.content_public.browser.WebContentsObserver;
import org.chromium.ui.UiUtils;
import org.chromium.ui.base.DeviceFormFactor;
import org.chromium.ui.base.PageTransition;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;
/**
* Manages UI effects for reader mode including hiding and showing the
* reader mode and reader mode preferences toolbar icon and hiding the
* top controls when a reader mode page has finished loading.
*/
public class ReaderModeManager extends TabModelSelectorTabObserver
implements InfoBarContainerObserver, ReaderModeManagerDelegate {
/**
* POSSIBLE means reader mode can be entered.
*/
public static final int POSSIBLE = 0;
/**
* NOT_POSSIBLE means reader mode cannot be entered.
*/
public static final int NOT_POSSIBLE = 1;
/**
* STARTED means reader mode is currently in reader mode.
*/
public static final int STARTED = 2;
// The url of the last page visited if the last page was reader mode page. Otherwise null.
private String mReaderModePageUrl;
// Whether the fact that the current web page was distillable or not has been recorded.
private boolean mIsUmaRecorded;
// The per-tab state of distillation.
protected Map<Integer, ReaderModeTabInfo> mTabStatusMap;
// The current tab ID. This will change as the user switches between tabs.
private int mTabId;
// The ReaderModePanel that this class is managing.
protected ReaderModePanel mReaderModePanel;
// The ChromeActivity that this panel exists in.
private ChromeActivity mChromeActivity;
// The primary means of getting the currently active tab.
private TabModelSelector mTabModelSelector;
private boolean mIsFullscreenModeEntered;
private boolean mIsFindToolbarShowing;
private boolean mIsKeyboardShowing;
// InfoBar tracking.
private boolean mIsInfoBarContainerShown;
// If Reader Mode is detecting all pages as distillable.
private boolean mIsReaderHeuristicAlwaysTrue;
public ReaderModeManager(TabModelSelector selector, ChromeActivity activity) {
super(selector);
mTabId = Tab.INVALID_TAB_ID;
mTabModelSelector = selector;
mChromeActivity = activity;
mTabStatusMap = new HashMap<>();
mIsReaderHeuristicAlwaysTrue = isDistillerHeuristicAlwaysTrue();
}
/**
* This function wraps a method that calls native code and is overridden by tests.
* @return True if the heuristic is ALWAYS_TRUE.
*/
protected boolean isDistillerHeuristicAlwaysTrue() {
return DomDistillerTabUtils.isHeuristicAlwaysTrue();
}
/**
* Clear the status map and references to other objects.
*/
@Override
public void destroy() {
super.destroy();
for (Map.Entry<Integer, ReaderModeTabInfo> e : mTabStatusMap.entrySet()) {
if (e.getValue().getWebContentsObserver() != null) {
e.getValue().getWebContentsObserver().destroy();
}
}
mTabStatusMap.clear();
DomDistillerUIUtils.destroy(this);
mChromeActivity = null;
mReaderModePanel = null;
mTabModelSelector = null;
}
/**
* @return A FindToolbarObserver capable of hiding the Reader Mode panel.
*/
public FindToolbarObserver getFindToolbarObserver() {
return new FindToolbarObserver() {
@Override
public void onFindToolbarShown() {
mIsFindToolbarShowing = true;
closeReaderPanel(StateChangeReason.UNKNOWN, true);
}
@Override
public void onFindToolbarHidden() {
mIsFindToolbarShowing = false;
requestReaderPanelShow(StateChangeReason.UNKNOWN);
}
};
}
// TabModelSelectorTabObserver:
@Override
public void onShown(Tab shownTab) {
int shownTabId = shownTab.getId();
Tab previousTab = mTabModelSelector.getTabById(mTabId);
mTabId = shownTabId;
// If the reader panel was dismissed, stop here.
if (mTabStatusMap.containsKey(shownTabId)
&& mTabStatusMap.get(shownTabId).isDismissed()) {
return;
}
// Set this manager as the active one for the UI utils.
DomDistillerUIUtils.setReaderModeManagerDelegate(this);
// Update infobar state based on current tab.
if (shownTab.getInfoBarContainer() != null) {
mIsInfoBarContainerShown = shownTab.getInfoBarContainer().hasInfoBars();
}
// Remove the infobar observer from the previous tab and attach it to the current one.
if (previousTab != null && previousTab.getInfoBarContainer() != null) {
previousTab.getInfoBarContainer().removeObserver(this);
}
if (shownTab.getInfoBarContainer() != null) {
shownTab.getInfoBarContainer().addObserver(this);
}
// If there is no state info for this tab, create it.
ReaderModeTabInfo tabInfo = mTabStatusMap.get(shownTabId);
if (tabInfo == null) {
tabInfo = new ReaderModeTabInfo();
tabInfo.setStatus(NOT_POSSIBLE);
tabInfo.setUrl(shownTab.getUrl());
mTabStatusMap.put(shownTabId, tabInfo);
}
// Make sure there is a WebContentsObserver on this tab's WebContents.
if (tabInfo.getWebContentsObserver() == null) {
tabInfo.setWebContentsObserver(createWebContentsObserver(shownTab.getWebContents()));
}
// Make sure there is a distillability delegate set on the WebContents.
setDistillabilityCallback(shownTabId);
requestReaderPanelShow(StateChangeReason.UNKNOWN);
}
@Override
public void onHidden(Tab tab) {
closeReaderPanel(StateChangeReason.UNKNOWN, false);
}
@Override
public void onDestroyed(Tab tab) {
if (tab == null) return;
if (tab.getInfoBarContainer() != null) {
tab.getInfoBarContainer().removeObserver(this);
}
// If the panel was not shown for the previous navigation, record it now.
ReaderModeTabInfo info = mTabStatusMap.get(tab.getId());
if (info != null && !info.isPanelShowRecorded()) {
recordPanelVisibilityForNavigation(false);
}
removeTabState(tab.getId());
}
/**
* Clean up the state associated with a tab.
* @param tabId The target tab ID.
*/
private void removeTabState(int tabId) {
if (!mTabStatusMap.containsKey(tabId)) return;
ReaderModeTabInfo tabInfo = mTabStatusMap.get(tabId);
if (tabInfo.getWebContentsObserver() != null) {
tabInfo.getWebContentsObserver().destroy();
}
mTabStatusMap.remove(tabId);
}
@Override
public void onContentChanged(Tab tab) {
// Only listen to events on the currently active tab.
if (tab.getId() != mTabId) return;
closeReaderPanel(StateChangeReason.UNKNOWN, false);
if (mTabStatusMap.containsKey(mTabId)) {
// If the panel was closed using the "x" icon, don't show it again for this tab.
if (mTabStatusMap.get(mTabId).isDismissed()) return;
removeTabState(mTabId);
}
ReaderModeTabInfo tabInfo = new ReaderModeTabInfo();
tabInfo.setStatus(NOT_POSSIBLE);
tabInfo.setUrl(tab.getUrl());
mTabStatusMap.put(tab.getId(), tabInfo);
if (tab.getWebContents() != null) {
tabInfo.setWebContentsObserver(createWebContentsObserver(tab.getWebContents()));
if (DomDistillerUrlUtils.isDistilledPage(tab.getUrl())) {
tabInfo.setStatus(STARTED);
mReaderModePageUrl = tab.getUrl();
closeReaderPanel(StateChangeReason.CONTENT_CHANGED, true);
}
// Make sure there is a distillability delegate set on the WebContents.
setDistillabilityCallback(tab.getId());
}
if (tab.getInfoBarContainer() != null) tab.getInfoBarContainer().addObserver(this);
}
@Override
public void onToggleFullscreenMode(Tab tab, boolean enable) {
// Temporarily hide the reader mode panel while fullscreen is enabled.
if (enable) {
mIsFullscreenModeEntered = true;
closeReaderPanel(StateChangeReason.FULLSCREEN_ENTERED, false);
} else {
mIsFullscreenModeEntered = false;
requestReaderPanelShow(StateChangeReason.FULLSCREEN_EXITED);
}
}
// InfoBarContainerObserver:
@Override
public void onAddInfoBar(InfoBarContainer container, InfoBar infoBar, boolean isFirst) {
mIsInfoBarContainerShown = true;
// If the panel is opened past the peeking state, obscure the infobar.
if (mReaderModePanel != null && mReaderModePanel.isPanelOpened() && container != null) {
container.setIsObscuredByOtherView(true);
} else if (isFirst) {
// Temporarily hides the reader mode button while the infobars are shown.
closeReaderPanel(StateChangeReason.INFOBAR_SHOWN, false);
}
}
@Override
public void onRemoveInfoBar(InfoBarContainer container, InfoBar infoBar, boolean isLast) {
// Re-shows the reader mode button if necessary once the infobars are dismissed.
if (isLast) {
mIsInfoBarContainerShown = false;
requestReaderPanelShow(StateChangeReason.INFOBAR_HIDDEN);
}
}
@Override
public void onInfoBarContainerAttachedToWindow(boolean hasInfoBars) {
mIsInfoBarContainerShown = hasInfoBars;
if (mIsInfoBarContainerShown) {
closeReaderPanel(StateChangeReason.INFOBAR_SHOWN, false);
} else {
requestReaderPanelShow(StateChangeReason.INFOBAR_HIDDEN);
}
}
// ReaderModeManagerDelegate:
@Override
public void setReaderModePanel(ReaderModePanel panel) {
mReaderModePanel = panel;
}
@Override
public ChromeActivity getChromeActivity() {
return mChromeActivity;
}
@Override
public void onPanelShown() {
if (mTabModelSelector == null) return;
int tabId = mTabModelSelector.getCurrentTabId();
ReaderModeTabInfo info = mTabStatusMap.get(tabId);
if (info != null && !info.isPanelShowRecorded()) {
info.setIsPanelShowRecorded(true);
recordPanelVisibilityForNavigation(true);
if (LibraryLoader.isInitialized()) {
RapporServiceBridge.sampleDomainAndRegistryFromURL(
"DomDistiller.PromptPanel", info.getUrl());
}
}
}
/**
* Record if the panel became visible on the current page. This can be overridden for testing.
* @param visible If the panel was visible at any time.
*/
protected void recordPanelVisibilityForNavigation(boolean visible) {
RecordHistogram.recordBooleanHistogram("DomDistiller.ReaderShownForPageLoad", visible);
}
@Override
public void onClosed(StateChangeReason reason) {
if (mReaderModePanel == null || mTabModelSelector == null) return;
restoreInfobars();
// Only dismiss the panel if the close was a result of user interaction.
if (reason != StateChangeReason.FLING && reason != StateChangeReason.SWIPE
&& reason != StateChangeReason.CLOSE_BUTTON) {
return;
}
// Record close button usage.
if (reason == StateChangeReason.CLOSE_BUTTON) {
RecordHistogram.recordBooleanHistogram("DomDistiller.BarCloseButtonUsage",
mReaderModePanel.getPanelState() == PanelState.EXPANDED
|| mReaderModePanel.getPanelState() == PanelState.MAXIMIZED);
}
int currentTabId = mTabModelSelector.getCurrentTabId();
if (!mTabStatusMap.containsKey(currentTabId)) return;
mTabStatusMap.get(currentTabId).setIsDismissed(true);
}
@Override
public void onPeek() {
restoreInfobars();
}
/**
* Restore any infobars that may have been hidden by Reader Mode.
*/
private void restoreInfobars() {
if (!mIsInfoBarContainerShown) return;
Tab curTab = mTabModelSelector.getCurrentTab();
if (curTab == null) return;
InfoBarContainer container = curTab.getInfoBarContainer();
if (container == null) return;
container.setIsObscuredByOtherView(false);
// Temporarily hides the reader mode button while the infobars are shown.
closeReaderPanel(StateChangeReason.INFOBAR_SHOWN, false);
}
@Override
public WebContents getBasePageWebContents() {
Tab tab = mTabModelSelector.getCurrentTab();
if (tab == null) return null;
return tab.getWebContents();
}
@Override
public void closeReaderPanel(StateChangeReason reason, boolean animate) {
if (mReaderModePanel == null) return;
mReaderModePanel.closePanel(reason, animate);
}
@Override
public void recordTimeSpentInReader(long timeMs) {
RecordHistogram.recordLongTimesHistogram("DomDistiller.Time.ViewingReaderModePanel",
timeMs, TimeUnit.MILLISECONDS);
}
@Override
public void onLayoutChanged() {
if (isKeyboardShowing()) {
mIsKeyboardShowing = true;
closeReaderPanel(StateChangeReason.KEYBOARD_SHOWN, false);
} else if (mIsKeyboardShowing) {
mIsKeyboardShowing = false;
requestReaderPanelShow(StateChangeReason.KEYBOARD_HIDDEN);
}
}
/**
* @return True if the keyboard might be showing. This is not 100% accurate; see
* UiUtils.isKeyboardShowing(...).
*/
protected boolean isKeyboardShowing() {
return mChromeActivity != null && UiUtils.isKeyboardShowing(mChromeActivity,
mChromeActivity.findViewById(android.R.id.content));
}
protected WebContentsObserver createWebContentsObserver(WebContents webContents) {
final int readerTabId = mTabModelSelector.getCurrentTabId();
if (readerTabId == Tab.INVALID_TAB_ID) return null;
return new WebContentsObserver(webContents) {
@Override
public void didStartProvisionalLoadForFrame(long frameId, long parentFrameId,
boolean isMainFrame, String validatedUrl, boolean isErrorPage,
boolean isIframeSrcdoc) {
if (!isMainFrame) return;
// If there is a navigation in the current tab, hide the bar. It will show again
// once the distillability test is successful.
if (readerTabId == mTabModelSelector.getCurrentTabId()) {
closeReaderPanel(StateChangeReason.TAB_NAVIGATION, false);
}
// Make sure the tab was not destroyed.
ReaderModeTabInfo tabInfo = mTabStatusMap.get(readerTabId);
if (tabInfo == null) return;
tabInfo.setUrl(validatedUrl);
if (DomDistillerUrlUtils.isDistilledPage(validatedUrl)) {
tabInfo.setStatus(STARTED);
mReaderModePageUrl = validatedUrl;
}
}
@Override
public void didNavigateMainFrame(String url, String baseUrl,
boolean isNavigationToDifferentPage, boolean isNavigationInPage,
int statusCode) {
// TODO(cjhopman): This should possibly ignore navigations that replace the entry
// (like those from history.replaceState()).
if (isNavigationInPage) return;
if (DomDistillerUrlUtils.isDistilledPage(url)) return;
// Make sure the tab was not destroyed.
ReaderModeTabInfo tabInfo = mTabStatusMap.get(readerTabId);
if (tabInfo == null) return;
tabInfo.setStatus(POSSIBLE);
if (!TextUtils.equals(url,
DomDistillerUrlUtils.getOriginalUrlFromDistillerUrl(
mReaderModePageUrl))) {
tabInfo.setStatus(NOT_POSSIBLE);
mIsUmaRecorded = false;
}
mReaderModePageUrl = null;
if (tabInfo.getStatus() != POSSIBLE) {
closeReaderPanel(StateChangeReason.UNKNOWN, false);
} else {
requestReaderPanelShow(StateChangeReason.UNKNOWN);
}
}
@Override
public void navigationEntryCommitted() {
// Make sure the tab was not destroyed.
ReaderModeTabInfo tabInfo = mTabStatusMap.get(readerTabId);
if (tabInfo == null) return;
// Reset closed state of reader mode in this tab once we know a navigation is
// happening.
tabInfo.setIsDismissed(false);
// If the panel was not shown for the previous navigation, record it now.
Tab curTab = mTabModelSelector.getTabById(readerTabId);
if (curTab != null && !curTab.isNativePage() && !curTab.isBeingRestored()) {
recordPanelVisibilityForNavigation(false);
}
tabInfo.setIsPanelShowRecorded(false);
}
};
}
/**
* This is a wrapper for "requestPanelShow" that checks if reader mode is possible before
* showing.
* @param reason The reason the panel is requesting to be shown.
*/
protected void requestReaderPanelShow(StateChangeReason reason) {
if (mTabModelSelector == null) return;
int currentTabId = mTabModelSelector.getCurrentTabId();
if (currentTabId == Tab.INVALID_TAB_ID) return;
// Test if the user is requesting the desktop site. Ignore this if distiller is set to
// ALWAYS_TRUE.
boolean usingRequestDesktopSite = getBasePageWebContents() != null
&& getBasePageWebContents().getNavigationController().getUseDesktopUserAgent()
&& !mIsReaderHeuristicAlwaysTrue;
if (mReaderModePanel == null || !mTabStatusMap.containsKey(currentTabId)
|| usingRequestDesktopSite
|| mTabStatusMap.get(currentTabId).getStatus() != POSSIBLE
|| mTabStatusMap.get(currentTabId).isDismissed()
|| mIsInfoBarContainerShown
|| mIsFindToolbarShowing
|| mIsFullscreenModeEntered
|| mIsKeyboardShowing
|| DeviceClassManager.isAccessibilityModeEnabled(mChromeActivity)) {
return;
}
mReaderModePanel.requestPanelShow(reason);
}
/**
* Open a link from the panel in a new tab.
* @param url The URL to load.
*/
public void createNewTab(String url) {
if (mChromeActivity == null) return;
Tab currentTab = mTabModelSelector.getCurrentTab();
if (currentTab == null) return;
TabCreatorManager.TabCreator tabCreator =
mChromeActivity.getTabCreator(currentTab.isIncognito());
if (tabCreator == null) return;
tabCreator.createNewTab(new LoadUrlParams(url, PageTransition.LINK),
TabModel.TabLaunchType.FROM_LINK, mChromeActivity.getActivityTab());
}
/**
* @return Whether the Reader Mode panel is opened (state is EXPANDED or MAXIMIZED).
*/
public boolean isPanelOpened() {
if (mReaderModePanel == null) return false;
return mReaderModePanel.isPanelOpened();
}
/**
* @return The ReaderModePanel for testing.
*/
@VisibleForTesting
public ReaderModePanel getPanelForTesting() {
return mReaderModePanel;
}
/**
* Set the callback for updating reader mode status based on whether or not the page should
* be viewed in reader mode.
* @param tabId The ID of the tab having its callback set.
*/
private void setDistillabilityCallback(final int tabId) {
if (tabId == Tab.INVALID_TAB_ID || mTabStatusMap.get(tabId).isCallbackSet()) {
return;
}
if (mTabModelSelector == null) return;
Tab currentTab = mTabModelSelector.getTabById(tabId);
if (currentTab == null || currentTab.getWebContents() == null
|| currentTab.getContentViewCore() == null) {
return;
}
DistillablePageUtils.setDelegate(currentTab.getWebContents(),
new DistillablePageUtils.PageDistillableDelegate() {
@Override
public void onIsPageDistillableResult(boolean isDistillable, boolean isLast) {
if (mTabModelSelector == null) return;
ReaderModeTabInfo tabInfo = mTabStatusMap.get(tabId);
Tab readerTab = mTabModelSelector.getTabById(tabId);
// It is possible that the tab was destroyed before this callback happens.
// TODO(wychen/mdjones): Remove the callback when a Tab/WebContents is
// destroyed so that this never happens.
if (readerTab == null || tabInfo == null) return;
// Make sure the page didn't navigate while waiting for a response.
if (!readerTab.getUrl().equals(tabInfo.getUrl())) return;
if (isDistillable) {
tabInfo.setStatus(POSSIBLE);
// The user may have changed tabs.
if (tabId == mTabModelSelector.getCurrentTabId()) {
// TODO(mdjones): Add reason DISTILLER_STATE_CHANGE.
requestReaderPanelShow(StateChangeReason.UNKNOWN);
}
} else {
tabInfo.setStatus(NOT_POSSIBLE);
}
if (!mIsUmaRecorded && (tabInfo.getStatus() == POSSIBLE || isLast)) {
mIsUmaRecorded = true;
RecordHistogram.recordBooleanHistogram(
"DomDistiller.PageDistillable",
tabInfo.getStatus() == POSSIBLE);
}
}
});
mTabStatusMap.get(tabId).setIsCallbackSet(true);
}
/**
* @return Whether Reader mode and its new UI are enabled.
* @param context A context
*/
public static boolean isEnabled(Context context) {
if (context == null) return false;
boolean enabled = CommandLine.getInstance().hasSwitch(ChromeSwitches.ENABLE_DOM_DISTILLER)
&& !CommandLine.getInstance().hasSwitch(
ChromeSwitches.DISABLE_READER_MODE_BOTTOM_BAR)
&& !DeviceFormFactor.isTablet(context)
&& DomDistillerTabUtils.isDistillerHeuristicsEnabled()
&& !SysUtils.isLowEndDevice();
return enabled;
}
}