// 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.contextualsearch; import android.os.Handler; import org.chromium.base.VisibleForTesting; import org.chromium.chrome.browser.ChromeActivity; import org.chromium.chrome.browser.compositor.bottombar.OverlayPanel; import org.chromium.chrome.browser.contextualsearch.ContextualSearchBlacklist.BlacklistReason; import org.chromium.chrome.browser.preferences.ChromePreferenceManager; import org.chromium.chrome.browser.tab.Tab; import org.chromium.content.browser.ContentViewCore; import org.chromium.content_public.browser.GestureStateListener; import org.chromium.ui.touch_selection.SelectionEventType; import java.util.regex.Matcher; import java.util.regex.Pattern; /** * Controls selection gesture interaction for Contextual Search. */ public class ContextualSearchSelectionController { /** * The type of selection made by the user. */ public enum SelectionType { UNDETERMINED, TAP, LONG_PRESS } // The number of milliseconds to wait for a selection change after a tap before considering // the tap invalid. This can't be too small or the subsequent taps may not have established // a new selection in time. This is because selectWordAroundCaret doesn't always select. // TODO(donnd): Fix in Blink, crbug.com/435778. private static final int INVALID_IF_NO_SELECTION_CHANGE_AFTER_TAP_MS = 50; // The default navigation-detection-delay in milliseconds. private static final int TAP_NAVIGATION_DETECTION_DELAY = 16; private static final String CONTAINS_WORD_PATTERN = "(\\w|\\p{L}|\\p{N})+"; // A URL is: // 1: scheme:// // 1+: any word char, _ or - // 1+: . followed by 1+ of any word char, _ or - // 0-1: 0+ of any word char or .,@?^=%&:/~#- followed by any word char or @?^-%&/~+#- // TODO(twellington): expand accepted schemes? private static final Pattern URL_PATTERN = Pattern.compile("((http|https|file|ftp|ssh)://)" + "([\\w_-]+(?:(?:\\.[\\w_-]+)+))([\\w.,@?^=%&:/~+#-]*[\\w@?^=%&/~+#-])?"); // Max selection length must be limited or the entire request URL can go past the 2K limit. private static final int MAX_SELECTION_LENGTH = 100; private final ChromeActivity mActivity; private final ContextualSearchSelectionHandler mHandler; private final Runnable mHandleInvalidTapRunnable; private final Handler mRunnableHandler; private final float mPxToDp; private final Pattern mContainsWordPattern; private String mSelectedText; private SelectionType mSelectionType; private boolean mWasTapGestureDetected; // Reflects whether the last tap was valid and whether we still have a tap-based selection. private ContextualSearchTapState mLastTapState; private TapSuppressionHeuristics mTapHeuristics; private boolean mIsWaitingForInvalidTapDetection; private boolean mIsSelectionEstablished; private boolean mShouldHandleSelectionModification; private boolean mDidExpandSelection; // Position of the selection. private float mX; private float mY; // The time of the most last scroll activity, or 0 if none. private long mLastScrollTimeNs; // Tracks whether a Context Menu has just been shown and the UX has been dismissed. // The selection may be unreliable until the next reset. See crbug.com/628436. private boolean mIsContextMenuShown; private class ContextualSearchGestureStateListener extends GestureStateListener { @Override public void onScrollStarted(int scrollOffsetY, int scrollExtentY) { mHandler.handleScroll(); } @Override public void onScrollEnded(int scrollOffsetY, int scrollExtentY) { mLastScrollTimeNs = System.nanoTime(); } @Override public void onScrollUpdateGestureConsumed() { // The onScrollEnded notification is unreliable, so mark time during scroll updates too. // See crbug.com/600863. mLastScrollTimeNs = System.nanoTime(); } // TODO(donnd): Remove this once we get notification of the selection changing // after a tap-select gets a subsequent tap nearby. Currently there's no // notification in this case. // See crbug.com/444114. @Override public void onSingleTap(boolean consumed) { // We may be notified that a tap has happened even when the system consumed the event. // This is being used to support tapping on an existing selection to show the selection // handles. We should process this tap unless we have already shown the selection // handles (have a long-press selection) and the tap was consumed. if (!(consumed && mSelectionType == SelectionType.LONG_PRESS)) { scheduleInvalidTapNotification(); } } } /** * Constructs a new Selection controller for the given activity. Callbacks will be issued * through the given selection handler. * @param activity The {@link ChromeActivity} to control. * @param handler The handler for callbacks. */ public ContextualSearchSelectionController(ChromeActivity activity, ContextualSearchSelectionHandler handler) { mActivity = activity; mHandler = handler; mPxToDp = 1.f / mActivity.getResources().getDisplayMetrics().density; mRunnableHandler = new Handler(); mHandleInvalidTapRunnable = new Runnable() { @Override public void run() { onInvalidTapDetectionTimeout(); } }; mContainsWordPattern = Pattern.compile(CONTAINS_WORD_PATTERN); } /** * Notifies that the base page has started loading a page. */ void onBasePageLoadStarted() { resetAllStates(); } /** * Notifies that a Context Menu has been shown. * Future controller events may be unreliable until the next reset. */ void onContextMenuShown() { // Hide the UX. mHandler.handleSelectionDismissal(); mIsContextMenuShown = true; } /** * Notifies that the Contextual Search has ended. * @param reason The reason for ending the Contextual Search. */ void onSearchEnded(OverlayPanel.StateChangeReason reason) { // If the user explicitly closes the panel after establishing a selection with long press, // it should not reappear until a new selection is made. This prevents the panel from // reappearing when a long press selection is modified after the user has taken action to // get rid of the panel. See crbug.com/489461. if (shouldPreventHandlingCurrentSelectionModification(reason)) { preventHandlingCurrentSelectionModification(); } // Long press selections should remain visible after ending a Contextual Search. if (mSelectionType == SelectionType.TAP) { clearSelection(); } } /** * Returns a new {@code GestureStateListener} that will listen for events in the Base Page. * This listener will handle all Contextual Search-related interactions that go through the * listener. */ public ContextualSearchGestureStateListener getGestureStateListener() { return new ContextualSearchGestureStateListener(); } /** * @return the {@link ChromeActivity}. */ ChromeActivity getActivity() { // TODO(donnd): don't expose the activity. return mActivity; } /** * @return the type of the selection. */ SelectionType getSelectionType() { return mSelectionType; } /** * @return the selected text. */ String getSelectedText() { return mSelectedText; } /** * @return The Pixel to Device independent Pixel ratio. */ float getPxToDp() { return mPxToDp; } /** * @return The time of the most recent scroll, or 0 if none. */ long getLastScrollTime() { return mLastScrollTimeNs; } /** * Clears the selection. */ void clearSelection() { ContentViewCore baseContentView = getBaseContentView(); if (baseContentView != null) { baseContentView.clearSelection(); } resetAllStates(); } /** * Handles a change in the current Selection. * @param selection The selection portion of the context. */ void handleSelectionChanged(String selection) { if (mDidExpandSelection) { mSelectedText = selection; mDidExpandSelection = false; return; } if (selection == null || selection.isEmpty()) { scheduleInvalidTapNotification(); // When the user taps on the page it will place the caret in that position, which // will trigger a onSelectionChanged event with an empty string. if (mSelectionType == SelectionType.TAP) { // Since we mostly ignore a selection that's empty, we only need to partially reset. resetSelectionStates(); return; } } if (!selection.isEmpty()) { unscheduleInvalidTapNotification(); } mSelectedText = selection; if (mWasTapGestureDetected) { mSelectionType = SelectionType.TAP; handleSelection(selection, mSelectionType); mWasTapGestureDetected = false; } else { boolean isValidSelection = validateSelectionSuppression(selection); mHandler.handleSelectionModification(selection, isValidSelection, mX, mY); } } /** * Handles a notification that a selection event took place. * @param eventType The type of event that took place. * @param posXPix The x coordinate of the selection start handle. * @param posYPix The y coordinate of the selection start handle. */ void handleSelectionEvent(int eventType, float posXPix, float posYPix) { boolean shouldHandleSelection = false; switch (eventType) { case SelectionEventType.SELECTION_HANDLES_SHOWN: if (!mIsContextMenuShown) { mWasTapGestureDetected = false; mSelectionType = SelectionType.LONG_PRESS; shouldHandleSelection = true; // Since we're showing pins, we don't care if the previous tap was invalid // anymore. unscheduleInvalidTapNotification(); } break; case SelectionEventType.SELECTION_HANDLES_CLEARED: mHandler.handleSelectionDismissal(); resetAllStates(); break; case SelectionEventType.SELECTION_HANDLE_DRAG_STOPPED: shouldHandleSelection = mShouldHandleSelectionModification; break; case SelectionEventType.SELECTION_ESTABLISHED: mIsSelectionEstablished = true; break; case SelectionEventType.SELECTION_DISSOLVED: mIsSelectionEstablished = false; break; default: } if (shouldHandleSelection) { ContentViewCore baseContentView = getBaseContentView(); if (baseContentView != null) { String selection = baseContentView.getSelectedText(); if (selection != null) { mX = posXPix; mY = posYPix; mSelectedText = selection; handleSelection(selection, SelectionType.LONG_PRESS); } } } } /** * Re-enables selection modification handling and invokes * ContextualSearchSelectionHandler.handleSelection(). * @param selection The text that was selected. * @param type The type of selection made by the user. */ private void handleSelection(String selection, SelectionType type) { mShouldHandleSelectionModification = true; boolean isValidSelection = validateSelectionSuppression(selection); mHandler.handleSelection(selection, isValidSelection, type, mX, mY); } /** * Resets all internal state of this class, including the tap state. */ private void resetAllStates() { resetSelectionStates(); mLastTapState = null; mLastScrollTimeNs = 0; mIsContextMenuShown = false; } /** * Resets all of the internal state of this class that handles the selection. */ private void resetSelectionStates() { mSelectionType = SelectionType.UNDETERMINED; mSelectedText = null; mWasTapGestureDetected = false; } /** * Should be called when a new Tab is selected. * Resets all of the internal state of this class. */ void onTabSelected() { resetAllStates(); } /** * Handles an unhandled tap gesture. */ void handleShowUnhandledTapUIIfNeeded(int x, int y) { mWasTapGestureDetected = false; // TODO(donnd): shouldn't we check == TAP here instead of LONG_PRESS? // TODO(donnd): refactor to avoid needing a new handler API method as suggested by Pedro. if (mSelectionType != SelectionType.LONG_PRESS) { mWasTapGestureDetected = true; long tapTimeNanoseconds = System.nanoTime(); // TODO(donnd): add a policy method to get adjusted tap count. ChromePreferenceManager prefs = ChromePreferenceManager.getInstance(mActivity); int adjustedTapsSinceOpen = prefs.getContextualSearchTapCount() - prefs.getContextualSearchTapQuickAnswerCount(); // Explicitly destroy the old heuristics so native code can dispose data. if (mTapHeuristics != null) mTapHeuristics.destroy(); mTapHeuristics = new TapSuppressionHeuristics(this, mLastTapState, x, y, adjustedTapsSinceOpen); // TODO(donnd): Move to be called when the panel closes to work with states that change. mTapHeuristics.logConditionState(); // Tell the manager what it needs in order to log metrics on whether the tap would have // been suppressed if each of the heuristics were satisfied. mHandler.handleMetricsForWouldSuppressTap(mTapHeuristics); mX = x; mY = y; boolean shouldSuppressTap = mTapHeuristics.shouldSuppressTap(); if (shouldSuppressTap) { mHandler.handleSuppressedTap(); } else { // TODO(donnd): Find a better way to determine that a navigation will be triggered // by the tap, or merge with other time-consuming actions like gathering surrounding // text or detecting page mutations. new Handler().postDelayed(new Runnable() { @Override public void run() { mHandler.handleValidTap(); } }, TAP_NAVIGATION_DETECTION_DELAY); } // Remember the tap state for subsequent tap evaluation. mLastTapState = new ContextualSearchTapState(x, y, tapTimeNanoseconds, shouldSuppressTap); } else { // Long press; reset last tap state. mLastTapState = null; mHandler.handleInvalidTap(); } } /** * @return The Base Page's {@link ContentViewCore}, or {@code null} if there is no current tab. */ ContentViewCore getBaseContentView() { Tab currentTab = mActivity.getActivityTab(); return currentTab != null ? currentTab.getContentViewCore() : null; } /** * Expands the current selection by the specified amounts. * @param selectionStartAdjust The start offset adjustment of the selection to use to highlight * the search term. * @param selectionEndAdjust The end offset adjustment of the selection to use to highlight * the search term. */ void adjustSelection(int selectionStartAdjust, int selectionEndAdjust) { // TODO(donnd): add code to verify that the selection is still valid before changing it. // crbug.com/508354 if (selectionStartAdjust == 0 && selectionEndAdjust == 0) return; ContentViewCore basePageContentView = getBaseContentView(); if (basePageContentView != null && basePageContentView.getWebContents() != null) { mDidExpandSelection = true; basePageContentView.getWebContents().adjustSelectionByCharacterOffset( selectionStartAdjust, selectionEndAdjust); } } // ============================================================================================ // Invalid Tap Notification // ============================================================================================ /** * Schedules a notification to check if the tap was invalid. * When we call selectWordAroundCaret it selects nothing in cases where the tap was invalid. * We have no way to know other than scheduling a notification to check later. * This allows us to hide the bar when there's no selection. */ private void scheduleInvalidTapNotification() { // TODO(donnd): Fix selectWordAroundCaret to we can tell if it selects, instead // of using a timer here! See crbug.com/435778. mRunnableHandler.postDelayed(mHandleInvalidTapRunnable, INVALID_IF_NO_SELECTION_CHANGE_AFTER_TAP_MS); } /** * Un-schedules all pending notifications to check if a tap was invalid. */ private void unscheduleInvalidTapNotification() { mRunnableHandler.removeCallbacks(mHandleInvalidTapRunnable); mIsWaitingForInvalidTapDetection = true; } /** * Notify's the system that tap gesture has been completed. */ private void onInvalidTapDetectionTimeout() { mHandler.handleInvalidTap(); mIsWaitingForInvalidTapDetection = false; } // ============================================================================================ // Selection Modification // ============================================================================================ /** * This method checks whether the selection modification should be handled. This method * is needed to allow modifying selections that are occluded by the Panel. * See crbug.com/489461. * * @param reason The reason the panel is closing. * @return Whether the selection modification should be handled. */ private boolean shouldPreventHandlingCurrentSelectionModification( OverlayPanel.StateChangeReason reason) { return getSelectionType() == SelectionType.LONG_PRESS && (reason == OverlayPanel.StateChangeReason.BACK_PRESS || reason == OverlayPanel.StateChangeReason.BASE_PAGE_SCROLL || reason == OverlayPanel.StateChangeReason.SWIPE || reason == OverlayPanel.StateChangeReason.FLING || reason == OverlayPanel.StateChangeReason.CLOSE_BUTTON); } /** * Temporarily prevents the controller from handling selection modification events on the * current selection. Handling will be re-enabled when a new selection is made through either a * tap or long press. */ private void preventHandlingCurrentSelectionModification() { mShouldHandleSelectionModification = false; } // ============================================================================================ // Misc. // ============================================================================================ /** * @return whether a tap gesture has been detected, for testing. */ @VisibleForTesting boolean wasAnyTapGestureDetected() { return mIsWaitingForInvalidTapDetection; } /** * @return whether the selection has been established, for testing. */ @VisibleForTesting boolean isSelectionEstablished() { return mIsSelectionEstablished; } /** * Evaluates whether the given selection is valid and notifies the handler about potential * selection suppression. * TODO(pedrosimonetti): substitute this once the system supports suppressing selections. * @param selection The given selection. * @return Whether the selection is valid. */ private boolean validateSelectionSuppression(String selection) { boolean isValid = isValidSelection(selection); if (mSelectionType == SelectionType.TAP) { BlacklistReason reason = ContextualSearchBlacklist.findReasonToSuppressSelection(selection); mHandler.handleSelectionSuppression(reason); // Only really suppress if enabled by field trial. Currently we can't prevent a // selection from being issued, so we end up clearing the selection immediately // afterwards, which does not look great. // TODO(pedrosimonetti): actually suppress selection once the system supports it. if (ContextualSearchFieldTrial.isBlacklistEnabled() && reason != BlacklistReason.NONE) { isValid = false; } } return isValid; } /** Determines if the given selection is valid or not. * @param selection The selection portion of the context. * @return whether the given selection is considered a valid target for a search. */ private boolean isValidSelection(String selection) { return isValidSelection(selection, getBaseContentView()); } @VisibleForTesting boolean isValidSelection(String selection, ContentViewCore baseContentView) { if (selection.length() > MAX_SELECTION_LENGTH) { return false; } if (!doesContainAWord(selection)) { return false; } if (baseContentView != null && baseContentView.isFocusedNodeEditable()) { return false; } return true; } /** * Determines if the given selection contains a word or not. * @param selection The the selection to check for a word. * @return Whether the selection contains a word anywhere within it or not. */ @VisibleForTesting public boolean doesContainAWord(String selection) { return mContainsWordPattern.matcher(selection).find(); } /** * @param selectionContext The String including the surrounding text and the selection. * @param startOffset The offset to the start of the selection (inclusive). * @param endOffset The offset to the end of the selection (non-inclusive). * @return Whether the selection is part of URL. A valid URL is: * 0-1: schema:// * 1+: any word char, _ or - * 1+: . followed by 1+ of any word char, _ or - * 0-1: 0+ of any word char or .,@?^=%&:/~#- followed by any word char or @?^-%&/~+#- */ public static boolean isSelectionPartOfUrl(String selectionContext, int startOffset, int endOffset) { Matcher matcher = URL_PATTERN.matcher(selectionContext); // Starts are inclusive and ends are non-inclusive for both GSAContext & matcher. while (matcher.find()) { if (startOffset >= matcher.start() && endOffset <= matcher.end()) { return true; } } return false; } }