// 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 androidx.annotation.Nullable;
import org.jni_zero.CalledByNative;
import org.jni_zero.JniType;
import org.jni_zero.NativeMethods;
import org.chromium.base.Callback;
import org.chromium.base.Log;
import org.chromium.base.supplier.ObservableSupplier;
import org.chromium.base.supplier.OneShotCallback;
import org.chromium.chrome.browser.compositor.bottombar.OverlayPanel.StateChangeReason;
import org.chromium.chrome.browser.firstrun.FirstRunStatus;
import org.chromium.chrome.browser.flags.ChromeFeatureList;
import org.chromium.chrome.browser.locale.LocaleManager;
import org.chromium.chrome.browser.profiles.Profile;
import org.chromium.chrome.browser.readaloud.ReadAloudController;
import org.chromium.chrome.browser.readaloud.ReadAloudControllerSupplier;
import org.chromium.chrome.browser.search_engines.TemplateUrlServiceFactory;
import org.chromium.chrome.browser.tab.EmptyTabObserver;
import org.chromium.chrome.browser.tab.Tab;
import org.chromium.components.search_engines.TemplateUrlService;
import org.chromium.components.search_engines.TemplateUrlService.TemplateUrlServiceObserver;
import org.chromium.content_public.browser.GestureListenerManager;
import org.chromium.content_public.browser.GestureStateListener;
import org.chromium.content_public.browser.SelectionClient;
import org.chromium.content_public.browser.SelectionPopupController;
import org.chromium.content_public.browser.WebContents;
import org.chromium.net.NetworkChangeNotifier;
import org.chromium.ui.base.WindowAndroid;
import org.chromium.url.GURL;
/** Manages the enabling and disabling and gesture listeners for ContextualSearch on a given Tab. */
public class ContextualSearchTabHelper extends EmptyTabObserver
implements NetworkChangeNotifier.ConnectionTypeObserver, TemplateUrlServiceObserver {
private static final String TAG = "ContextualSearch";
/** The Tab that this helper tracks. */
private final Tab mTab;
// Device scale factor.
private final float mPxToDp;
private TemplateUrlService mTemplateUrlService;
/** The WebContents associated with the Tab which this helper is monitoring, unless detached. */
private WebContents mWebContents;
/**
* The {@link ContextualSearchManager} that's managing this tab. This may point to
* the manager from another activity during reparenting, or be {@code null} during startup.
*/
private ContextualSearchManager mContextualSearchManager;
/** The GestureListener used for handling events from the current WebContents. */
private GestureStateListener mGestureStateListener;
/** Manages incoming calls to Smart Select when available, for the current base WebContents. */
private SelectionClientManager mSelectionClientManager;
/** The pointer to our native C++ implementation. */
private long mNativeHelper;
/** Whether the current default search engine is Google. Is {@code null} if not inited. */
private Boolean mIsDefaultSearchEngineGoogle;
private Callback<ContextualSearchManager> mManagerCallback;
/** The ReadAloudController supplier to get the active playback tab supplier when available. */
private ObservableSupplier<ReadAloudController> mReadAloudControllerSupplier;
/** To listen for when the current tab has an active ReadAloud playback. */
private ObservableSupplier<Tab> mReadAloudActivePlaybackTab;
/** Callback for when the ReadAloudController is ready. */
private OneShotCallback<ReadAloudController> mReadAloudControllerSupplierCallback;
/**
* Creates a contextual search tab helper for the given tab.
*
* @param tab The tab whose contextual search actions will be handled by this helper.
*/
public static void createForTab(Tab tab) {
new ContextualSearchTabHelper(tab);
}
/**
* Constructs a Tab helper that can enable and disable Contextual Search based on Tab activity.
* @param tab The {@link Tab} to track with this helper.
*/
private ContextualSearchTabHelper(Tab tab) {
mTab = tab;
tab.addObserver(this);
// Connect to a network, unless under test.
if (NetworkChangeNotifier.isInitialized()) {
NetworkChangeNotifier.addConnectionTypeObserver(this);
}
float scaleFactor = 1.f;
Context context = tab != null ? tab.getContext() : null;
if (context != null) scaleFactor /= context.getResources().getDisplayMetrics().density;
mPxToDp = scaleFactor;
mManagerCallback = (ContextualSearchManager manager) -> updateHooksForTab(mTab);
if (isReadAloudTapToSeekEnabled()) {
mReadAloudControllerSupplier = getReadAloudControllerSupplier(tab);
if (mReadAloudControllerSupplier != null) {
mReadAloudControllerSupplierCallback =
new OneShotCallback<ReadAloudController>(
mReadAloudControllerSupplier,
this::onReadAloudControllerSupplierReady);
}
}
}
// ============================================================================================
// EmptyTabObserver overrides.
// ============================================================================================
@Override
public void onPageLoadStarted(Tab tab, GURL url) {
updateHooksForTab(tab);
ContextualSearchManager manager = getContextualSearchManager(tab);
if (manager != null) manager.onBasePageLoadStarted();
}
private void onReadAloudControllerSupplierReady(ReadAloudController readAloudController) {
if (readAloudController == null) return;
if (mReadAloudActivePlaybackTab == null) {
mReadAloudActivePlaybackTab = readAloudController.getActivePlaybackTabSupplier();
}
if (mReadAloudActivePlaybackTab != null) {
mReadAloudActivePlaybackTab.addObserver(this::onActivePlaybackTabUpdated);
}
}
private void onActivePlaybackTabUpdated(Tab tab) {
updateContextualSearchHooks(mTab.getWebContents());
}
@Override
public void onContentChanged(Tab tab) {
// Native initialization happens after a page loads or content is changed to ensure profile
// is initialized.
Profile profile = tab.getProfile();
if (mNativeHelper == 0 && tab.getWebContents() != null) {
mNativeHelper =
ContextualSearchTabHelperJni.get()
.init(ContextualSearchTabHelper.this, profile);
}
if (profile != null && mTemplateUrlService == null) {
mTemplateUrlService = TemplateUrlServiceFactory.getForProfile(profile);
mTemplateUrlService.addObserver(this);
if (mTemplateUrlService.isLoaded()) onTemplateURLServiceChanged();
}
updateHooksForTab(tab);
}
@Override
public void onWebContentsSwapped(Tab tab, boolean didStartLoad, boolean didFinishLoad) {
updateHooksForTab(tab);
}
@Override
public void onDestroyed(Tab tab) {
if (mNativeHelper != 0) {
ContextualSearchTabHelperJni.get()
.destroy(mNativeHelper, ContextualSearchTabHelper.this);
mNativeHelper = 0;
}
if (mTemplateUrlService != null) {
mTemplateUrlService.removeObserver(this);
}
if (NetworkChangeNotifier.isInitialized()) {
NetworkChangeNotifier.removeConnectionTypeObserver(this);
}
removeContextualSearchHooks(mWebContents);
mWebContents = null;
mContextualSearchManager = null;
mSelectionClientManager = null;
mGestureStateListener = null;
ObservableSupplier<ContextualSearchManager> supplier =
getContextualSearchManagerSupplier(mTab);
if (supplier != null) {
supplier.removeObserver(mManagerCallback);
}
}
@Override
public void onActivityAttachmentChanged(Tab tab, @Nullable WindowAndroid window) {
if (window != null) {
updateHooksForTab(tab);
maybeObserveManagerCreation();
} else {
removeContextualSearchHooks(mWebContents);
mContextualSearchManager = null;
}
}
@Override
public void onContextMenuShown(Tab tab) {
ContextualSearchManager manager = getContextualSearchManager(tab);
if (manager != null) {
manager.onContextMenuShown();
}
}
// ============================================================================================
// TemplateUrlServiceObserver overrides.
// ============================================================================================
@Override
public void onTemplateURLServiceChanged() {
assert mTemplateUrlService != null;
boolean isDefaultSearchEngineGoogle = mTemplateUrlService.isDefaultSearchEngineGoogle();
if (mIsDefaultSearchEngineGoogle == null
|| isDefaultSearchEngineGoogle != mIsDefaultSearchEngineGoogle) {
mIsDefaultSearchEngineGoogle = isDefaultSearchEngineGoogle;
updateContextualSearchHooks(mWebContents);
}
}
// ============================================================================================
// NetworkChangeNotifier.ConnectionTypeObserver overrides.
// ============================================================================================
@Override
public void onConnectionTypeChanged(int connectionType) {
updateContextualSearchHooks(mWebContents);
}
// ============================================================================================
// Private helpers.
// ============================================================================================
/**
* Should be called whenever the Tab's WebContents may have changed. Removes hooks from the
* existing WebContents, if necessary, and then adds hooks for the new WebContents.
* @param tab The current tab.
*/
private void updateHooksForTab(Tab tab) {
WebContents currentWebContents = tab.getWebContents();
boolean webContentsChanged = currentWebContents != mWebContents;
if (webContentsChanged || mContextualSearchManager != getContextualSearchManager(tab)) {
mContextualSearchManager = getContextualSearchManager(tab);
if (webContentsChanged) {
// Ensure the hooks are cleared on the old web contents before proceeding. All of
// the objects associated with the web content need to be recreated in order for
// selection to continue working. See https://crbug.com/1076326 for more details.
removeContextualSearchHooks(mWebContents);
mSelectionClientManager =
currentWebContents != null
? new SelectionClientManager(currentWebContents)
: null;
}
mWebContents = currentWebContents;
updateContextualSearchHooks(mWebContents);
}
}
/**
* Updates the Contextual Search hooks, adding or removing them depending on whether it is
* currently active. If the current tab's {@link WebContents} may have changed, call {@link
* #updateHooksForTab(Tab)} instead.
*
* @param webContents The WebContents to attach the gesture state listener to.
*/
private void updateContextualSearchHooks(WebContents webContents) {
if (webContents == null) return;
removeContextualSearchHooks(webContents);
if (isContextualSearchActive(webContents)) addContextualSearchHooks(webContents);
}
/**
* Adds Contextual Search hooks for its client and listener to the given WebContents.
* @param webContents The WebContents to attach the gesture state listener to.
*/
private void addContextualSearchHooks(WebContents webContents) {
assert mTab.getWebContents() == null || mTab.getWebContents() == webContents;
ContextualSearchManager contextualSearchManager = getContextualSearchManager(mTab);
if (mGestureStateListener == null && contextualSearchManager != null) {
mGestureStateListener = contextualSearchManager.getGestureStateListener();
GestureListenerManager.fromWebContents(webContents).addListener(mGestureStateListener);
// If we needed to add our listener, we also need to add our selection client.
SelectionPopupController controller =
SelectionPopupController.fromWebContents(webContents);
controller.setSelectionClient(
mSelectionClientManager.addContextualSearchSelectionClient(
contextualSearchManager.getContextualSearchSelectionClient()));
ContextualSearchTabHelperJni.get()
.installUnhandledTapNotifierIfNeeded(
mNativeHelper, ContextualSearchTabHelper.this, webContents, mPxToDp);
}
}
/**
* Removes Contextual Search hooks for its client and listener from the given WebContents.
* @param webContents The WebContents to detach the gesture state listener from.
*/
private void removeContextualSearchHooks(@Nullable WebContents webContents) {
if (webContents == null) return;
if (mGestureStateListener != null) {
GestureListenerManager.fromWebContents(webContents)
.removeListener(mGestureStateListener);
mGestureStateListener = null;
// If we needed to remove our listener, we also need to remove our selection client.
if (mSelectionClientManager != null) {
SelectionPopupController controller =
SelectionPopupController.fromWebContents(webContents);
SelectionClient client =
mSelectionClientManager.removeContextualSearchSelectionClient();
if (isReadAloudTapToSeekEnabled()) {
if (controller.getSelectionClient()
== mSelectionClientManager.getSelectionClient()) {
controller.setSelectionClient(client);
}
} else {
controller.setSelectionClient(client);
}
}
// Also make sure the UI is hidden if the device is offline.
ContextualSearchManager contextualSearchManager = getContextualSearchManager(mTab);
if (contextualSearchManager != null && !isDeviceOnline(contextualSearchManager)) {
contextualSearchManager.hideContextualSearch(StateChangeReason.UNKNOWN);
}
}
}
/**
* @return whether Contextual Search is enabled and active in this tab.
*/
private boolean isContextualSearchActive(WebContents webContents) {
assert mTab.getWebContents() == null || mTab.getWebContents() == webContents;
// If the tab has an active ReadAloud playback, contextual search is disabled
if (isReadAloudTapToSeekEnabled()
&& mReadAloudActivePlaybackTab != null
&& mReadAloudActivePlaybackTab.get() == mTab
&& mTab != null) {
return false;
}
if (maybeObserveManagerCreation()) return false;
ContextualSearchManager manager = getContextualSearchManager(mTab);
Profile profile = Profile.fromWebContents(webContents);
boolean isDseGoogle =
TemplateUrlServiceFactory.getForProfile(profile).isDefaultSearchEngineGoogle();
boolean isActive =
!webContents.isIncognito()
&& FirstRunStatus.getFirstRunFlowComplete()
&& !ContextualSearchPolicy.isContextualSearchDisabled(profile)
&& isDseGoogle
&& !LocaleManager.getInstance().needToCheckForSearchEnginePromo()
// Svelte and Accessibility devices are incompatible with the first-run flow
// and Talkback has poor interaction with Contextual Search (see
// http://crbug.com/399708 and http://crbug.com/396934).
&& !manager.isRunningInCompatibilityMode()
&& !(mTab.isShowingErrorPage())
&& isDeviceOnline(manager);
if (mTab.isCustomTab() && !isActive) {
// TODO(donnd): remove after https://crbug.com/1192143 is resolved.
Log.w(TAG, "Not allowed to be active! Checking reasons:");
Log.w(
TAG,
"!isIncognito: "
+ !webContents.isIncognito()
+ " getFirstRunFlowComplete: "
+ FirstRunStatus.getFirstRunFlowComplete()
+ " !isContextualSearchDisabled: "
+ !ContextualSearchManager.isContextualSearchDisabled(profile)
+ " isDefaultSearchEngineGoogle: "
+ isDseGoogle
+ " !needToCheckForSearchEnginePromo: "
+ !LocaleManager.getInstance().needToCheckForSearchEnginePromo()
+ " !isRunningInCompatibilityMode: "
+ !manager.isRunningInCompatibilityMode()
+ " !isShowingErrorPage: "
+ !mTab.isShowingErrorPage()
+ " isDeviceOnline: "
+ isDeviceOnline(manager));
}
return isActive;
}
/**
* Observe {@link ContextualSearchManager} creation if not available.
*
* @return {@code True} if the observer is installed. {@code false} if manager already exists,
* thus no observer was installed.
*/
private boolean maybeObserveManagerCreation() {
ContextualSearchManager manager = getContextualSearchManager(mTab);
if (manager != null) return false;
if (mTab.isCustomTab()) Log.w(TAG, "No manager!");
ObservableSupplier<ContextualSearchManager> supplier =
getContextualSearchManagerSupplier(mTab);
if (supplier != null) {
supplier.addObserver(mManagerCallback);
}
return true;
}
/**
* @return Whether the device is online, or we have disabled online-detection.
*/
private boolean isDeviceOnline(ContextualSearchManager manager) {
return ChromeFeatureList.isEnabled(
ChromeFeatureList.CONTEXTUAL_SEARCH_DISABLE_ONLINE_DETECTION)
? true
: manager.isDeviceOnline();
}
/**
* @return Whether ReadAloud's tap to seek is enabled
*/
private static boolean isReadAloudTapToSeekEnabled() {
return ChromeFeatureList.sReadAloudTapToSeek.isEnabled();
}
/**
* Gets the {@link ContextualSearchManager} associated with the given tab's activity.
*
* @param tab The {@link Tab} that we're getting the manager for.
* @return The Contextual Search manager controlling that Tab.
*/
private ContextualSearchManager getContextualSearchManager(Tab tab) {
var supplier = getContextualSearchManagerSupplier(tab);
return supplier != null ? supplier.get() : null;
}
private ObservableSupplier<ContextualSearchManager> getContextualSearchManagerSupplier(
Tab tab) {
// Window may be null in tests.
WindowAndroid window = tab.getWindowAndroid();
return window != null ? ContextualSearchManagerSupplier.from(window) : null;
}
private static ObservableSupplier<ReadAloudController> getReadAloudControllerSupplier(Tab tab) {
// Window may be null in tests.
WindowAndroid window = tab.getWindowAndroid();
return window != null ? ReadAloudControllerSupplier.from(window) : null;
}
// ============================================================================================
// Native support.
// ============================================================================================
@CalledByNative
void onContextualSearchPrefChanged() {
updateContextualSearchHooks(mWebContents);
ContextualSearchManager manager = getContextualSearchManager(mTab);
if (manager != null) {
manager.onContextualSearchPrefChanged();
}
}
/**
* Notifies this helper to show the Unhandled Tap UI due to a tap at the given pixel
* coordinates.
*/
@CalledByNative
void onShowUnhandledTapUIIfNeeded(int x, int y) {
// Only notify the manager if we currently have a valid listener.
if (mGestureStateListener != null && getContextualSearchManager(mTab) != null) {
getContextualSearchManager(mTab).onShowUnhandledTapUIIfNeeded(x, y);
}
}
@NativeMethods
interface Natives {
long init(ContextualSearchTabHelper caller, @JniType("Profile*") Profile profile);
void installUnhandledTapNotifierIfNeeded(
long nativeContextualSearchTabHelper,
ContextualSearchTabHelper caller,
WebContents webContents,
float pxToDpScaleFactor);
void destroy(long nativeContextualSearchTabHelper, ContextualSearchTabHelper caller);
}
}