// 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.content.browser.selection;
import android.app.Activity;
import android.app.SearchManager;
import android.content.Context;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.graphics.Rect;
import android.graphics.drawable.Drawable;
import android.os.Build;
import android.os.Handler;
import android.provider.Browser;
import android.text.TextUtils;
import android.view.ActionMode;
import android.view.HapticFeedbackConstants;
import android.view.Menu;
import android.view.MenuItem;
import android.view.View;
import android.view.ViewConfiguration;
import android.view.ViewGroup;
import android.view.textclassifier.SelectionEvent;
import android.view.textclassifier.TextClassifier;
import androidx.annotation.IdRes;
import androidx.annotation.IntDef;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.Px;
import androidx.annotation.RequiresApi;
import androidx.annotation.VisibleForTesting;
import org.jni_zero.CalledByNative;
import org.jni_zero.JNINamespace;
import org.jni_zero.NativeMethods;
import org.chromium.base.Log;
import org.chromium.base.PackageManagerUtils;
import org.chromium.base.ResettersForTesting;
import org.chromium.base.UserData;
import org.chromium.base.metrics.RecordHistogram;
import org.chromium.base.metrics.RecordUserAction;
import org.chromium.base.supplier.ObservableSupplier;
import org.chromium.base.supplier.ObservableSupplierImpl;
import org.chromium.content.R;
import org.chromium.content.browser.GestureListenerManagerImpl;
import org.chromium.content.browser.PopupController;
import org.chromium.content.browser.PopupController.HideablePopup;
import org.chromium.content.browser.WindowEventObserver;
import org.chromium.content.browser.WindowEventObserverManager;
import org.chromium.content.browser.input.ImeAdapterImpl;
import org.chromium.content.browser.selection.SelectActionMenuHelper.SelectActionMenuDelegate;
import org.chromium.content.browser.selection.SelectActionMenuHelper.TextProcessingIntentHandler;
import org.chromium.content.browser.webcontents.WebContentsImpl;
import org.chromium.content.browser.webcontents.WebContentsImpl.UserDataFactory;
import org.chromium.content_public.browser.ActionModeCallback;
import org.chromium.content_public.browser.ActionModeCallbackHelper;
import org.chromium.content_public.browser.ContentFeatureList;
import org.chromium.content_public.browser.ContentFeatureMap;
import org.chromium.content_public.browser.ImeEventObserver;
import org.chromium.content_public.browser.RenderFrameHost;
import org.chromium.content_public.browser.SelectAroundCaretResult;
import org.chromium.content_public.browser.SelectionClient;
import org.chromium.content_public.browser.SelectionMenuGroup;
import org.chromium.content_public.browser.SelectionMenuItem;
import org.chromium.content_public.browser.SelectionPopupController;
import org.chromium.content_public.browser.WebContents;
import org.chromium.content_public.browser.selection.SelectionActionMenuDelegate;
import org.chromium.content_public.browser.selection.SelectionDropdownMenuDelegate;
import org.chromium.content_public.common.ContentFeatures;
import org.chromium.ui.base.Clipboard;
import org.chromium.ui.base.DeviceFormFactor;
import org.chromium.ui.base.MenuSourceType;
import org.chromium.ui.base.ViewAndroidDelegate;
import org.chromium.ui.base.ViewAndroidDelegate.ContainerViewObserver;
import org.chromium.ui.base.WindowAndroid;
import org.chromium.ui.modelutil.MVCListAdapter;
import org.chromium.ui.touch_selection.SelectionEventType;
import org.chromium.ui.touch_selection.TouchSelectionDraggableType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.SortedSet;
/** Implementation of the interface {@link SelectionPopupController}. */
@JNINamespace("content")
public class SelectionPopupControllerImpl extends ActionModeCallbackHelper
implements ImeEventObserver,
SelectionPopupController,
WindowEventObserver,
HideablePopup,
ContainerViewObserver,
UserData,
SelectActionMenuDelegate {
private static final String TAG = "SelectionPopupCtlr"; // 20 char limit
private static final boolean DEBUG = false;
/**
* Android Intent size limitations prevent sending over a megabyte of data. Limit
* query lengths to 100kB because other things may be added to the Intent.
*/
private static final int MAX_SHARE_QUERY_LENGTH = 100000;
// Default delay for reshowing the {@link ActionMode} after it has been
// hidden. This avoids flickering issues if there are trailing rect
// invalidations after the ActionMode is shown. For example, after the user
// stops dragging a selection handle, in turn showing the ActionMode, the
// selection change response will be asynchronous. 300ms should accomodate
// most such trailing, async delays.
private static final int SHOW_DELAY_MS = 300;
// A large value to force text processing menu items to be at the end of the
// context menu. Chosen to be bigger than the order of possible items in the
// XML template.
private static final int MENU_ITEM_ORDER_TEXT_PROCESS_START = 100;
// A flag to determine if we should get readback view from WindowAndroid.
// The readback view could be the ContainerView, which WindowAndroid has no control on that.
// Embedders should set this properly to use the correct view for readback.
private static boolean sShouldGetReadbackViewFromWindowAndroid;
// Allow using magnifer built using surface control instead of the system-proivded one.
private static boolean sAllowSurfaceControlMagnifier;
// A flag to determine if we must only use the context from the associated web contents
// to inflate menus. By default we use the context held by the ActionMode, because this
// enables correct theming, but in cases where we rely on the wrapping of contexts for
// correct resource lookup, this is not correct. In that case we must directly inflate
// menus from the context.
private static boolean sMustUseWebContentsContext;
private static boolean sDisableMagnifierForTesting;
// Used in tests to enable tablet UI mode.
private static boolean sEnableTabletUiModeForTesting;
private static final class UserDataFactoryLazyHolder {
private static final UserDataFactory<SelectionPopupControllerImpl> INSTANCE =
SelectionPopupControllerImpl::new;
}
@IntDef({SelectionMenuType.ACTION_MODE, SelectionMenuType.DROPDOWN})
@Retention(RetentionPolicy.SOURCE)
public @interface SelectionMenuType {
int ACTION_MODE = 0;
int DROPDOWN = 1;
}
private final Handler mHandler;
private Context mContext;
private WindowAndroid mWindowAndroid;
private WebContentsImpl mWebContents;
private ActionModeCallback mCallback;
private RenderFrameHost mRenderFrameHost;
private long mNativeSelectionPopupController;
private SelectionClient.ResultCallback mResultCallback;
// Selection rectangle in DIP.
private final Rect mSelectionRect = new Rect();
// Self-repeating task that repeatedly hides the ActionMode. This is
// required because ActionMode only exposes a temporary hide routine.
private Runnable mRepeatingHideRunnable;
// Can be null temporarily when switching between WindowAndroid.
@Nullable private View mView;
private ActionMode mActionMode;
// Supplier of whether action bar is showing now.
private final ObservableSupplierImpl<Boolean> mIsActionBarShowingSupplier =
new ObservableSupplierImpl<>();
// Bit field for mappings from menu item to a flag indicating it is allowed.
private int mAllowedMenuItems;
private boolean mHidden;
private boolean mEditable;
private boolean mIsPasswordType;
private boolean mIsInsertionForTesting;
private boolean mCanSelectAll;
private boolean mCanEditRichly;
@MenuSourceType private int mMenuSourceType;
// Click or touch down coordinates
private int mXDip;
private int mYDip;
private boolean mUnselectAllOnDismiss;
private String mLastSelectedText;
private int mLastSelectionOffset;
private boolean mIsInHandleDragging;
// Tracks whether a touch selection is currently active.
private boolean mHasSelection;
// If we are currently processing a Select All request from the menu. Used to
// dismiss the old menu so that it won't be preserved and redrawn at a new anchor.
private boolean mIsProcessingSelectAll;
private boolean mWasPastePopupShowingOnInsertionDragStart;
// Dropdown menu delegate that handles showing a dropdown style text selection menu.
// This must be set by the embedders that want to use this functionality.
@Nullable private SelectionDropdownMenuDelegate mDropdownMenuDelegate;
/**
* The {@link SelectionClient} that processes textual selection, or {@code null} if none
* exists.
*/
private SelectionClient mSelectionClient;
@Nullable private SmartSelectionEventProcessor mSmartSelectionEventProcessor;
private PopupController mPopupController;
// The classificaton result of the selected text if the selection exists and
// SelectionClient was able to classify it, otherwise null.
private SelectionClient.Result mClassificationResult;
private boolean mPreserveSelectionOnNextLossOfFocus;
// Delegate used by embedders to customize selection menu.
@Nullable private SelectionActionMenuDelegate mSelectionActionMenuDelegate;
private MagnifierAnimator mMagnifierAnimator;
// Cached selection menu items to check against new selections.
@Nullable private SelectionMenuCachedResult mSelectionMenuCachedResult;
/** Custom {@link android.view.View.OnClickListener} map for ActionMode menu items. */
private final Map<MenuItem, View.OnClickListener> mCustomActionMenuItemClickListeners;
/** An interface for getting {@link View} for readback. */
public interface ReadbackViewCallback {
/** Gets the {@link View} for readback. */
View getReadbackView();
}
/** Sets to use the readback view from {@link WindowAndroid}. */
public static void setShouldGetReadbackViewFromWindowAndroid() {
sShouldGetReadbackViewFromWindowAndroid = true;
}
public static void setAllowSurfaceControlMagnifier() {
sAllowSurfaceControlMagnifier = true;
}
public static void setMustUseWebContentsContext() {
sMustUseWebContentsContext = true;
}
/**
* Get {@link SelectionPopupController} object used for the give WebContents.
* {@link #create()} should precede any calls to this.
* @param webContents {@link WebContents} object.
* @return {@link SelectionPopupController} object. {@code null} if not available because
* {@link #create()} is not called yet.
*/
public static SelectionPopupControllerImpl fromWebContents(WebContents webContents) {
return ((WebContentsImpl) webContents)
.getOrSetUserData(
SelectionPopupControllerImpl.class, UserDataFactoryLazyHolder.INSTANCE);
}
/**
* Get {@link SelectionPopupController} object used for the given WebContents but does not
* create a new one.
* @param webContents {@link WebContents} object.
* @return {@link SelectionPopupController} object. {@code null} if not available.
*/
public static SelectionPopupControllerImpl fromWebContentsNoCreate(WebContents webContents) {
return ((WebContentsImpl) webContents)
.getOrSetUserData(SelectionPopupControllerImpl.class, null);
}
/**
* Create {@link SelectionPopupController} instance. Note that it will create an instance with
* no link to native side for testing only.
* @param webContents {@link WebContents} mocked for testing.
* @param popupController {@link PopupController} mocked for testing.
*/
public static SelectionPopupControllerImpl createForTesting(
WebContents webContents, PopupController popupController) {
return new SelectionPopupControllerImpl(webContents, popupController, false);
}
public static SelectionPopupControllerImpl createForTesting(WebContents webContents) {
return new SelectionPopupControllerImpl(webContents, null, false);
}
public static boolean isMagnifierWithSurfaceControlSupported() {
return Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU
&& sAllowSurfaceControlMagnifier
&& SelectionPopupControllerImplJni.get().isMagnifierWithSurfaceControlSupported();
}
public static void setDisableMagnifierForTesting(boolean disable) {
sDisableMagnifierForTesting = disable;
ResettersForTesting.register(() -> sDisableMagnifierForTesting = false);
}
public static void setEnableTabletUiModeForTesting(boolean disable) {
sEnableTabletUiModeForTesting = disable;
ResettersForTesting.register(() -> sEnableTabletUiModeForTesting = false);
}
/**
* Create {@link SelectionPopupControllerImpl} instance.
* @param webContents WebContents instance.
*/
public SelectionPopupControllerImpl(WebContents webContents) {
this(webContents, null, true);
setActionModeCallback(ActionModeCallbackHelper.EMPTY_CALLBACK);
}
private SelectionPopupControllerImpl(
WebContents webContents, PopupController popupController, boolean initializeNative) {
mHandler = new Handler();
mWebContents = (WebContentsImpl) webContents;
mPopupController = popupController;
mContext = mWebContents.getContext();
mWindowAndroid = mWebContents.getTopLevelNativeWindow();
ViewAndroidDelegate viewDelegate = mWebContents.getViewAndroidDelegate();
if (viewDelegate != null) {
mView = viewDelegate.getContainerView();
viewDelegate.addObserver(this);
}
// The menu items are allowed by default.
mAllowedMenuItems = MENU_ITEM_SHARE | MENU_ITEM_WEB_SEARCH | MENU_ITEM_PROCESS_TEXT;
mRepeatingHideRunnable =
new Runnable() {
@Override
public void run() {
assert mHidden;
final long hideDuration = getDefaultHideDuration();
// Ensure the next hide call occurs before the ActionMode reappears.
mHandler.postDelayed(mRepeatingHideRunnable, hideDuration - 1);
hideActionModeTemporarily(hideDuration);
}
};
WindowEventObserverManager manager = WindowEventObserverManager.from(mWebContents);
if (manager != null) {
manager.addObserver(this);
}
if (initializeNative) {
mNativeSelectionPopupController =
SelectionPopupControllerImplJni.get()
.init(SelectionPopupControllerImpl.this, mWebContents);
ImeAdapterImpl imeAdapter = ImeAdapterImpl.fromWebContents(mWebContents);
if (imeAdapter != null) imeAdapter.addEventObserver(this);
}
mResultCallback = new SmartSelectionCallback();
mLastSelectedText = "";
mCustomActionMenuItemClickListeners = new HashMap<>();
getPopupController().registerPopup(this);
}
private void reset() {
dropFocus();
mContext = null;
mWindowAndroid = null;
}
private void dropFocus() {
// Hide popups and clear selection.
destroyActionModeAndUnselect();
dismissTextHandles();
PopupController.hideAll(mWebContents);
// Clear the selection. The selection is cleared on destroying IME
// and also here since we may receive destroy first, for example
// when focus is lost in webview.
clearSelection();
}
public static String sanitizeQuery(String query, int maxLength) {
if (TextUtils.isEmpty(query) || query.length() < maxLength) return query;
Log.w(TAG, "Truncating oversized query (" + query.length() + ").");
return query.substring(0, maxLength) + "…";
}
// ViewAndroidDelegate.ContainerViewObserver
@Override
public void onUpdateContainerView(ViewGroup containerView) {
// Cleans up action mode before switching to a new container view.
if (isActionModeValid()) finishActionMode();
mUnselectAllOnDismiss = true;
if (containerView != null) containerView.setClickable(true);
mView = containerView;
mMagnifierAnimator = null;
}
// ImeEventObserver
@Override
public void onNodeAttributeUpdated(boolean editable, boolean password) {
updateSelectionState(editable, password);
}
@Override
public void setActionModeCallback(ActionModeCallback callback) {
mCallback = callback;
}
@Override
public void setSelectionActionMenuDelegate(@Nullable SelectionActionMenuDelegate delegate) {
mSelectionActionMenuDelegate = delegate;
}
@Override
public RenderFrameHost getRenderFrameHost() {
return mRenderFrameHost;
}
@Override
public SelectionClient.ResultCallback getResultCallback() {
return mResultCallback;
}
public SelectionClient.Result getClassificationResult() {
return mClassificationResult;
}
@Override
public SelectionClient getSelectionClient() {
return mSelectionClient;
}
@Nullable
public SelectionMenuCachedResult getSelectionMenuCachedResultForTesting() {
return mSelectionMenuCachedResult;
}
@Override
public boolean isActionModeValid() {
return mActionMode != null;
}
// True if action mode is initialized to a working (not a no-op) mode.
@VisibleForTesting
public boolean isActionModeSupported() {
return mCallback != EMPTY_CALLBACK;
}
@Override
public void setAllowedMenuItems(int allowedMenuItems) {
mAllowedMenuItems = allowedMenuItems;
}
@Override
public int getAllowedMenuItemIfAny(ActionMode mode, MenuItem item) {
if (!isActionModeValid()) return 0;
return getAllowedMenuItemIfAny(item.getGroupId(), item.getItemId());
}
@Override
public int getAllowedMenuItemIfAny(int groupId, int id) {
if (id == R.id.select_action_menu_share) {
return MENU_ITEM_SHARE;
} else if (id == R.id.select_action_menu_web_search) {
return MENU_ITEM_WEB_SEARCH;
} else if (groupId == R.id.select_action_menu_text_processing_items) {
return MENU_ITEM_PROCESS_TEXT;
}
return 0;
}
/** Returns true if the window is on tablet. Can be disabled for testing. */
private boolean isWindowOnTablet() {
if (sEnableTabletUiModeForTesting) {
return true;
}
return DeviceFormFactor.isWindowOnTablet(mWindowAndroid);
}
/**
* Returns true if a dropdown menu should be used based on the current state
* (i.e. mouse was used to invoke text selection menu).
*/
private boolean shouldUseDropdownMenu() {
if (!ContentFeatureMap.isEnabled(ContentFeatureList.MOUSE_AND_TRACKPAD_DROPDOWN_MENU)) {
return false;
}
return mView != null
&& mDropdownMenuDelegate != null
&& mMenuSourceType == MenuSourceType.MENU_SOURCE_MOUSE
&& isWindowOnTablet();
}
/** Returns the type of menu to show based on the current state (i.e. has selection). */
@VisibleForTesting
@SelectionMenuType
protected int getMenuType() {
if (shouldUseDropdownMenu()) {
return SelectionMenuType.DROPDOWN;
}
return SelectionMenuType.ACTION_MODE;
}
@VisibleForTesting
@CalledByNative
public void showSelectionMenu(
int xDip,
int yDip,
int left,
int top,
int right,
int bottom,
int handleHeight,
boolean isEditable,
boolean isPasswordType,
String selectionText,
int selectionStartOffset,
boolean canSelectAll,
boolean canRichlyEdit,
boolean shouldSuggest,
@MenuSourceType int sourceType,
RenderFrameHost renderFrameHost) {
RecordHistogram.recordEnumeratedHistogram(
"Android.ShowSelectionMenuSourceType",
sourceType,
MenuSourceType.MENU_SOURCE_TYPE_LAST + 1);
int offsetBottom = bottom;
offsetBottom += handleHeight;
mXDip = xDip;
mYDip = yDip;
mSelectionRect.set(left, top, right, offsetBottom);
mEditable = isEditable;
mLastSelectedText = selectionText;
mLastSelectionOffset = selectionStartOffset;
mCanSelectAll = canSelectAll;
setHasSelection(!selectionText.isEmpty());
mIsPasswordType = isPasswordType;
mCanEditRichly = canRichlyEdit;
mMenuSourceType = sourceType;
mUnselectAllOnDismiss = true;
if (hasSelection()) {
mRenderFrameHost = renderFrameHost;
if (mSmartSelectionEventProcessor != null) {
switch (sourceType) {
case MenuSourceType.MENU_SOURCE_ADJUST_SELECTION:
mSmartSelectionEventProcessor.onSelectionModified(
mLastSelectedText, mLastSelectionOffset, mClassificationResult);
break;
case MenuSourceType.MENU_SOURCE_ADJUST_SELECTION_RESET:
mSmartSelectionEventProcessor.onSelectionAction(
mLastSelectedText,
mLastSelectionOffset,
SelectionEvent.ACTION_RESET,
/* SelectionClient.Result = */ null);
break;
case MenuSourceType.MENU_SOURCE_TOUCH_HANDLE:
break;
default:
mSmartSelectionEventProcessor.onSelectionStarted(
mLastSelectedText, mLastSelectionOffset, isEditable);
}
}
// From selection adjustment, show menu directly.
// Note that this won't happen if it is incognito mode or device is not provisioned.
if (sourceType == MenuSourceType.MENU_SOURCE_ADJUST_SELECTION) {
showSelectionMenuInternal();
return;
}
// Show menu there is no updates from SelectionClient.
if (mSelectionClient == null
|| !mSelectionClient.requestSelectionPopupUpdates(shouldSuggest)) {
showSelectionMenuInternal();
}
} else {
showSelectionMenuInternal();
}
}
/** Shows the correct menu based on the current state (i.e. has selection). */
private void showSelectionMenuInternal() {
@SelectionMenuType final int menuType = getMenuType();
switch (menuType) {
case SelectionMenuType.ACTION_MODE:
showActionModeOrClearOnFailure();
break;
case SelectionMenuType.DROPDOWN:
createAndShowDropdownMenu();
break;
}
}
/**
* Show (activate) android action mode by starting it.
*
* <p>Action mode in floating mode is tried first, and then falls back to a normal one.
*
* <p>If the action mode cannot be created the selection is cleared.
*/
public void showActionModeOrClearOnFailure() {
if (!isActionModeSupported()
|| mView == null
|| getMenuType() != SelectionMenuType.ACTION_MODE) {
return;
}
// Just refresh non-floating action mode if it already exists to avoid blinking.
if (isActionModeValid() && !isFloatingActionMode()) {
// Try/catch necessary for framework bug, crbug.com/446717.
try {
mActionMode.invalidate();
} catch (NullPointerException e) {
Log.w(TAG, "Ignoring NPE from ActionMode.invalidate() as workaround for L", e);
}
hideActionMode(false);
return;
}
// Dismiss the dropdown menu if showing.
destroyDropdownMenu();
setTextHandlesHiddenForDropdownMenu(false);
// Reset overflow menu (see crbug.com/700929).
destroyActionModeAndKeepSelection();
assert mWebContents != null;
ActionMode actionMode = mView.startActionMode(mCallback, ActionMode.TYPE_FLOATING);
if (actionMode != null) {
// This is to work around an LGE email issue. See crbug.com/651706 for more details.
LGEmailActionModeWorkaroundImpl.runIfNecessary(mContext, actionMode);
}
setActionMode(actionMode);
mUnselectAllOnDismiss = true;
if (!isActionModeValid() && hasSelection()) clearSelection();
}
private void dismissTextHandles() {
if (mWebContents.getRenderWidgetHostView() != null) {
mWebContents.getRenderWidgetHostView().dismissTextHandles();
}
}
private void showContextMenuAtTouchHandle(int left, int bottom) {
if (mWebContents.getRenderWidgetHostView() != null) {
mWebContents.getRenderWidgetHostView().showContextMenuAtTouchHandle(left, bottom);
}
}
private SelectionDropdownMenuDelegate.ItemClickListener getDropdownItemClickListener(
SelectionDropdownMenuDelegate delegate) {
return item -> {
final int groupId = delegate.getGroupId(item);
final int id = delegate.getItemId(item);
logSelectionAction(groupId, id);
mCallback.onDropdownItemClicked(
groupId, id, delegate.getItemIntent(item), delegate.getClickListener(item));
};
}
private MVCListAdapter.ModelList getDropdownItems() {
MVCListAdapter.ModelList items = new MVCListAdapter.ModelList();
if (mDropdownMenuDelegate != null) {
SortedSet<SelectionMenuGroup> allItemGroups = getMenuItems();
int groupIndex = 0;
for (SelectionMenuGroup group : allItemGroups) {
// First determine if any item in the group contains an icon. Given
// there will always be a small amount of items in the menu it is
// okay to run this loop twice. This property will be used later on when
// rendering the items to determine title spacing.
boolean groupContainsIcon = false;
for (SelectionMenuItem item : group.items) {
groupContainsIcon = item.isEnabled && item.getIcon(mContext) != null;
// Exit early if there is an icon found.
if (groupContainsIcon) {
break;
}
}
// Add a divider above the new group.
final boolean addDivider = groupIndex > 0;
int itemIndexInGroup = 0;
// Populate the items from the group.
for (SelectionMenuItem item : group.items) {
if (!item.isEnabled) {
// We will only add items if they are enabled.
continue;
}
if (itemIndexInGroup++ == 0 && addDivider) {
items.add(mDropdownMenuDelegate.getDivider());
}
CharSequence title = item.getTitle(mContext);
CharSequence contentDescription = item.contentDescription;
items.add(
mDropdownMenuDelegate.getMenuItem(
title != null ? title.toString() : null,
contentDescription != null
? contentDescription.toString()
: null,
group.id,
item.id,
item.getIcon(mContext),
item.isIconTintable,
groupContainsIcon,
true,
item.clickListener,
item.intent));
}
groupIndex++;
}
}
return items;
}
@VisibleForTesting
protected void createAndShowDropdownMenu() {
assert mView != null;
assert mDropdownMenuDelegate != null;
if (getMenuType() != SelectionMenuType.DROPDOWN) {
return;
}
// Dismiss any action menu if showing.
destroyActionModeAndKeepSelection();
// Dismiss any previous menu if showing.
destroyDropdownMenu();
setTextHandlesHiddenForDropdownMenu(true);
// Convert coordinates to pixels and show the dropdown.
final float deviceScaleFactor = getDeviceScaleFactor();
@Px final int x = (int) (mXDip * deviceScaleFactor);
// The click down coordinates are relative to the content viewport, but we need
// coordinates relative to the containing View, therefore we need to add the content offset
// to the y value.
@Px
final int y =
((int)
((mYDip * deviceScaleFactor)
+ mWebContents.getRenderCoordinates().getContentOffsetYPix()));
MVCListAdapter.ModelList items = getDropdownItems();
SelectionDropdownMenuDelegate.ItemClickListener itemClickListener =
getDropdownItemClickListener(mDropdownMenuDelegate);
mDropdownMenuDelegate.show(mContext, mView, items, itemClickListener, x, y);
}
// HideablePopup implementation
@Override
public void hide() {
destroySelectActionMode();
}
private void destroyDropdownMenu() {
if (mDropdownMenuDelegate != null) {
mDropdownMenuDelegate.dismiss();
}
}
public boolean isPasteActionModeValid() {
return isActionModeValid() && !hasSelection();
}
// Composition methods for android.view.ActionMode
/**
* @see ActionMode#finish()
*/
@Override
public void finishActionMode() {
mHidden = false;
mHandler.removeCallbacks(mRepeatingHideRunnable);
if (isActionModeValid()) {
mActionMode.finish();
// Should be nulled out in case #onDestroyActionMode() is not invoked in response.
setActionMode(null);
}
}
@Override
public void dismissMenu() {
final int type = getMenuType();
switch (type) {
case SelectionMenuType.ACTION_MODE:
finishActionMode();
break;
case SelectionMenuType.DROPDOWN:
destroyDropdownMenu();
break;
}
}
/**
* @see ActionMode#invalidateContentRect()
*/
public void invalidateContentRect() {
if (isActionModeValid()) mActionMode.invalidateContentRect();
}
// WindowEventObserver
@Override
public void onWindowFocusChanged(boolean gainFocus) {
if (isActionModeValid()) {
mActionMode.onWindowFocusChanged(gainFocus);
}
}
@Override
public void onAttachedToWindow() {
updateTextSelectionUI(true);
}
@Override
public void onDetachedFromWindow() {
// WebView uses PopupWindows for handle rendering, which may remain
// unintentionally visible even after the WebView has been detached.
// Override the handle visibility explicitly to address this, but
// preserve the underlying selection for detachment cases like screen
// locking and app switching.
updateTextSelectionUI(false);
}
@Override
public void onWindowAndroidChanged(WindowAndroid newWindowAndroid) {
if (newWindowAndroid == null) {
reset();
return;
}
mWindowAndroid = newWindowAndroid;
mContext = mWebContents.getContext();
mMagnifierAnimator = null;
destroySelectActionMode();
}
@Override
public void onRotationChanged(int rotation) {
// ActionMode#invalidate() won't be able to re-layout the floating
// action mode menu items according to the new rotation. So Chrome
// has to re-create the action mode.
if (isActionModeValid()) {
hidePopupsAndPreserveSelection();
showActionModeOrClearOnFailure();
}
}
@Override
public void onViewFocusChanged(boolean gainFocus, boolean hideKeyboardOnBlur) {
if (gainFocus) {
restoreSelectionPopupsIfNecessary();
} else {
ImeAdapterImpl.fromWebContents(mWebContents)
.cancelRequestToScrollFocusedEditableNodeIntoView();
if (getPreserveSelectionOnNextLossOfFocus()) {
setPreserveSelectionOnNextLossOfFocus(false);
hidePopupsAndPreserveSelection();
} else {
dropFocus();
}
}
}
/**
* Update scroll status.
* @param scrollInProgress {@code true} if scroll is in progress.
*/
public void setScrollInProgress(boolean scrollInProgress) {
hideActionMode(scrollInProgress);
}
/**
* Hide or reveal the ActionMode. Note that this only has visible
* side-effects if the underlying ActionMode supports hiding.
* @param hide whether to hide or show the ActionMode.
*/
private void hideActionMode(boolean hide) {
if (!isFloatingActionMode()) return;
if (mHidden == hide) return;
mHidden = hide;
if (mHidden) {
mRepeatingHideRunnable.run();
} else {
mHandler.removeCallbacks(mRepeatingHideRunnable);
// To show the action mode that is being hidden call hide() again with a short delay.
hideActionModeTemporarily(SHOW_DELAY_MS);
}
}
/**
* @see ActionMode#hide(long)
*/
private void hideActionModeTemporarily(long duration) {
assert isFloatingActionMode();
if (isActionModeValid()) mActionMode.hide(duration);
}
private boolean isFloatingActionMode() {
return isActionModeValid() && mActionMode.getType() == ActionMode.TYPE_FLOATING;
}
private long getDefaultHideDuration() {
return ViewConfiguration.getDefaultActionModeHideDuration();
}
// Default handlers for action mode callbacks.
@Override
public void onCreateActionMode(ActionMode mode, Menu menu) {
mode.setTitle(
mWindowAndroid != null && DeviceFormFactor.isWindowOnTablet(mWindowAndroid)
? mContext.getString(R.string.actionbar_textselection_title)
: null);
mode.setSubtitle(null);
}
@Override
public boolean onPrepareActionMode(ActionMode mode, Menu menu) {
SortedSet<SelectionMenuGroup> menuItems = getMenuItems();
SelectActionMenuHelper.removeAllAddedGroupsFromMenu(menu);
mCustomActionMenuItemClickListeners.clear();
initializeActionMenu(
mContext,
menuItems,
menu,
mCustomActionMenuItemClickListeners,
item -> {
logSelectionAction(item.getGroupId(), item.getItemId());
return false;
});
return true;
}
@VisibleForTesting
public SortedSet<SelectionMenuGroup> getMenuItems() {
TextProcessingIntentHandler textProcessingIntentHandler =
isSelectActionModeAllowed(MENU_ITEM_PROCESS_TEXT) ? this::processText : null;
// If the menu items haven't been cached, process new menu and cache it.
if (mSelectionMenuCachedResult == null
|| !mSelectionMenuCachedResult.canReuseResult(
mClassificationResult,
isSelectionPassword(),
!isFocusedNodeEditable(),
getSelectedText())) {
mSelectionMenuCachedResult =
new SelectionMenuCachedResult(
mClassificationResult,
isSelectionPassword(),
!isFocusedNodeEditable(),
getSelectedText(),
SelectActionMenuHelper.getMenuItems(
this,
mContext,
mClassificationResult,
isSelectionPassword(),
!isFocusedNodeEditable(),
getSelectedText(),
textProcessingIntentHandler,
mSelectionActionMenuDelegate));
}
// Return the cached menu items for this selection.
return mSelectionMenuCachedResult.getResult();
}
/**
* Initializes the action menu.
*
* @param customMenuItemClickListeners map to populate any custom click listeners for menu
* items.
* @param additionalMenuItemClickListener executes after every menu item is clicked.
*/
public static void initializeActionMenu(
Context context,
SortedSet<SelectionMenuGroup> menuGroups,
Menu menu,
Map<MenuItem, View.OnClickListener> customMenuItemClickListeners,
@Nullable MenuItem.OnMenuItemClickListener additionalMenuItemClickListener) {
boolean isSelectionMenuOrderCorrectionEnabled =
ContentFeatureMap.isEnabled(ContentFeatures.SELECTION_MENU_ITEM_MODIFICATION);
for (SelectionMenuGroup group : menuGroups) {
addMenuItemsToActionMenu(
context,
group,
menu,
customMenuItemClickListeners,
additionalMenuItemClickListener,
isSelectionMenuOrderCorrectionEnabled);
}
}
/**
* Adds the menu items from the {@link SelectionMenuGroup} to the action menu.
*
* @param additionalMenuItemClickListener executes after every menu item is clicked.
*/
private static void addMenuItemsToActionMenu(
Context context,
SelectionMenuGroup group,
Menu menu,
Map<MenuItem, View.OnClickListener> customMenuItemClickListeners,
@Nullable MenuItem.OnMenuItemClickListener additionalMenuItemClickListener,
boolean isSelectionMenuOrderCorrectionEnabled) {
// All menu items and groups are sorted already at this point, so this is just passing
// 1-indexed value as order.
int menuItemCount = menu.size();
for (SelectionMenuItem item : group.items) {
if (!item.isEnabled) {
// We will only add items if they are enabled. This will prevent us from
// needing to remove them.
continue;
}
MenuItem menuItem =
menu.add(group.id, item.id, isSelectionMenuOrderCorrectionEnabled
? ++menuItemCount
: item.orderInCategory,
item.getTitle(context))
.setShowAsActionFlags(item.showAsActionFlags);
@Nullable Drawable icon = item.getIcon(context);
if (icon != null) {
menuItem.setIcon(icon);
}
@Nullable Character alphabeticShortcut = item.alphabeticShortcut;
if (alphabeticShortcut != null) {
menuItem.setAlphabeticShortcut(alphabeticShortcut);
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
// Content descriptions supported on O+.
@Nullable CharSequence contentDescription = item.contentDescription;
if (contentDescription != null) {
menuItem.setContentDescription(contentDescription);
}
}
if (item.clickListener != null) {
customMenuItemClickListeners.put(menuItem, item.clickListener);
}
menuItem.setOnMenuItemClickListener(
clickedMenuItem -> {
if (additionalMenuItemClickListener != null) {
additionalMenuItemClickListener.onMenuItemClick(clickedMenuItem);
}
return false;
});
menuItem.setIntent(item.intent);
}
}
/** Checks if copy action is available. */
@Override
public boolean canCopy() {
return hasSelection() && !isSelectionPassword() && Clipboard.getInstance().canCopy();
}
/** Checks if cut action is available. */
@Override
public boolean canCut() {
return hasSelection()
&& isFocusedNodeEditable()
&& !isSelectionPassword()
&& Clipboard.getInstance().canCopy();
}
/** Checks if paste action is available. */
@Override
public boolean canPaste() {
return isFocusedNodeEditable() && Clipboard.getInstance().canPaste();
}
/** Checks if share action is available. */
@Override
public boolean canShare() {
return hasSelection()
&& !isFocusedNodeEditable()
&& isSelectActionModeAllowed(MENU_ITEM_SHARE);
}
/** Checks if web search action is available. */
@Override
public boolean canWebSearch() {
return hasSelection()
&& !isFocusedNodeEditable()
&& !isIncognito()
&& isSelectActionModeAllowed(MENU_ITEM_WEB_SEARCH);
}
/**
* Check if there is a need to show "paste as plain text" option.
* "paste as plain text" option needs clipboard content is rich text, and editor supports rich
* text as well.
*/
@Override
public boolean canPasteAsPlainText() {
if (!canPaste()) return false;
// String resource "paste_as_plain_text" only exist in O+.
// Also this is an O feature, we need to make it consistent with TextView.
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return false;
if (!mCanEditRichly) return false;
// We need to show "paste as plain text" when Clipboard contains the HTML text. In addition
// to that, on Android, Spanned could be copied to Clipboard as plain_text MIME type, but in
// some cases, Spanned could have text format, we need to show "paste as plain text" when
// that happens as well.
return Clipboard.getInstance().hasHTMLOrStyledText();
}
/** Testing use only. Initialize the menu items for processing text, if there is any. */
/* package */ void initializeTextProcessingMenuForTesting(ActionMode mode, Menu menu) {
if (!isSelectActionModeAllowed(MENU_ITEM_PROCESS_TEXT)) {
return;
}
SelectionMenuGroup textProcessingItems =
SelectActionMenuHelper.getTextProcessingItems(
mContext, false, false, this::processText, mSelectionActionMenuDelegate);
if (!textProcessingItems.items.isEmpty()) {
boolean isSelectionMenuOrderCorrectionEnabled =
ContentFeatureMap.isEnabled(ContentFeatures.SELECTION_MENU_ITEM_MODIFICATION);
addMenuItemsToActionMenu(
mContext, textProcessingItems, menu, mCustomActionMenuItemClickListeners, null,
isSelectionMenuOrderCorrectionEnabled);
}
}
@Override
public boolean onActionItemClicked(ActionMode mode, MenuItem item) {
// Actions should only happen when there is a WindowAndroid so mView should not be null.
assert mView != null;
if (!isActionModeValid()) return true;
int itemId = item.getItemId();
// Check to see if this menu item has a custom click listener to handle it.
View.OnClickListener customMenuItemClickListener =
mCustomActionMenuItemClickListeners.get(item);
if (customMenuItemClickListener != null) {
customMenuItemClickListener.onClick(mView);
if (isPasteActionModeValid()) mode.finish();
} else {
handleMenuItemClick(itemId);
}
// We don't dismiss the action menu for select all action.
if (itemId != R.id.select_action_menu_select_all) {
mode.finish();
}
return true;
}
@Override
public boolean onDropdownItemClicked(
int groupId,
int id,
@Nullable Intent intent,
@Nullable View.OnClickListener clickListener) {
// Use the click listener for the item if it has one.
if (clickListener != null) {
clickListener.onClick(null);
} else {
handleMenuItemClick(id);
}
if (id != R.id.select_action_menu_select_all) {
// We will clear the selection for all actions other
// than select all.
clearSelection();
}
destroyDropdownMenu();
return true;
}
private void handleMenuItemClick(@IdRes final int id) {
if (id == R.id.select_action_menu_select_all) {
selectAll();
} else if (id == R.id.select_action_menu_cut) {
cut();
} else if (id == R.id.select_action_menu_copy) {
copy();
} else if (id == R.id.select_action_menu_paste) {
paste();
if (isPasteActionModeValid()) dismissTextHandles();
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
&& id == R.id.select_action_menu_paste_as_plain_text) {
pasteAsPlainText();
if (isPasteActionModeValid()) dismissTextHandles();
} else if (id == R.id.select_action_menu_share) {
share();
} else if (id == R.id.select_action_menu_web_search) {
search();
}
}
@Override
public void onDestroyActionMode() {
setActionMode(null);
if (mUnselectAllOnDismiss) {
clearSelection();
}
}
private void logSelectionAction(@IdRes int groupId, @IdRes int id) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) {
return;
}
if (hasSelection() && mSmartSelectionEventProcessor != null) {
mSmartSelectionEventProcessor.onSelectionAction(
mLastSelectedText,
mLastSelectionOffset,
getActionType(id, groupId),
mClassificationResult);
}
}
/**
* Called when an ActionMode needs to be positioned on screen, potentially occluding view
* content. Note this may be called on a per-frame basis.
*
* @param mode The ActionMode that requires positioning.
* @param view The View that originated the ActionMode, in whose coordinates the Rect should
* be provided.
* @param outRect The Rect to be populated with the content position.
*/
@Override
public void onGetContentRect(ActionMode mode, View view, Rect outRect) {
outRect.set(getSelectionRectRelativeToContainingView());
}
private Rect getSelectionRectRelativeToContainingView() {
float deviceScale = getDeviceScaleFactor();
Rect viewSelectionRect =
new Rect(
(int) (mSelectionRect.left * deviceScale),
(int) (mSelectionRect.top * deviceScale),
(int) (mSelectionRect.right * deviceScale),
(int) (mSelectionRect.bottom * deviceScale));
// The selection coordinates are relative to the content viewport, but we need
// coordinates relative to the containing View.
viewSelectionRect.offset(
0, (int) mWebContents.getRenderCoordinates().getContentOffsetYPix());
return viewSelectionRect;
}
private float getDeviceScaleFactor() {
return mWebContents.getRenderCoordinates().getDeviceScaleFactor();
}
private int getActionType(int menuItemId, int menuItemGroupId) {
if (menuItemGroupId == android.R.id.textAssist || menuItemId == android.R.id.textAssist) {
return SelectionEvent.ACTION_SMART_SHARE;
}
if (menuItemId == R.id.select_action_menu_select_all) {
return SelectionEvent.ACTION_SELECT_ALL;
}
if (menuItemId == R.id.select_action_menu_cut) {
return SelectionEvent.ACTION_CUT;
}
if (menuItemId == R.id.select_action_menu_copy) {
return SelectionEvent.ACTION_COPY;
}
if (menuItemId == R.id.select_action_menu_paste
|| menuItemId == R.id.select_action_menu_paste_as_plain_text) {
return SelectionEvent.ACTION_PASTE;
}
if (menuItemId == R.id.select_action_menu_share) {
return SelectionEvent.ACTION_SHARE;
}
return SelectionEvent.ACTION_OTHER;
}
/** Perform a select all action. */
@VisibleForTesting
public void selectAll() {
mIsProcessingSelectAll = true;
mWebContents.selectAll();
mClassificationResult = null;
// Even though the above statement logged a SelectAll user action, we want to
// track whether the focus was in an editable field, so log that too.
if (isFocusedNodeEditable()) {
RecordUserAction.record("MobileActionMode.SelectAllWasEditable");
} else {
RecordUserAction.record("MobileActionMode.SelectAllWasNonEditable");
}
}
/** Perform a cut (to clipboard) action. */
@VisibleForTesting
public void cut() {
mWebContents.cut();
}
/** Perform a copy (to clipboard) action. */
@VisibleForTesting
public void copy() {
mWebContents.copy();
}
/** Perform a paste action. */
@VisibleForTesting
public void paste() {
mWebContents.paste();
}
/** Perform a paste as plain text action. */
@VisibleForTesting
void pasteAsPlainText() {
mWebContents.pasteAsPlainText();
}
/** Perform a share action. */
@VisibleForTesting
public void share() {
RecordUserAction.record(UMA_MOBILE_ACTION_MODE_SHARE);
String query = sanitizeQuery(getSelectedText(), MAX_SHARE_QUERY_LENGTH);
if (TextUtils.isEmpty(query)) return;
Intent send = new Intent(Intent.ACTION_SEND);
send.setType("text/plain");
send.putExtra(Intent.EXTRA_TEXT, query);
try {
Intent i = Intent.createChooser(send, mContext.getString(R.string.actionbar_share));
i.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
mContext.startActivity(i);
} catch (android.content.ActivityNotFoundException ex) {
// If no app handles it, do nothing.
}
}
/** Perform a processText action (translating the text, for example). */
private void processText(Intent intent) {
RecordUserAction.record("MobileActionMode.ProcessTextIntent");
// Use MAX_SHARE_QUERY_LENGTH for the Intent 100k limitation.
String query = sanitizeQuery(getSelectedText(), MAX_SHARE_QUERY_LENGTH);
if (TextUtils.isEmpty(query)) return;
intent.putExtra(Intent.EXTRA_PROCESS_TEXT, query);
// Intent is sent by WindowAndroid by default.
try {
mWindowAndroid.showIntent(
intent,
new WindowAndroid.IntentCallback() {
@Override
public void onIntentCompleted(int resultCode, Intent data) {
onReceivedProcessTextResult(resultCode, data);
}
},
null);
} catch (android.content.ActivityNotFoundException ex) {
// If no app handles it, do nothing.
}
}
/** Perform a search action. */
@VisibleForTesting
public void search() {
RecordUserAction.record("MobileActionMode.WebSearch");
String query = sanitizeQuery(getSelectedText(), MAX_SEARCH_QUERY_LENGTH);
if (TextUtils.isEmpty(query)) return;
Intent i = new Intent(Intent.ACTION_WEB_SEARCH);
i.putExtra(SearchManager.EXTRA_NEW_SEARCH, true);
i.putExtra(SearchManager.QUERY, query);
i.putExtra(Browser.EXTRA_APPLICATION_ID, mContext.getPackageName());
i.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
try {
mContext.startActivity(i);
} catch (android.content.ActivityNotFoundException ex) {
// If no app handles it, do nothing.
}
}
/**
* @return true if the current selection is of password type.
*/
@VisibleForTesting
public boolean isSelectionPassword() {
return mIsPasswordType;
}
@Override
public boolean isFocusedNodeEditable() {
return mEditable;
}
/**
* @return true if the current selection is an insertion point.
*/
public boolean isInsertionForTesting() {
return mIsInsertionForTesting;
}
/**
* @return true if the current selection can select all.
*/
@Override
public boolean canSelectAll() {
return mCanSelectAll;
}
/**
* @return true if the current selection is for incognito content.
* Note: This should remain constant for the callback's lifetime.
*/
private boolean isIncognito() {
return mWebContents.isIncognito();
}
/**
* @param actionModeItem the flag for the action mode item in question. The valid flags are
* {@link #MENU_ITEM_SHARE}, {@link #MENU_ITEM_WEB_SEARCH}, and
* {@link #MENU_ITEM_PROCESS_TEXT}.
* @return true if the menu item action is allowed. Otherwise, the menu item
* should be removed from the menu.
*/
private boolean isSelectActionModeAllowed(int actionModeItem) {
boolean isAllowedByClient = (mAllowedMenuItems & actionModeItem) != 0;
if (actionModeItem == MENU_ITEM_SHARE) {
return isAllowedByClient && isShareAvailable();
}
return isAllowedByClient;
}
@Override
public void onReceivedProcessTextResult(int resultCode, Intent data) {
if (mWebContents == null || resultCode != Activity.RESULT_OK || data == null) return;
// Do not handle the result if no text is selected or current selection is not editable.
if (!hasSelection() || !isFocusedNodeEditable()) return;
CharSequence result = data.getCharSequenceExtra(Intent.EXTRA_PROCESS_TEXT);
if (result != null) {
// TODO(hush): Use a variant of replace that re-selects the replaced text.
// crbug.com/546710
mWebContents.replace(result.toString());
}
}
@Override
public void setPreserveSelectionOnNextLossOfFocus(boolean preserve) {
mPreserveSelectionOnNextLossOfFocus = preserve;
}
public boolean getPreserveSelectionOnNextLossOfFocus() {
return mPreserveSelectionOnNextLossOfFocus;
}
@Override
public void updateTextSelectionUI(boolean focused) {
setTextHandlesTemporarilyHidden(!focused);
if (focused) {
restoreSelectionPopupsIfNecessary();
} else {
destroyActionModeAndKeepSelection();
getPopupController().hideAllPopups();
}
}
@Override
public void setDropdownMenuDelegate(
@NonNull SelectionDropdownMenuDelegate dropdownMenuDelegate) {
mDropdownMenuDelegate = dropdownMenuDelegate;
}
private void setTextHandlesHiddenForDropdownMenu(boolean hide) {
if (mNativeSelectionPopupController == 0) return;
SelectionPopupControllerImplJni.get()
.setTextHandlesHiddenForDropdownMenu(
mNativeSelectionPopupController, SelectionPopupControllerImpl.this, hide);
}
private void setTextHandlesTemporarilyHidden(boolean hide) {
if (mNativeSelectionPopupController == 0) return;
SelectionPopupControllerImplJni.get()
.setTextHandlesTemporarilyHidden(
mNativeSelectionPopupController, SelectionPopupControllerImpl.this, hide);
}
@CalledByNative
public void restoreSelectionPopupsIfNecessary() {
if (hasSelection()
&& !isActionModeValid()
&& getMenuType() == SelectionMenuType.ACTION_MODE) {
showActionModeOrClearOnFailure();
}
}
@CalledByNative
private void childLocalSurfaceIdChanged() {
if (mMagnifierAnimator != null) {
mMagnifierAnimator.childLocalSurfaceIdChanged();
}
}
// All coordinates are in DIP.
@VisibleForTesting
@CalledByNative
void onSelectionEvent(
@SelectionEventType int eventType, int left, int top, int right, int bottom) {
if (DEBUG) {
Log.i(
TAG,
"onSelectionEvent: "
+ eventType
+ "[("
+ left
+ ", "
+ top
+ ")-("
+ right
+ ", "
+ bottom
+ ")]");
}
// Ensure the provided selection coordinates form a non-empty rect, as required by
// the selection action mode.
// NOTE: the native side ensures the rectangle is not empty, but that's done using floating
// point, which means it's entirely possible for this code to receive an empty rect.
if (left == right) ++right;
if (top == bottom) ++bottom;
switch (eventType) {
case SelectionEventType.SELECTION_HANDLES_SHOWN:
mSelectionRect.set(left, top, right, bottom);
break;
case SelectionEventType.SELECTION_HANDLES_MOVED:
mSelectionRect.set(left, top, right, bottom);
invalidateContentRect();
if (mIsInHandleDragging) {
performHapticFeedback();
}
break;
case SelectionEventType.SELECTION_HANDLES_CLEARED:
mLastSelectedText = "";
mLastSelectionOffset = 0;
setHasSelection(false);
mUnselectAllOnDismiss = false;
mSelectionRect.setEmpty();
if (mSelectionClient != null) mSelectionClient.cancelAllRequests();
mRenderFrameHost = null;
finishActionMode();
// reset system gesture exclusion rects
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
setSystemGestureExclusionRects(List.of(new Rect(0, 0, 0, 0)));
}
break;
case SelectionEventType.SELECTION_HANDLE_DRAG_STARTED:
hideActionMode(true);
mIsInHandleDragging = true;
break;
case SelectionEventType.SELECTION_HANDLE_DRAG_STOPPED:
showContextMenuAtTouchHandle(left, bottom);
if (getMagnifierAnimator() != null) {
getMagnifierAnimator().handleDragStopped();
}
mIsInHandleDragging = false;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
setSystemGestureExclusionRectsInternal();
}
break;
case SelectionEventType.INSERTION_HANDLE_SHOWN:
mSelectionRect.set(left, top, right, bottom);
mIsInsertionForTesting = true;
break;
case SelectionEventType.INSERTION_HANDLE_MOVED:
mSelectionRect.set(left, top, right, bottom);
if (!getGestureListenerManager().isScrollInProgress() && isPasteActionModeValid()) {
showActionModeOrClearOnFailure();
} else {
destroySelectActionMode();
}
if (mIsInHandleDragging) {
performHapticFeedback();
}
break;
case SelectionEventType.INSERTION_HANDLE_TAPPED:
if (mWasPastePopupShowingOnInsertionDragStart) {
destroySelectActionMode();
} else {
showContextMenuAtTouchHandle(mSelectionRect.left, mSelectionRect.bottom);
}
mWasPastePopupShowingOnInsertionDragStart = false;
break;
case SelectionEventType.INSERTION_HANDLE_CLEARED:
if (isPasteActionModeValid()) destroySelectActionMode();
mIsInsertionForTesting = false;
if (!hasSelection()) mSelectionRect.setEmpty();
break;
case SelectionEventType.INSERTION_HANDLE_DRAG_STARTED:
mWasPastePopupShowingOnInsertionDragStart = isPasteActionModeValid();
hidePopupsAndPreserveSelection();
mIsInHandleDragging = true;
break;
case SelectionEventType.INSERTION_HANDLE_DRAG_STOPPED:
if (mWasPastePopupShowingOnInsertionDragStart) {
showContextMenuAtTouchHandle(mSelectionRect.left, mSelectionRect.bottom);
}
mWasPastePopupShowingOnInsertionDragStart = false;
if (getMagnifierAnimator() != null) {
getMagnifierAnimator().handleDragStopped();
}
mIsInHandleDragging = false;
break;
default:
assert false : "Invalid selection event type.";
}
if (mSelectionClient != null) {
final float deviceScale = getDeviceScaleFactor();
final int xAnchorPix = (int) (mSelectionRect.left * deviceScale);
final int yAnchorPix = (int) (mSelectionRect.bottom * deviceScale);
mSelectionClient.onSelectionEvent(eventType, xAnchorPix, yAnchorPix);
}
}
@RequiresApi(Build.VERSION_CODES.Q)
private void setSystemGestureExclusionRectsInternal() {
Object[] handleRects = getTouchHandleRects();
if (handleRects == null) return;
Rect start = (Rect) handleRects[0];
Rect end = (Rect) handleRects[1];
float deviceScale = mWebContents.getRenderCoordinates().getDeviceScaleFactor();
Rect startHandleRect = getScaledRect(start, deviceScale);
startHandleRect.offset(0, (int) mWebContents.getRenderCoordinates().getContentOffsetYPix());
Rect endHandleRect = getScaledRect(end, deviceScale);
endHandleRect.offset(0, (int) mWebContents.getRenderCoordinates().getContentOffsetYPix());
List<Rect> rects = new ArrayList<>();
rects.add(startHandleRect);
rects.add(endHandleRect);
setSystemGestureExclusionRects(rects);
}
@RequiresApi(Build.VERSION_CODES.Q)
private void setSystemGestureExclusionRects(List<Rect> rects) {
if (mView != null) {
// This API is added in Android Q so that apps can opt out of the back gesture
// selectively by indicating to the system which regions need to receive touch
// input, as the new system gesture for back navigation can interfere with app
// elements in those areas.
mView.setSystemGestureExclusionRects(rects);
}
}
private Rect getScaledRect(Rect rect, float deviceScale) {
return new Rect(
(int) (rect.left * deviceScale),
(int) (rect.top * deviceScale),
(int) (rect.right * deviceScale),
(int) (rect.bottom * deviceScale));
}
@VisibleForTesting
/* package */ GestureListenerManagerImpl getGestureListenerManager() {
return GestureListenerManagerImpl.fromWebContents(mWebContents);
}
@VisibleForTesting
@CalledByNative
/* package */ void onDragUpdate(@TouchSelectionDraggableType int type, float x, float y) {
// If this is for longpress drag selector, we can only have mangifier on S and above.
if (type == TouchSelectionDraggableType.LONGPRESS
&& Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
return;
}
if (getMagnifierAnimator() != null) {
final float deviceScale = getDeviceScaleFactor();
x *= deviceScale;
// The selection coordinates are relative to the content viewport, but we need
// coordinates relative to the containing View, so adding getContentOffsetYPix().
y = y * deviceScale + mWebContents.getRenderCoordinates().getContentOffsetYPix();
getMagnifierAnimator().handleDragStartedOrMoved(x, y);
}
}
@Override
public void clearSelection() {
if (mWebContents == null || !isActionModeSupported()) return;
mWebContents.collapseSelection();
mClassificationResult = null;
setHasSelection(false);
}
private PopupController getPopupController() {
if (mPopupController == null) {
mPopupController = PopupController.fromWebContents(mWebContents);
}
return mPopupController;
}
@VisibleForTesting
/* package */ void performHapticFeedback() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && mView != null) {
mView.performHapticFeedback(HapticFeedbackConstants.TEXT_HANDLE_MOVE);
}
}
/**
* @return The context used for SelectionPopupController.
*/
@CalledByNative
private Context getContext() {
return mContext;
}
@VisibleForTesting
@CalledByNative
/* package */ void onSelectionChanged(String text) {
final boolean unSelected = TextUtils.isEmpty(text) && hasSelection();
if (unSelected || mIsProcessingSelectAll) {
if (mSmartSelectionEventProcessor != null) {
mSmartSelectionEventProcessor.onSelectionAction(
mLastSelectedText,
mLastSelectionOffset,
SelectionEvent.ACTION_ABANDON,
/* SelectionClient.Result = */ null);
}
destroyActionModeAndKeepSelection();
}
mLastSelectedText = text;
if (mSelectionClient != null) {
mSelectionClient.onSelectionChanged(text);
}
mIsProcessingSelectAll = false;
}
/**
* Sets the client that implements selection augmenting functionality, or null if none exists.
*/
@Override
public void setSelectionClient(@Nullable SelectionClient selectionClient) {
mSelectionClient = selectionClient;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
mSmartSelectionEventProcessor =
mSelectionClient == null
? null
: (SmartSelectionEventProcessor)
mSelectionClient.getSelectionEventProcessor();
} else {
mSmartSelectionEventProcessor = null;
}
mClassificationResult = null;
assert !mHidden;
}
/** Sets the handle observer, or null if none exists. */
@VisibleForTesting
void setMagnifierAnimator(@Nullable MagnifierAnimator magnifierAnimator) {
mMagnifierAnimator = magnifierAnimator;
}
private @Nullable MagnifierAnimator getMagnifierAnimator() {
if (mMagnifierAnimator != null) return mMagnifierAnimator;
if (sDisableMagnifierForTesting || Build.VERSION.SDK_INT < Build.VERSION_CODES.P) {
return null;
}
ReadbackViewCallback callback =
() -> {
if (sShouldGetReadbackViewFromWindowAndroid) {
return mWindowAndroid == null ? null : mWindowAndroid.getReadbackView();
} else {
return mView;
}
};
MagnifierWrapper magnifier;
if (isMagnifierWithSurfaceControlSupported()) {
magnifier = new MagnifierSurfaceControl(mWebContents, callback);
} else {
magnifier = new MagnifierWrapperImpl(callback);
}
mMagnifierAnimator = new MagnifierAnimator(magnifier);
return mMagnifierAnimator;
}
@CalledByNative
private void onSelectAroundCaretSuccess(
int extendedStartAdjust,
int extendedEndAdjust,
int wordStartAdjust,
int wordEndAdjust) {
if (mSelectionClient != null) {
SelectAroundCaretResult result =
new SelectAroundCaretResult(
extendedStartAdjust, extendedEndAdjust, wordStartAdjust, wordEndAdjust);
mSelectionClient.selectAroundCaretAck(result);
}
}
@CalledByNative
private void onSelectAroundCaretFailure() {
if (mSelectionClient != null) {
mSelectionClient.selectAroundCaretAck(null);
}
}
@CalledByNative
public void hidePopupsAndPreserveSelection() {
destroyActionModeAndKeepSelection();
getPopupController().hideAllPopups();
}
public void destroyActionModeAndUnselect() {
mUnselectAllOnDismiss = true;
finishActionMode();
}
public void destroyActionModeAndKeepSelection() {
mUnselectAllOnDismiss = false;
finishActionMode();
}
public void updateSelectionState(boolean editable, boolean isPassword) {
if (!editable && isPasteActionModeValid()) destroySelectActionMode();
if (editable != isFocusedNodeEditable() || isPassword != isSelectionPassword()) {
mEditable = editable;
mIsPasswordType = isPassword;
if (isActionModeValid()) mActionMode.invalidate();
}
}
private void setHasSelection(boolean hasSelection) {
mHasSelection = hasSelection;
mIsActionBarShowingSupplier.set(isSelectActionBarShowing());
}
@Override
public boolean hasSelection() {
return mHasSelection;
}
@Override
public String getSelectedText() {
return mLastSelectedText;
}
private void setActionMode(ActionMode actionMode) {
mActionMode = actionMode;
mIsActionBarShowingSupplier.set(isSelectActionBarShowing());
}
private boolean isShareAvailable() {
Intent intent = new Intent(Intent.ACTION_SEND);
intent.setType("text/plain");
return PackageManagerUtils.canResolveActivity(intent, PackageManager.MATCH_DEFAULT_ONLY);
}
// The callback class that delivers the result from a SmartSelectionClient.
private class SmartSelectionCallback implements SelectionClient.ResultCallback {
@Override
public void onClassified(SelectionClient.Result result) {
// If the selection does not exist any more, discard |result|.
if (!hasSelection()) {
mClassificationResult = null;
return;
}
// Do not allow classifier to shorten the selection. If the suggested selection is
// smaller than the original we throw away classification result and show the menu.
// TODO(amaralp): This was added to fix the SelectAll problem in
// http://crbug.com/714106. Once we know the cause of the original selection we can
// remove this check.
if (result.startAdjust > 0 || result.endAdjust < 0) {
mClassificationResult = null;
showSelectionMenuInternal();
return;
}
// The classificationresult is a property of the selection. Keep it even the action
// mode has been dismissed.
mClassificationResult = result;
// Update the selection range if needed.
if (!(result.startAdjust == 0 && result.endAdjust == 0)) {
// This call will cause showSelectionMenu again.
mWebContents.adjustSelectionByCharacterOffset(
result.startAdjust, result.endAdjust, /* showSelectionMenu= */ true);
return;
}
// We won't do expansion here, however, we want to 1) for starting a new logging
// session, log non selection expansion event to match the behavior of expansion case.
// 2) log selection handle dragging triggered selection change.
if (mSmartSelectionEventProcessor != null) {
mSmartSelectionEventProcessor.onSelectionModified(
mLastSelectedText, mLastSelectionOffset, mClassificationResult);
}
// Rely on this method to clear |mHidden| and unhide the action mode.
showSelectionMenuInternal();
}
}
;
@Override
public void destroySelectActionMode() {
finishActionMode();
}
@Override
public boolean isSelectActionBarShowing() {
return isActionModeValid() && hasSelection();
}
@Override
public ObservableSupplier<Boolean> isSelectActionBarShowingSupplier() {
return mIsActionBarShowingSupplier;
}
@Override
public ActionModeCallbackHelper getActionModeCallbackHelper() {
return this;
}
@Override
public void setTextClassifier(TextClassifier textClassifier) {
assert Build.VERSION.SDK_INT >= Build.VERSION_CODES.O;
SelectionClient client = getSelectionClient();
if (client != null) client.setTextClassifier(textClassifier);
}
@Override
public TextClassifier getTextClassifier() {
assert Build.VERSION.SDK_INT >= Build.VERSION_CODES.O;
SelectionClient client = getSelectionClient();
return client == null ? null : client.getTextClassifier();
}
@Override
public TextClassifier getCustomTextClassifier() {
assert Build.VERSION.SDK_INT >= Build.VERSION_CODES.O;
SelectionClient client = getSelectionClient();
return client == null ? null : client.getCustomTextClassifier();
}
@CalledByNative
private void nativeSelectionPopupControllerDestroyed() {
mNativeSelectionPopupController = 0;
}
@CalledByNative
private static Rect createJavaRect(int x, int y, int right, int bottom) {
return new Rect(x, y, right, bottom);
}
/**
* Gets the current touch handle rects.
*
* @return current touch handle rects object array.
*/
@VisibleForTesting
Object[] getTouchHandleRects() {
if (mNativeSelectionPopupController == 0) return null;
return SelectionPopupControllerImplJni.get()
.getTouchHandleRects(
mNativeSelectionPopupController, SelectionPopupControllerImpl.this);
}
@NativeMethods
interface Natives {
boolean isMagnifierWithSurfaceControlSupported();
long init(SelectionPopupControllerImpl caller, WebContents webContents);
void setTextHandlesTemporarilyHidden(
long nativeSelectionPopupController,
SelectionPopupControllerImpl caller,
boolean hidden);
void setTextHandlesHiddenForDropdownMenu(
long nativeSelectionPopupController,
SelectionPopupControllerImpl caller,
boolean hidden);
Object[] getTouchHandleRects(
long nativeSelectionPopupController, SelectionPopupControllerImpl caller);
}
}