// 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.widget.findinpage; import android.animation.Animator; import android.annotation.SuppressLint; import android.content.ClipData; import android.content.ClipboardManager; import android.content.Context; import android.graphics.Rect; import android.os.Handler; import android.os.Vibrator; import android.provider.Settings; import android.support.v4.view.accessibility.AccessibilityEventCompat; import android.text.Editable; import android.text.InputType; import android.text.Selection; import android.text.TextWatcher; import android.util.AttributeSet; import android.view.ActionMode; import android.view.Gravity; import android.view.KeyEvent; import android.view.View; import android.view.View.OnKeyListener; import android.widget.LinearLayout; import android.widget.TextView; import org.chromium.base.ApiCompatibilityUtils; import org.chromium.base.VisibleForTesting; import org.chromium.chrome.R; import org.chromium.chrome.browser.findinpage.FindInPageBridge; import org.chromium.chrome.browser.findinpage.FindMatchRectsDetails; import org.chromium.chrome.browser.findinpage.FindNotificationDetails; import org.chromium.chrome.browser.tab.EmptyTabObserver; import org.chromium.chrome.browser.tab.Tab; import org.chromium.chrome.browser.tab.TabObserver; import org.chromium.chrome.browser.tab.TabWebContentsDelegateAndroid; import org.chromium.chrome.browser.tabmodel.EmptyTabModelObserver; import org.chromium.chrome.browser.tabmodel.EmptyTabModelSelectorObserver; import org.chromium.chrome.browser.tabmodel.TabModel; import org.chromium.chrome.browser.tabmodel.TabModel.TabSelectionType; import org.chromium.chrome.browser.tabmodel.TabModelObserver; import org.chromium.chrome.browser.tabmodel.TabModelSelector; import org.chromium.chrome.browser.tabmodel.TabModelSelectorObserver; import org.chromium.chrome.browser.widget.TintedImageButton; import org.chromium.chrome.browser.widget.VerticallyFixedEditText; import org.chromium.ui.UiUtils; import org.chromium.ui.base.WindowAndroid; /** A toolbar providing find in page functionality. */ public class FindToolbar extends LinearLayout implements TabWebContentsDelegateAndroid.FindResultListener, TabWebContentsDelegateAndroid.FindMatchRectsListener { private static final long ACCESSIBLE_ANNOUNCEMENT_DELAY_MILLIS = 500; // Toolbar UI private TextView mFindStatus; protected FindQuery mFindQuery; protected TintedImageButton mCloseFindButton; protected TintedImageButton mFindPrevButton; protected TintedImageButton mFindNextButton; private FindResultBar mResultBar = null; private TabModelSelector mTabModelSelector; private final TabModelSelectorObserver mTabModelSelectorObserver; private final TabModelObserver mTabModelObserver; private Tab mCurrentTab; private final TabObserver mTabObserver; private WindowAndroid mWindowAndroid; private FindInPageBridge mFindInPageBridge; private FindToolbarObserver mObserver; /** Most recently entered search text (globally, in non-incognito tabs). */ private String mLastUserSearch = ""; /** Whether toolbar text is being set automatically (not typed by user). */ private boolean mSettingFindTextProgrammatically = false; /** Whether the search key should trigger a new search. */ private boolean mSearchKeyShouldTriggerSearch = false; private boolean mActive = false; private Handler mHandler = new Handler(); private Runnable mAccessibleAnnouncementRunnable; private boolean mAccessibilityDidActivateResult; /** Subclasses EditText in order to intercept BACK key presses. */ @SuppressLint("Instantiatable") static class FindQuery extends VerticallyFixedEditText implements OnKeyListener { private FindToolbar mFindToolbar; public FindQuery(Context context, AttributeSet attrs) { super(context, attrs); setOnKeyListener(this); } void setFindToolbar(FindToolbar findToolbar) { mFindToolbar = findToolbar; } @Override public boolean onKey(View v, int keyCode, KeyEvent event) { if (keyCode == KeyEvent.KEYCODE_BACK) { if (event.getAction() == KeyEvent.ACTION_DOWN && event.getRepeatCount() == 0) { // Tell the framework to start tracking this event. getKeyDispatcherState().startTracking(event, this); return true; } else if (event.getAction() == KeyEvent.ACTION_UP) { getKeyDispatcherState().handleUpEvent(event); if (event.isTracking() && !event.isCanceled()) { mFindToolbar.deactivate(); return true; } } } return false; } @Override public boolean onKeyDown(int keyCode, KeyEvent event) { if (keyCode == KeyEvent.KEYCODE_ENTER || keyCode == KeyEvent.KEYCODE_F3 || (keyCode == KeyEvent.KEYCODE_G && event.isCtrlPressed())) { mFindToolbar.hideKeyboardAndStartFinding(!event.isShiftPressed()); return true; } return super.onKeyDown(keyCode, event); } @Override public boolean onTextContextMenuItem(int id) { if (id == android.R.id.paste) { ClipboardManager clipboard = (ClipboardManager) getContext() .getSystemService(Context.CLIPBOARD_SERVICE); ClipData clipData = clipboard.getPrimaryClip(); if (clipData != null) { // Convert the clip data to a simple string StringBuilder builder = new StringBuilder(); for (int i = 0; i < clipData.getItemCount(); i++) { builder.append(clipData.getItemAt(i).coerceToText(getContext())); } // Identify how much of the original text should be replaced int min = 0; int max = getText().length(); if (isFocused()) { final int selStart = getSelectionStart(); final int selEnd = getSelectionEnd(); min = Math.max(0, Math.min(selStart, selEnd)); max = Math.max(0, Math.max(selStart, selEnd)); } Selection.setSelection(getText(), max); getText().replace(min, max, builder.toString()); return true; } } return super.onTextContextMenuItem(id); } } public FindToolbar(Context context, AttributeSet attrs) { super(context, attrs); mTabObserver = new EmptyTabObserver() { @Override public void onPageLoadStarted(Tab tab, String url) { deactivate(); } @Override public void onContentChanged(Tab tab) { deactivate(); } @Override public void onClosingStateChanged(Tab tab, boolean closing) { if (closing) deactivate(); } }; mTabModelSelectorObserver = new EmptyTabModelSelectorObserver() { @Override public void onTabModelSelected(TabModel newModel, TabModel oldModel) { deactivate(); updateVisualsForTabModel(newModel.isIncognito()); } }; mTabModelObserver = new EmptyTabModelObserver() { @Override public void didSelectTab(Tab tab, TabSelectionType type, int lastId) { deactivate(); } @Override public void tabRemoved(Tab tab) { if (tab != mCurrentTab) return; deactivate(); } }; } @Override public void onFinishInflate() { super.onFinishInflate(); setOrientation(HORIZONTAL); setGravity(Gravity.CENTER_VERTICAL); mFindQuery = (FindQuery) findViewById(R.id.find_query); mFindQuery.setFindToolbar(this); mFindQuery.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_FILTER); mFindQuery.setSelectAllOnFocus(true); mFindQuery.setOnFocusChangeListener(new View.OnFocusChangeListener() { @Override public void onFocusChange(View v, boolean hasFocus) { mAccessibilityDidActivateResult = false; if (!hasFocus) { if (mFindQuery.getText().length() > 0) { mSearchKeyShouldTriggerSearch = true; } UiUtils.hideKeyboard(mFindQuery); } } }); mFindQuery.addTextChangedListener(new TextWatcher() { @Override public void onTextChanged(CharSequence s, int start, int before, int count) { if (mFindInPageBridge == null) return; mAccessibilityDidActivateResult = false; setPrevNextEnabled(s.length() > 0); if (mSettingFindTextProgrammatically) return; // If we're called during onRestoreInstanceState() the current // view won't have been set yet. TODO(husky): Find a better fix. assert mCurrentTab != null; assert mCurrentTab.getContentViewCore() != null; if (mCurrentTab.getContentViewCore() == null) return; if (s.length() > 0) { // Don't clearResults() as that would cause flicker. // Just wait until onFindResultReceived updates it. mSearchKeyShouldTriggerSearch = false; mFindInPageBridge.startFinding(s.toString(), true, false); } else { clearResults(); mFindInPageBridge.stopFinding(true); } if (!mCurrentTab.isIncognito()) { mLastUserSearch = s.toString(); } } @Override public void beforeTextChanged(CharSequence s, int start, int count, int after) { } @Override public void afterTextChanged(Editable s) { } }); mFindQuery.setOnEditorActionListener(new TextView.OnEditorActionListener() { @Override public boolean onEditorAction(TextView v, int actionId, KeyEvent event) { if (event != null && event.getAction() == KeyEvent.ACTION_UP) return false; if (mFindInPageBridge == null) return false; // Only trigger a new find if the text was set programmatically. // Otherwise just revisit the current active match. if (mSearchKeyShouldTriggerSearch) { mSearchKeyShouldTriggerSearch = false; hideKeyboardAndStartFinding(true); } else { UiUtils.hideKeyboard(mFindQuery); mFindInPageBridge.activateFindInPageResultForAccessibility(); mAccessibilityDidActivateResult = true; } return true; } }); mFindStatus = (TextView) findViewById(R.id.find_status); mFindPrevButton = (TintedImageButton) findViewById(R.id.find_prev_button); mFindPrevButton.setOnClickListener(new OnClickListener() { @Override public void onClick(View v) { hideKeyboardAndStartFinding(false); } }); mFindNextButton = (TintedImageButton) findViewById(R.id.find_next_button); mFindNextButton.setOnClickListener(new OnClickListener() { @Override public void onClick(View v) { hideKeyboardAndStartFinding(true); } }); setPrevNextEnabled(false); mCloseFindButton = (TintedImageButton) findViewById(R.id.close_find_button); mCloseFindButton.setOnClickListener(new OnClickListener() { @Override public void onClick(View v) { deactivate(); } }); } // Overriden by subclasses. protected void findResultSelected(Rect rect) { } private void hideKeyboardAndStartFinding(boolean forward) { if (mFindInPageBridge == null) return; final String findQuery = mFindQuery.getText().toString(); if (findQuery.length() == 0) return; UiUtils.hideKeyboard(mFindQuery); mFindInPageBridge.startFinding(findQuery, forward, false); mFindInPageBridge.activateFindInPageResultForAccessibility(); mAccessibilityDidActivateResult = true; } private boolean mShowKeyboardOnceWindowIsFocused = false; @Override public void onWindowFocusChanged(boolean hasFocus) { super.onWindowFocusChanged(hasFocus); if (mShowKeyboardOnceWindowIsFocused) { mShowKeyboardOnceWindowIsFocused = false; // See showKeyboard() for explanation. // By this point we've already waited till the window regains focus // from the options menu, but we still need to use postDelayed with // a zero wait time to delay until all the side-effects are complete // (e.g. becoming the target of the Input Method). mHandler.postDelayed(new Runnable() { @Override public void run() { showKeyboard(); // This is also a great time to set accessibility focus to the query box - // this also fails if we don't wait until the window regains focus. // Sending a HOVER_ENTER event before the ACCESSIBILITY_FOCUSED event // is a widely-used hack to force TalkBack to move accessibility focus // to a view, which is discouraged in general but reasonable in this case. mFindQuery.sendAccessibilityEvent( AccessibilityEventCompat.TYPE_VIEW_HOVER_ENTER); mFindQuery.sendAccessibilityEvent( AccessibilityEventCompat.TYPE_VIEW_ACCESSIBILITY_FOCUSED); } }, 0); } } @Override public void onFindMatchRects(FindMatchRectsDetails matchRects) { if (mResultBar == null) return; if (mFindQuery.getText().length() > 0) { mResultBar.setMatchRects(matchRects.version, matchRects.rects, matchRects.activeRect); } else { // Since we don't issue a request for an empty string we never get a 'no rects' response // in that case. This could cause us to display stale state if the user is deleting the // search string. If the response for the last character comes in after we've issued a // clearReslts in TextChangedListener that response will be accepted and we will end up // showing stale results for an empty query. // Sending an empty string message seems a bit wasteful, so instead we simply ignore all // results that come in if the query is empty. mResultBar.clearMatchRects(); } } @Override public void onFindResult(FindNotificationDetails result) { if (mResultBar != null) mResultBar.mWaitingForActivateAck = false; assert mFindInPageBridge != null; if ((result.activeMatchOrdinal == -1 || result.numberOfMatches == 1) && !result.finalUpdate) { // Wait until activeMatchOrdinal has been determined (is no longer // -1) before showing counts. Additionally, to reduce flicker, // ignore short-lived interim notifications with numberOfMatches set // to 1, which are sent as soon as something has been found (see bug // 894389 and FindBarController::UpdateFindBarForCurrentResult). // Instead wait until the scoping effort starts returning real // match counts (or the search actually finishes with 1 result). // This also protects against receiving bogus rendererSelectionRects // at the start (see below for why we can't filter them out). return; } if (result.finalUpdate) { if (result.numberOfMatches > 0) { // TODO(johnme): Don't wait till end of find, stream rects live! mFindInPageBridge.requestFindMatchRects( mResultBar != null ? mResultBar.mRectsVersion : -1); } else { clearResults(); } findResultSelected(result.rendererSelectionRect); } // Even though we wait above until activeMatchOrdinal is no longer -1, // it's possible for it to still be -1 (unknown) in the final find // notification. This happens very rarely, e.g. if the m_activeMatch // found by WebFrameImpl::find has been removed from the DOM by the time // WebFrameImpl::scopeStringMatches tries to find the ordinal of the // active match (while counting the matches), as in b/4147049. In such // cases it looks less broken to show 0 instead of -1 (as desktop does). Context context = getContext(); String text = context.getResources().getString( R.string.find_in_page_count, Math.max(result.activeMatchOrdinal, 0), result.numberOfMatches); setStatus(text, result.numberOfMatches == 0); // The accessible version will be something like "Result 1 of 9". String accessibleText = getAccessibleStatusText( Math.max(result.activeMatchOrdinal, 0), result.numberOfMatches); mFindStatus.setContentDescription(accessibleText); announceStatusForAccessibility(accessibleText); // Vibrate when no results are found, unless you're just deleting chars. if (result.numberOfMatches == 0 && result.finalUpdate && !mFindInPageBridge.getPreviousFindText().startsWith( mFindQuery.getText().toString())) { final boolean hapticFeedbackEnabled = Settings.System.getInt( context.getContentResolver(), Settings.System.HAPTIC_FEEDBACK_ENABLED, 1) == 1; if (hapticFeedbackEnabled) { Vibrator v = (Vibrator) context.getSystemService( Context.VIBRATOR_SERVICE); final long noResultsVibrateDurationMs = 50; v.vibrate(noResultsVibrateDurationMs); } } } private String getAccessibleStatusText(int activeMatchOrdinal, int numberOfMatches) { Context context = getContext(); return (numberOfMatches > 0) ? context.getResources().getString( R.string.accessible_find_in_page_count, activeMatchOrdinal, numberOfMatches) : context.getResources().getString(R.string.accessible_find_in_page_no_results); } private void announceStatusForAccessibility(final String announcementText) { // Don't announce if the user has already activated a result by pressing Enter/Search // or clicking on the Next/Previous buttons. if (mAccessibilityDidActivateResult) return; // Delay the announcement briefly, and if any additional announcements come in, // have them preempt the previous queued one. That makes for a better user experience // than speaking instantly as you're typing and constantly interrupting itself. if (mAccessibleAnnouncementRunnable != null) { mHandler.removeCallbacks(mAccessibleAnnouncementRunnable); } mAccessibleAnnouncementRunnable = new Runnable() { @Override public void run() { mFindQuery.announceForAccessibility(announcementText); } }; mHandler.postDelayed(mAccessibleAnnouncementRunnable, ACCESSIBLE_ANNOUNCEMENT_DELAY_MILLIS); } /** The find toolbar's container must provide access to its TabModel. */ public void setTabModelSelector(TabModelSelector modelSelector) { mTabModelSelector = modelSelector; updateVisualsForTabModel(modelSelector != null && modelSelector.isIncognitoSelected()); } /** * Sets the WindowAndroid in which the find toolbar will be shown. Needed for animations. */ public void setWindowAndroid(WindowAndroid windowAndroid) { mWindowAndroid = windowAndroid; } /** * Handles updating any visual elements of the find toolbar based on changes to the tab model. * @param isIncognito Whether the current tab model is incognito or not. */ protected void updateVisualsForTabModel(boolean isIncognito) { } /** * Sets a custom ActionMode.Callback instance to the FindQuery. This lets us * get notified when the user tries to do copy, paste, etc. on the FindQuery. * @param callback The ActionMode.Callback instance to be notified when selection ActionMode * is triggered. */ public void setActionModeCallbackForTextEdit(ActionMode.Callback callback) { mFindQuery.setCustomSelectionActionModeCallback(callback); } /** * Sets the observer to be notified of changes to the find toolbar. */ protected void setObserver(FindToolbarObserver observer) { mObserver = observer; } /** * Checks to see if a ContentViewCore is available to hook into. */ protected boolean isViewAvailable() { Tab currentTab = mTabModelSelector.getCurrentTab(); return currentTab != null && currentTab.getContentViewCore() != null; } /** * Initializes the find toolbar. Should be called just after the find toolbar is shown. * If the toolbar is already showing, this just focuses the toolbar. */ public void activate() { if (!isViewAvailable()) return; if (mActive) { requestQueryFocus(); return; } mTabModelSelector.addObserver(mTabModelSelectorObserver); for (TabModel model : mTabModelSelector.getModels()) { model.addObserver(mTabModelObserver); } mCurrentTab = mTabModelSelector.getCurrentTab(); mCurrentTab.addObserver(mTabObserver); mFindInPageBridge = new FindInPageBridge(mCurrentTab.getWebContents()); mCurrentTab.getTabWebContentsDelegateAndroid().setFindResultListener(this); mCurrentTab.getTabWebContentsDelegateAndroid().setFindMatchRectsListener(this); initializeFindText(); mFindQuery.requestFocus(); // The keyboard doesn't show itself automatically. showKeyboard(); // Always show the bar to make the FindToolbar more distinct from the Omnibox. setResultsBarVisibility(true); mActive = true; updateVisualsForTabModel(mTabModelSelector.isIncognitoSelected()); // Let everyone know that we've just updated. if (mObserver != null) mObserver.onFindToolbarShown(); } /** * Call this just before closing the find toolbar. The selection on the page will be cleared. */ public void deactivate() { deactivate(true); } /** * Call this just before closing the find toolbar. * @param clearSelection Whether the selection on the page should be cleared. */ public void deactivate(boolean clearSelection) { if (!mActive) return; if (mObserver != null) mObserver.onFindToolbarHidden(); setResultsBarVisibility(false); mTabModelSelector.removeObserver(mTabModelSelectorObserver); for (TabModel model : mTabModelSelector.getModels()) { model.removeObserver(mTabModelObserver); } mCurrentTab.getTabWebContentsDelegateAndroid().setFindResultListener(null); mCurrentTab.getTabWebContentsDelegateAndroid().setFindMatchRectsListener(null); mCurrentTab.removeObserver(mTabObserver); UiUtils.hideKeyboard(mFindQuery); if (mFindQuery.getText().length() > 0) { clearResults(); mFindInPageBridge.stopFinding(clearSelection); } mFindInPageBridge.destroy(); mFindInPageBridge = null; mCurrentTab = null; mActive = false; } /** * Requests focus for the query input field and shows the keyboard. */ public void requestQueryFocus() { mFindQuery.requestFocus(); showKeyboard(); } /** Called by the tablet-specific implementation when the hide animation is about to begin. */ protected void onHideAnimationStart() { // We do this because hiding the bar after the animation ends doesn't look good. setResultsBarVisibility(false); } /** * @see WindowAndroid#startAnimationOverContent(Animator) */ protected void startAnimationOverContent(Animator animation) { mWindowAndroid.startAnimationOverContent(animation); } @VisibleForTesting public FindResultBar getFindResultBar() { return mResultBar; } /** * Returns whether an animation to show/hide the FindToolbar is currently running. */ @VisibleForTesting public boolean isAnimating() { return false; } /** * Restores the last text searched in this tab, or the global last search. */ private void initializeFindText() { assert mFindInPageBridge != null; mSettingFindTextProgrammatically = true; String findText = null; if (mSettingFindTextProgrammatically) { findText = mFindInPageBridge.getPreviousFindText(); if (findText.isEmpty() && !mCurrentTab.isIncognito()) { findText = mLastUserSearch; } mSearchKeyShouldTriggerSearch = true; } else { mSearchKeyShouldTriggerSearch = false; } mFindQuery.setText(findText); mSettingFindTextProgrammatically = false; } /** Clears the result displays (except in-page match highlighting). */ protected void clearResults() { setStatus("", false); if (mResultBar != null) { mResultBar.clearMatchRects(); } } private void setResultsBarVisibility(boolean visibility) { if (visibility && mResultBar == null && mCurrentTab != null && mCurrentTab.getContentViewCore() != null) { assert mFindInPageBridge != null; mResultBar = new FindResultBar(getContext(), mCurrentTab, mFindInPageBridge); } else if (!visibility) { if (mResultBar != null) { mResultBar.dismiss(); mResultBar = null; } } } private void setStatus(String text, boolean failed) { mFindStatus.setText(text); mFindStatus.setContentDescription(null); boolean incognito = mTabModelSelector != null && mTabModelSelector.isIncognitoSelected(); mFindStatus.setTextColor(getStatusColor(failed, incognito)); } /** * @param failed Whether or not the find query had any matching results. * @param incognito Whether or not the current tab is incognito. * @return The color of the status text. */ protected int getStatusColor(boolean failed, boolean incognito) { int colorResourceId = failed ? R.color.find_in_page_failed_results_status_color : R.color.find_in_page_results_status_color; return ApiCompatibilityUtils.getColor(getContext().getResources(), colorResourceId); } protected void setPrevNextEnabled(boolean enable) { mFindPrevButton.setEnabled(enable); mFindNextButton.setEnabled(enable); } private void showKeyboard() { if (!mFindQuery.hasWindowFocus()) { // HACK: showKeyboard() is normally called from activate() which is // triggered by an options menu item. Unfortunately, because the // options menu is still focused at this point, that means our // window doesn't actually have focus when this first gets called, // and hence it isn't the target of the Input Method, and in // practice that means the soft keyboard never shows up (whatever // flags you pass). So as a workaround we postpone asking for the // keyboard to be shown until just after the window gets refocused. // See onWindowFocusChanged(boolean hasFocus). mShowKeyboardOnceWindowIsFocused = true; return; } UiUtils.showKeyboard(mFindQuery); } }