blob: 528438da47d5038f56ccf52bb14f2ca6b12a4cf4 [file] [log] [blame]
// Copyright 2018 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.omnibox.suggestions;
import android.content.Context;
import android.content.res.Resources;
import android.os.Handler;
import android.os.SystemClock;
import android.text.TextUtils;
import android.view.View;
import androidx.annotation.IntDef;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.Px;
import androidx.annotation.StringRes;
import androidx.annotation.VisibleForTesting;
import org.chromium.base.ActivityState;
import org.chromium.base.Callback;
import org.chromium.base.jank_tracker.JankScenario;
import org.chromium.base.jank_tracker.JankTracker;
import org.chromium.base.metrics.RecordUserAction;
import org.chromium.base.supplier.Supplier;
import org.chromium.chrome.browser.flags.ChromeFeatureList;
import org.chromium.chrome.browser.omnibox.LocationBarDataProvider;
import org.chromium.chrome.browser.omnibox.OmniboxSuggestionType;
import org.chromium.chrome.browser.omnibox.R;
import org.chromium.chrome.browser.omnibox.UrlBarEditingTextStateProvider;
import org.chromium.chrome.browser.omnibox.styles.OmniboxResourceProvider;
import org.chromium.chrome.browser.omnibox.styles.OmniboxTheme;
import org.chromium.chrome.browser.omnibox.suggestions.AutocompleteController.OnSuggestionsReceivedListener;
import org.chromium.chrome.browser.omnibox.suggestions.basic.BasicSuggestionProcessor.BookmarkState;
import org.chromium.chrome.browser.omnibox.suggestions.mostvisited.ExploreIconProvider;
import org.chromium.chrome.browser.omnibox.voice.VoiceRecognitionHandler;
import org.chromium.chrome.browser.profiles.Profile;
import org.chromium.chrome.browser.share.ShareDelegate;
import org.chromium.chrome.browser.tab.Tab;
import org.chromium.chrome.browser.tab.TabSelectionType;
import org.chromium.chrome.browser.tabmodel.TabModel;
import org.chromium.chrome.browser.tabmodel.TabModelUtils;
import org.chromium.chrome.browser.tabmodel.TabWindowManager;
import org.chromium.components.metrics.OmniboxEventProtos.OmniboxEventProto.PageClassification;
import org.chromium.components.omnibox.AutocompleteMatch;
import org.chromium.components.omnibox.AutocompleteResult;
import org.chromium.components.query_tiles.QueryTile;
import org.chromium.content_public.browser.WebContents;
import org.chromium.ui.base.PageTransition;
import org.chromium.ui.modaldialog.DialogDismissalCause;
import org.chromium.ui.modaldialog.ModalDialogManager;
import org.chromium.ui.modaldialog.ModalDialogProperties;
import org.chromium.ui.modelutil.MVCListAdapter.ModelList;
import org.chromium.ui.modelutil.PropertyModel;
import org.chromium.ui.mojom.WindowOpenDisposition;
import org.chromium.url.GURL;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.util.List;
/**
* Handles updating the model state for the currently visible omnibox suggestions.
*/
class AutocompleteMediator implements OnSuggestionsReceivedListener,
OmniboxSuggestionsDropdown.Observer, SuggestionHost {
private static final int SUGGESTION_NOT_FOUND = -1;
private static final int SCHEDULE_FOR_IMMEDIATE_EXECUTION = -1;
// Delay triggering the omnibox results upon key press to allow the location bar to repaint
// with the new characters.
private static final long OMNIBOX_SUGGESTION_START_DELAY_MS = 30;
private static final int OMNIBOX_HISTOGRAMS_MAX_SUGGESTIONS = 10;
private final @NonNull Context mContext;
private final @NonNull AutocompleteDelegate mDelegate;
private final @NonNull UrlBarEditingTextStateProvider mUrlBarEditingTextProvider;
private final @NonNull PropertyModel mListPropertyModel;
private final @NonNull ModelList mSuggestionModels;
private final @NonNull Handler mHandler;
private final @NonNull LocationBarDataProvider mDataProvider;
private final @NonNull Supplier<ModalDialogManager> mModalDialogManagerSupplier;
private final @NonNull DropdownItemViewInfoListBuilder mDropdownViewInfoListBuilder;
private final @NonNull DropdownItemViewInfoListManager mDropdownViewInfoListManager;
private final @NonNull Callback<Tab> mBringTabToFrontCallback;
private final @NonNull Supplier<TabWindowManager> mTabWindowManagerSupplier;
private final @NonNull JankTracker mJankTracker;
private @NonNull AutocompleteResult mAutocompleteResult = AutocompleteResult.EMPTY_RESULT;
private @Nullable Runnable mCurrentAutocompleteRequest;
private @Nullable Runnable mDeferredLoadAction;
private boolean mNativeInitialized;
private AutocompleteController mAutocomplete;
private long mUrlFocusTime;
private boolean mEnableAdaptiveSuggestionsCount;
private boolean mShouldCacheSuggestions;
@IntDef({SuggestionVisibilityState.DISALLOWED, SuggestionVisibilityState.PENDING_ALLOW,
SuggestionVisibilityState.ALLOWED})
@Retention(RetentionPolicy.SOURCE)
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
@interface SuggestionVisibilityState {
int DISALLOWED = 0;
int PENDING_ALLOW = 1;
int ALLOWED = 2;
}
@SuggestionVisibilityState
private int mSuggestionVisibilityState;
@IntDef({EditSessionState.INACTIVE, EditSessionState.ACTIVATED_BY_USER_INPUT,
EditSessionState.ACTIVATED_BY_QUERY_TILE})
@Retention(RetentionPolicy.SOURCE)
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
@interface EditSessionState {
int INACTIVE = 0; // Omnibox is not being edited.
int ACTIVATED_BY_USER_INPUT = 1; // The edit session is triggered by user input.
int ACTIVATED_BY_QUERY_TILE = 2; // The edit session is triggered from query tile.
}
@EditSessionState
private int mEditSessionState = EditSessionState.INACTIVE;
// The timestamp (using SystemClock.elapsedRealtime()) at the point when the user started
// modifying the omnibox with new input.
private long mNewOmniboxEditSessionTimestamp = -1;
// Set at the end of the Omnibox interaction to indicate whether the user selected an item
// from the list (true) or left the Omnibox and suggestions list with no action taken (false).
private boolean mOmniboxFocusResultedInNavigation;
/**
* The text shown in the URL bar (user text + inline autocomplete) after the most recent set of
* omnibox suggestions was received. When the user presses enter in the omnibox, this value is
* compared to the URL bar text to determine whether the first suggestion is still valid.
*/
private String mUrlTextAfterSuggestionsReceived;
private boolean mShouldPreventOmniboxAutocomplete;
private long mLastActionUpTimestamp;
private boolean mIgnoreOmniboxItemSelection = true;
public AutocompleteMediator(@NonNull Context context, @NonNull AutocompleteDelegate delegate,
@NonNull UrlBarEditingTextStateProvider textProvider,
@NonNull PropertyModel listPropertyModel, @NonNull Handler handler,
@NonNull Supplier<ModalDialogManager> modalDialogManagerSupplier,
@NonNull Supplier<Tab> activityTabSupplier,
@Nullable Supplier<ShareDelegate> shareDelegateSupplier,
@NonNull LocationBarDataProvider locationBarDataProvider,
@NonNull Callback<Tab> bringTabToFrontCallback,
@NonNull Supplier<TabWindowManager> tabWindowManagerSupplier,
@NonNull BookmarkState bookmarkState, @NonNull JankTracker jankTracker,
@NonNull ExploreIconProvider exploreIconProvider) {
mContext = context;
mDelegate = delegate;
mUrlBarEditingTextProvider = textProvider;
mListPropertyModel = listPropertyModel;
mJankTracker = jankTracker;
mModalDialogManagerSupplier = modalDialogManagerSupplier;
mHandler = handler;
mDataProvider = locationBarDataProvider;
mBringTabToFrontCallback = bringTabToFrontCallback;
mTabWindowManagerSupplier = tabWindowManagerSupplier;
mSuggestionModels = mListPropertyModel.get(SuggestionListProperties.SUGGESTION_MODELS);
mDropdownViewInfoListBuilder = new DropdownItemViewInfoListBuilder(
activityTabSupplier, bookmarkState, exploreIconProvider);
mDropdownViewInfoListBuilder.setShareDelegateSupplier(shareDelegateSupplier);
mDropdownViewInfoListManager = new DropdownItemViewInfoListManager(mSuggestionModels);
}
/**
* Initialize the Mediator with default set of suggestion processors.
*/
void initDefaultProcessors(Callback<List<QueryTile>> queryTileSuggestionCallback) {
mDropdownViewInfoListBuilder.initDefaultProcessors(
mContext, this, mDelegate, mUrlBarEditingTextProvider, queryTileSuggestionCallback);
}
/**
* @return DropdownItemViewInfoListBuilder instance used to convert OmniboxSuggestions to list
* of ViewInfos.
*/
DropdownItemViewInfoListBuilder getDropdownItemViewInfoListBuilderForTest() {
return mDropdownViewInfoListBuilder;
}
public void destroy() {
if (mAutocomplete != null) {
stopAutocomplete(false);
mAutocomplete.removeOnSuggestionsReceivedListener(this);
}
mDropdownViewInfoListBuilder.destroy();
}
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
void setSuggestionVisibilityState(@SuggestionVisibilityState int state) {
mSuggestionVisibilityState = state;
}
private @SuggestionVisibilityState int getSuggestionVisibilityState() {
return mSuggestionVisibilityState;
}
/** @return The ModelList for currently shown suggestions. */
ModelList getSuggestionModelListForTest() {
return mSuggestionModels;
}
/**
* Check if the suggestion is created from clipboard.
*
* @param suggestion The AutocompleteMatch to check.
* @return Whether or not the suggestion is from clipboard.
*/
private boolean isSuggestionFromClipboard(AutocompleteMatch suggestion) {
return suggestion.getType() == OmniboxSuggestionType.CLIPBOARD_URL
|| suggestion.getType() == OmniboxSuggestionType.CLIPBOARD_TEXT
|| suggestion.getType() == OmniboxSuggestionType.CLIPBOARD_IMAGE;
}
/**
* @return The number of current autocomplete suggestions.
*/
public int getSuggestionCount() {
return mAutocompleteResult.getSuggestionsList().size();
}
/**
* Retrieve the omnibox suggestion at the specified index. The index represents the ordering
* in the underlying model. The index does not represent visibility due to the current scroll
* position of the list.
*
* @param index The index of the suggestion to fetch.
* @return The suggestion at the given index.
*/
public AutocompleteMatch getSuggestionAt(int index) {
return mAutocompleteResult.getSuggestionsList().get(index);
}
/**
* Sets the layout direction to be used for any new suggestion views.
* @see View#setLayoutDirection(int)
*/
void setLayoutDirection(int layoutDirection) {
mDropdownViewInfoListManager.setLayoutDirection(layoutDirection);
}
/**
* Specifies the visual state to be used by the suggestions.
* @param useDarkColors Whether dark colors should be used for fonts and icons.
* @param isIncognito Whether the UI is for incognito mode or not.
*/
void updateVisualsForState(boolean useDarkColors, boolean isIncognito) {
@OmniboxTheme
int omniboxTheme = OmniboxResourceProvider.getThemeFromDarkColorsAndIncognito(
useDarkColors, isIncognito);
mDropdownViewInfoListManager.setOmniboxTheme(omniboxTheme);
mListPropertyModel.set(SuggestionListProperties.IS_INCOGNITO, isIncognito);
}
/**
* Show cached zero suggest results.
* Enables Autocomplete subsystem to offer most recently presented suggestions in the event
* where Native counterpart is not yet initialized.
*
* Note: the only supported page context right now is the ANDROID_SEARCH_WIDGET.
*/
void startCachedZeroSuggest() {
if (mNativeInitialized) return;
onSuggestionsReceived(CachedZeroSuggestionsManager.readFromCache(), "");
}
/** Notify the mediator that a item selection is pending and should be accepted. */
void allowPendingItemSelection() {
mIgnoreOmniboxItemSelection = false;
}
/**
* Signals that native initialization has completed.
*/
void onNativeInitialized() {
mNativeInitialized = true;
mEnableAdaptiveSuggestionsCount =
ChromeFeatureList.isEnabled(ChromeFeatureList.OMNIBOX_ADAPTIVE_SUGGESTIONS_COUNT);
mDropdownViewInfoListBuilder.onNativeInitialized();
runPendingAutocompleteRequests();
}
/** @see org.chromium.chrome.browser.omnibox.UrlFocusChangeListener#onUrlFocusChange(boolean) */
void onUrlFocusChange(boolean hasFocus) {
if (hasFocus) {
mOmniboxFocusResultedInNavigation = false;
mUrlFocusTime = System.currentTimeMillis();
mJankTracker.startTrackingScenario(JankScenario.OMNIBOX_FOCUS);
setSuggestionVisibilityState(SuggestionVisibilityState.PENDING_ALLOW);
// Ask directly for zero-suggestions related to current input, unless the user is
// currently visiting SearchActivity and the input is populated from the launch intent.
// For SearchActivity, in most cases the input will be empty, triggering the same
// response (starting zero suggestions), but if the Activity was launched with a QUERY,
// then the query might point to a different URL than the reported Page, and the
// suggestion would take the user to the DSE home page.
// This is tracked by MobileStartup.LaunchCause / EXTERNAL_SEARCH_ACTION_INTENT
// metric.
if (mDataProvider.getPageClassification(false)
!= PageClassification.ANDROID_SEARCH_WIDGET_VALUE) {
postAutocompleteRequest(this::startZeroSuggest, SCHEDULE_FOR_IMMEDIATE_EXECUTION);
} else {
String text = mUrlBarEditingTextProvider.getTextWithoutAutocomplete();
onTextChanged(text, text);
}
} else {
mJankTracker.finishTrackingScenario(JankScenario.OMNIBOX_FOCUS);
cancelAutocompleteRequests();
SuggestionsMetrics.recordOmniboxFocusResultedInNavigation(
mOmniboxFocusResultedInNavigation);
setSuggestionVisibilityState(SuggestionVisibilityState.DISALLOWED);
mEditSessionState = EditSessionState.INACTIVE;
mNewOmniboxEditSessionTimestamp = -1;
// Prevent any upcoming omnibox suggestions from showing once a URL is loaded (and as
// a consequence the omnibox is unfocused).
hideSuggestions();
}
mDropdownViewInfoListBuilder.onUrlFocusChange(hasFocus);
}
/**
* @see
* org.chromium.chrome.browser.omnibox.UrlFocusChangeListener#onUrlAnimationFinished(boolean)
*/
void onUrlAnimationFinished(boolean hasFocus) {
setSuggestionVisibilityState(hasFocus ? SuggestionVisibilityState.ALLOWED
: SuggestionVisibilityState.DISALLOWED);
updateOmniboxSuggestionsVisibility();
}
/**
* Updates the profile used for generating autocomplete suggestions.
* @param profile The profile to be used.
*/
void setAutocompleteProfile(Profile profile) {
if (mAutocomplete != null) {
stopAutocomplete(true);
mAutocomplete.removeOnSuggestionsReceivedListener(this);
}
mAutocomplete = AutocompleteController.getForProfile(profile);
mAutocomplete.addOnSuggestionsReceivedListener(this);
mDropdownViewInfoListBuilder.setProfile(profile);
runPendingAutocompleteRequests();
}
/**
* Whether omnibox autocomplete should currently be prevented from generating suggestions.
*/
void setShouldPreventOmniboxAutocomplete(boolean prevent) {
mShouldPreventOmniboxAutocomplete = prevent;
}
/**
* @see AutocompleteController#onVoiceResults(List)
*/
void onVoiceResults(@Nullable List<VoiceRecognitionHandler.VoiceResult> results) {
if (!mNativeInitialized || mAutocomplete == null) return;
mAutocomplete.onVoiceResults(results);
}
/**
* @return The current native pointer to the autocomplete results.
* TODO(crbug.com/1138587): Figure out how to remove this.
*/
long getCurrentNativeAutocompleteResult() {
return mAutocompleteResult.getNativeObjectRef();
}
private static boolean isQueryEditingEnabled() {
return ChromeFeatureList.isEnabled(ChromeFeatureList.QUERY_TILES_ENABLE_QUERY_EDITING);
}
/** Called when a query tile is selected by the user. */
void onQueryTileSelected(QueryTile queryTile) {
// For last level tile, start a search query, unless we want to let user have a chance to
// edit the query.
if (queryTile.children.isEmpty() && !isQueryEditingEnabled()) {
launchSearchUrlForQueryTileSuggestion(queryTile);
return;
}
// If the tile has sub-tiles, start a new request to the backend to get the new set
// of tiles. Also set the tile text in omnibox.
stopAutocomplete(false);
String refineText = TextUtils.concat(queryTile.queryText, " ").toString();
mDelegate.setOmniboxEditingText(refineText);
mNewOmniboxEditSessionTimestamp = SystemClock.elapsedRealtime();
mEditSessionState = EditSessionState.ACTIVATED_BY_QUERY_TILE;
mAutocomplete.start(mDataProvider.getCurrentUrl(),
mDataProvider.getPageClassification(mDelegate.didFocusUrlFromFakebox()),
mUrlBarEditingTextProvider.getTextWithoutAutocomplete(),
mUrlBarEditingTextProvider.getSelectionStart(),
!mUrlBarEditingTextProvider.shouldAutocomplete(), queryTile.id, true);
}
/**
* Triggered when the user selects one of the omnibox suggestions to navigate to.
* @param suggestion The AutocompleteMatch which was selected.
* @param position Position of the suggestion in the drop down view.
* @param url The URL associated with the suggestion.
*/
@Override
public void onSuggestionClicked(
@NonNull AutocompleteMatch suggestion, int position, @NonNull GURL url) {
if (mAutocompleteResult.isFromCachedResult()
&& (!mNativeInitialized || mAutocomplete == null)) {
// clang-format off
mDeferredLoadAction = () -> loadUrlForOmniboxMatch(
position, suggestion, url, mLastActionUpTimestamp, true);
// clang-format on
return;
}
loadUrlForOmniboxMatch(position, suggestion, url, mLastActionUpTimestamp, true);
}
/**
* Triggered when the user selects to refine one of the omnibox suggestions.
* @param suggestion The suggestion selected.
*/
@Override
public void onRefineSuggestion(AutocompleteMatch suggestion) {
stopAutocomplete(false);
boolean isSearchSuggestion = suggestion.isSearchSuggestion();
String refineText = suggestion.getFillIntoEdit();
if (isSearchSuggestion) refineText = TextUtils.concat(refineText, " ").toString();
mDelegate.setOmniboxEditingText(refineText);
onTextChanged(mUrlBarEditingTextProvider.getTextWithoutAutocomplete(),
mUrlBarEditingTextProvider.getTextWithAutocomplete());
if (isSearchSuggestion) {
RecordUserAction.record("MobileOmniboxRefineSuggestion.Search");
} else {
RecordUserAction.record("MobileOmniboxRefineSuggestion.Url");
}
}
@Override
public void onSwitchToTab(AutocompleteMatch suggestion, int position) {
Tab tab = mAutocomplete.findMatchingTabWithUrl(suggestion.getUrl());
if (tab == null || !mTabWindowManagerSupplier.hasValue()) {
onSuggestionClicked(suggestion, position, suggestion.getUrl());
return;
}
// When invoked directly from a browser, we want to trigger switch to tab animation.
// If invoked from other activities, ex. searchActivity, we do not need to trigger the
// animation since Android will show the animation for switching apps.
if (tab.getWindowAndroid().getActivityState() != ActivityState.STOPPED
&& tab.getWindowAndroid().getActivityState() != ActivityState.DESTROYED) {
TabModel tabModel = mTabWindowManagerSupplier.get().getTabModelForTab(tab);
assert tabModel != null;
int tabIndex = TabModelUtils.getTabIndexById(tabModel, tab.getId());
tabModel.setIndex(tabIndex, TabSelectionType.FROM_OMNIBOX);
} else {
mBringTabToFrontCallback.onResult(tab);
}
recordMetrics(position, WindowOpenDisposition.SWITCH_TO_TAB, suggestion);
}
@Override
public void onGesture(boolean isGestureUp, long timestamp) {
stopAutocomplete(false);
if (isGestureUp) {
mLastActionUpTimestamp = timestamp;
}
}
/**
* Triggered when the user long presses the omnibox suggestion.
* @param suggestion The suggestion selected.
* @param position The position of the suggestion.
*
* TODO(crbug.com/1136107): revisit the event propagation here to make sure we do not try to
* execute an action before native is initialize.
*/
@Override
public void onSuggestionLongClicked(@NonNull AutocompleteMatch suggestion, int position) {
RecordUserAction.record("MobileOmniboxDeleteGesture");
if (!suggestion.isDeletable()) return;
ModalDialogManager manager = mModalDialogManagerSupplier.get();
if (manager == null) {
assert false : "No modal dialog manager registered for this activity.";
return;
}
ModalDialogProperties.Controller dialogController = new ModalDialogProperties.Controller() {
@Override
public void onClick(PropertyModel model, int buttonType) {
if (buttonType == ModalDialogProperties.ButtonType.POSITIVE) {
RecordUserAction.record("MobileOmniboxDeleteRequested");
mAutocomplete.deleteSuggestion(position);
manager.dismissDialog(model, DialogDismissalCause.POSITIVE_BUTTON_CLICKED);
} else if (buttonType == ModalDialogProperties.ButtonType.NEGATIVE) {
manager.dismissDialog(model, DialogDismissalCause.NEGATIVE_BUTTON_CLICKED);
}
}
@Override
public void onDismiss(PropertyModel model, int dismissalCause) {}
};
Resources resources = mContext.getResources();
@StringRes
int dialogMessageId = R.string.omnibox_confirm_delete;
if (isSuggestionFromClipboard(suggestion)) {
dialogMessageId = R.string.omnibox_confirm_delete_from_clipboard;
}
PropertyModel model =
new PropertyModel.Builder(ModalDialogProperties.ALL_KEYS)
.with(ModalDialogProperties.CONTROLLER, dialogController)
.with(ModalDialogProperties.TITLE, suggestion.getDisplayText())
.with(ModalDialogProperties.MESSAGE, resources, dialogMessageId)
.with(ModalDialogProperties.POSITIVE_BUTTON_TEXT, resources, R.string.ok)
.with(ModalDialogProperties.NEGATIVE_BUTTON_TEXT, resources,
R.string.cancel)
.with(ModalDialogProperties.CANCEL_ON_TOUCH_OUTSIDE, true)
.build();
// Prevent updates to the shown omnibox suggestions list while the dialog is open.
stopAutocomplete(false);
manager.showDialog(model, ModalDialogManager.ModalDialogType.APP);
}
/**
* Triggered when the user navigates to one of the suggestions without clicking on it.
* @param text The text to be displayed in the Omnibox.
*/
@Override
public void setOmniboxEditingText(String text) {
if (mIgnoreOmniboxItemSelection) return;
mIgnoreOmniboxItemSelection = true;
mDelegate.setOmniboxEditingText(text);
}
/**
* Updates the URL we will navigate to from suggestion, if needed. This will update the search
* URL to be of the corpus type if query in the omnibox is displayed and update aqs= parameter
* on regular web search URLs.
*
* @param suggestion The chosen omnibox suggestion.
* @param selectedIndex The index of the chosen omnibox suggestion.
* @param url The URL associated with the suggestion to navigate to.
* @param skipCheck Whether to skip an out of bounds check.
* @return The url to navigate to.
*/
private GURL updateSuggestionUrlIfNeeded(@NonNull AutocompleteMatch suggestion,
int selectedIndex, @NonNull GURL url, boolean skipCheck) {
if (!mNativeInitialized || mAutocomplete == null) return url;
if (suggestion.getType() == OmniboxSuggestionType.VOICE_SUGGEST
|| suggestion.getType() == OmniboxSuggestionType.TILE_SUGGESTION
|| suggestion.getType() == OmniboxSuggestionType.TILE_NAVSUGGEST) {
return url;
}
int verifiedIndex = SUGGESTION_NOT_FOUND;
if (!skipCheck) {
verifiedIndex = findSuggestionInAutocompleteResult(suggestion, selectedIndex);
}
// If we do not have the suggestion as part of our results, skip the URL update.
if (verifiedIndex == SUGGESTION_NOT_FOUND) return url;
// TODO(mariakhomenko): Ideally we want to update match destination URL with new aqs
// for query in the omnibox and voice suggestions, but it's currently difficult to do.
GURL updatedUrl = mAutocomplete.updateMatchDestinationUrlWithQueryFormulationTime(
verifiedIndex, getElapsedTimeSinceInputChange());
return updatedUrl == null ? url : updatedUrl;
}
/**
* Check if the supplied suggestion is still in the current model and return its index.
*
* This call should be used to confirm that model has not been changed ahead of an event being
* called by all the methods that are dispatched rather than called directly.
*
* @param suggestion Suggestion to look for.
* @param index Last known position of the suggestion.
* @return Current index of the supplied suggestion, or SUGGESTION_NOT_FOUND if it is no longer
* part of the model.
*/
@SuppressWarnings("ReferenceEquality")
private int findSuggestionInAutocompleteResult(AutocompleteMatch suggestion, int position) {
if (getSuggestionCount() > position && getSuggestionAt(position) == suggestion) {
return position;
}
// Underlying omnibox results may have changed since the selection was made,
// find the suggestion item, if possible.
for (int index = 0; index < getSuggestionCount(); index++) {
if (suggestion.equals(getSuggestionAt(index))) {
return index;
}
}
return SUGGESTION_NOT_FOUND;
}
/**
* Notifies the autocomplete system that the text has changed that drives autocomplete and the
* autocomplete suggestions should be updated.
*/
public void onTextChanged(String textWithoutAutocomplete, String textWithAutocomplete) {
if (mShouldPreventOmniboxAutocomplete) return;
mIgnoreOmniboxItemSelection = true;
cancelAutocompleteRequests();
if (mEditSessionState == EditSessionState.INACTIVE && mNativeInitialized
&& mAutocomplete != null) {
mAutocomplete.resetSession();
mNewOmniboxEditSessionTimestamp = SystemClock.elapsedRealtime();
mEditSessionState = EditSessionState.ACTIVATED_BY_USER_INPUT;
}
stopAutocomplete(false);
if (TextUtils.isEmpty(textWithoutAutocomplete)) {
hideSuggestions();
postAutocompleteRequest(this::startZeroSuggest, SCHEDULE_FOR_IMMEDIATE_EXECUTION);
} else {
// There may be no tabs when searching form omnibox in overview mode. In that case,
// LocationBarDataProvider.getCurrentUrl() returns NTP url.
if (mDataProvider.hasTab() || mDataProvider.isInOverviewAndShowingOmnibox()) {
boolean preventAutocomplete = !mUrlBarEditingTextProvider.shouldAutocomplete();
int cursorPosition = mUrlBarEditingTextProvider.getSelectionStart()
== mUrlBarEditingTextProvider.getSelectionEnd()
? mUrlBarEditingTextProvider.getSelectionStart()
: -1;
int pageClassification =
mDataProvider.getPageClassification(mDelegate.didFocusUrlFromFakebox());
String currentUrl = mDataProvider.getCurrentUrl();
boolean isQueryStartedFromTiles = mDelegate.didFocusUrlFromQueryTiles()
|| mEditSessionState == EditSessionState.ACTIVATED_BY_QUERY_TILE;
postAutocompleteRequest(() -> {
mAutocomplete.start(currentUrl, pageClassification, textWithoutAutocomplete,
cursorPosition, preventAutocomplete, null, isQueryStartedFromTiles);
}, OMNIBOX_SUGGESTION_START_DELAY_MS);
}
}
mDelegate.onUrlTextChanged();
}
@Override
public void onSuggestionsReceived(
AutocompleteResult autocompleteResult, String inlineAutocompleteText) {
if (mShouldPreventOmniboxAutocomplete
|| getSuggestionVisibilityState() == SuggestionVisibilityState.DISALLOWED) {
return;
}
if (mShouldCacheSuggestions) {
CachedZeroSuggestionsManager.saveToCache(autocompleteResult);
}
final List<AutocompleteMatch> newSuggestions = autocompleteResult.getSuggestionsList();
String userText = mUrlBarEditingTextProvider.getTextWithoutAutocomplete();
mUrlTextAfterSuggestionsReceived = userText + inlineAutocompleteText;
if (!mAutocompleteResult.equals(autocompleteResult)) {
mAutocompleteResult = autocompleteResult;
List<DropdownItemViewInfo> viewInfoList =
mDropdownViewInfoListBuilder.buildDropdownViewInfoList(autocompleteResult);
mDropdownViewInfoListManager.setSourceViewInfoList(
viewInfoList, autocompleteResult.getGroupsDetails());
boolean defaultMatchIsSearch = true;
if (!TextUtils.isEmpty(mUrlBarEditingTextProvider.getTextWithoutAutocomplete())
&& !newSuggestions.isEmpty()) {
defaultMatchIsSearch = newSuggestions.get(0).isSearchSuggestion();
}
mDelegate.onSuggestionsChanged(inlineAutocompleteText, defaultMatchIsSearch);
updateOmniboxSuggestionsVisibility();
}
}
@Override
public void setGroupCollapsedState(int groupId, boolean state) {
mDropdownViewInfoListManager.setGroupCollapsedState(groupId, state);
}
/**
* Load the url corresponding to the typed omnibox text.
* @param eventTime The timestamp the load was triggered by the user.
*/
void loadTypedOmniboxText(long eventTime) {
final String urlText = mUrlBarEditingTextProvider.getTextWithAutocomplete();
cancelAutocompleteRequests();
if (mNativeInitialized && mAutocomplete != null) {
findMatchAndLoadUrl(urlText, eventTime);
} else {
mDeferredLoadAction = () -> findMatchAndLoadUrl(urlText, eventTime);
}
}
/**
* Search for a suggestion with the same associated URL as the supplied one.
*
* @param urlText The URL text to search for.
* @param eventTime The timestamp the load was triggered by the user.
*/
private void findMatchAndLoadUrl(String urlText, long inputStart) {
AutocompleteMatch suggestionMatch;
boolean inSuggestionList = true;
if (getSuggestionCount() > 0
&& urlText.trim().equals(mUrlTextAfterSuggestionsReceived.trim())) {
// Common case: the user typed something, received suggestions, then pressed enter.
// This triggers the Default Match.
suggestionMatch = getSuggestionAt(0);
} else {
// Less common case: there are no valid omnibox suggestions. This can happen if the
// user tapped the URL bar to dismiss the suggestions, then pressed enter. This can
// also happen if the user presses enter before any suggestions have been received
// from the autocomplete controller.
if (!mNativeInitialized || mAutocomplete == null) return;
suggestionMatch = mAutocomplete.classify(urlText, mDelegate.didFocusUrlFromFakebox());
// Classify matches don't propagate to java, so skip the OOB check.
inSuggestionList = false;
// If urlText couldn't be classified, bail.
if (suggestionMatch == null) return;
}
loadUrlForOmniboxMatch(
0, suggestionMatch, suggestionMatch.getUrl(), inputStart, inSuggestionList);
}
/**
* Loads the specified omnibox suggestion.
*
* @param matchPosition The position of the selected omnibox suggestion.
* @param suggestion The suggestion selected.
* @param url The URL to load.
* @param inputStart The timestamp the input was started.
* @param inVisibleSuggestionList Whether the suggestion is in the visible suggestion list.
*/
private void loadUrlForOmniboxMatch(int matchPosition, @NonNull AutocompleteMatch suggestion,
@NonNull GURL url, long inputStart, boolean inVisibleSuggestionList) {
SuggestionsMetrics.recordFocusToOpenTime(System.currentTimeMillis() - mUrlFocusTime);
// Clear the deferred site load action in case it executes. Reclaims a bit of memory.
mDeferredLoadAction = null;
mOmniboxFocusResultedInNavigation = true;
url = updateSuggestionUrlIfNeeded(suggestion, matchPosition, url, !inVisibleSuggestionList);
// loadUrl modifies AutocompleteController's state clearing the native
// AutocompleteResults needed by onSuggestionsSelected. Therefore,
// loadUrl should should be invoked last.
int transition = suggestion.getTransition();
int type = suggestion.getType();
recordMetrics(matchPosition, WindowOpenDisposition.CURRENT_TAB, suggestion);
if (((transition & PageTransition.CORE_MASK) == PageTransition.TYPED)
&& TextUtils.equals(url.getSpec(), mDataProvider.getCurrentUrl())) {
// When the user hit enter on the existing permanent URL, treat it like a
// reload for scoring purposes. We could detect this by just checking
// user_input_in_progress_, but it seems better to treat "edits" that end
// up leaving the URL unchanged (e.g. deleting the last character and then
// retyping it) as reloads too. We exclude non-TYPED transitions because if
// the transition is GENERATED, the user input something that looked
// different from the current URL, even if it wound up at the same place
// (e.g. manually retyping the same search query), and it seems wrong to
// treat this as a reload.
transition = PageTransition.RELOAD;
} else if (type == OmniboxSuggestionType.URL_WHAT_YOU_TYPED
&& mUrlBarEditingTextProvider.wasLastEditPaste()) {
// It's important to use the page transition from the suggestion or we might end
// up saving generated URLs as typed URLs, which would then pollute the subsequent
// omnibox results. There is one special case where the suggestion text was pasted,
// where we want the transition type to be LINK.
transition = PageTransition.LINK;
}
if (suggestion.getType() == OmniboxSuggestionType.CLIPBOARD_IMAGE) {
mDelegate.loadUrlWithPostData(url.getSpec(), transition, inputStart,
suggestion.getPostContentType(), suggestion.getPostData());
return;
}
mDelegate.loadUrl(url.getSpec(), transition, inputStart);
}
/**
* Make a zero suggest request if:
* - The URL bar has focus.
* - The the tab/overview is not incognito.
* This method should not be called directly. Schedule execution using postAutocompleteRequest.
*/
private void startZeroSuggest() {
// Reset "edited" state in the omnibox if zero suggest is triggered -- new edits
// now count as a new session.
mEditSessionState = EditSessionState.INACTIVE;
mNewOmniboxEditSessionTimestamp = -1;
assert mNativeInitialized
: "startZeroSuggest should be scheduled using postAutocompleteRequest";
if (mDelegate.isUrlBarFocused()
&& (mDataProvider.hasTab() || mDataProvider.isInOverviewAndShowingOmnibox())) {
int pageClassification =
mDataProvider.getPageClassification(mDelegate.didFocusUrlFromFakebox());
mShouldCacheSuggestions =
pageClassification == PageClassification.ANDROID_SEARCH_WIDGET_VALUE;
mAutocomplete.startZeroSuggest(mUrlBarEditingTextProvider.getTextWithAutocomplete(),
mDataProvider.getCurrentUrl(), pageClassification, mDataProvider.getTitle());
}
}
/**
* Update whether the omnibox suggestions are visible.
*/
private void updateOmniboxSuggestionsVisibility() {
boolean shouldBeVisible =
getSuggestionVisibilityState() == SuggestionVisibilityState.ALLOWED
&& getSuggestionCount() > 0;
boolean wasVisible = mListPropertyModel.get(SuggestionListProperties.VISIBLE);
mListPropertyModel.set(SuggestionListProperties.VISIBLE, shouldBeVisible);
if (shouldBeVisible && !wasVisible) {
mIgnoreOmniboxItemSelection = true; // Reset to default value.
}
}
/**
* Hides the omnibox suggestion popup.
*
* <p>
* Signals the autocomplete controller to stop generating omnibox suggestions.
*
* @see AutocompleteController#stop(boolean)
*/
private void hideSuggestions() {
if (!mNativeInitialized || mAutocomplete == null) return;
stopAutocomplete(true);
mDropdownViewInfoListManager.clear();
mAutocompleteResult = AutocompleteResult.EMPTY_RESULT;
updateOmniboxSuggestionsVisibility();
}
/**
* Signals the autocomplete controller to stop generating omnibox suggestions and cancels the
* queued task to start the autocomplete controller, if any.
*
* @param clear Whether to clear the most recent autocomplete results.
*/
private void stopAutocomplete(boolean clear) {
if (mNativeInitialized && mAutocomplete != null) mAutocomplete.stop(clear);
cancelAutocompleteRequests();
}
/**
* Trigger autocomplete for the given query.
*/
void startAutocompleteForQuery(String query) {
if (!mNativeInitialized || mAutocomplete == null) return;
stopAutocomplete(false);
if (mDataProvider.hasTab()) {
mAutocomplete.start(mDataProvider.getCurrentUrl(),
mDataProvider.getPageClassification(false), query, -1, false, null, false);
}
}
/**
* Respond to Suggestion list height change and update list of presented suggestions.
*
* This typically happens as a result of soft keyboard being shown or hidden.
*
* @param newHeightPx New height of the suggestion list in pixels.
*/
@Override
public void onSuggestionDropdownHeightChanged(@Px int newHeight) {
// Report the dropdown height whenever we intend to - or do show soft keyboard. This
// addresses cases where hardware keyboard is attached to a device, or where user explicitly
// called the keyboard back after we hid it.
if (mDelegate.isKeyboardActive()) {
mDropdownViewInfoListBuilder.setDropdownHeightWithKeyboardActive(newHeight);
}
}
@Override
public void onSuggestionDropdownScroll() {
if (mEnableAdaptiveSuggestionsCount
&& mDropdownViewInfoListBuilder.hasFullyConcealedElements()) {
mDelegate.setKeyboardVisibility(false, false);
}
}
/**
* Called whenever a navigation happens from the omnibox to record metrics about the user's
* interaction with the omnibox.
*
* @param matchPosition The index of the suggestion that was selected.
* @param disposition The window open disposition.
* @param suggestion The suggestion selected.
*/
private void recordMetrics(int matchPosition, int disposition, AutocompleteMatch suggestion) {
SuggestionsMetrics.recordUsedSuggestionFromCache(mAutocompleteResult.isFromCachedResult());
// Do not attempt to record other metrics for cached suggestions if the source of the list
// is local cache. These suggestions do not have corresponding native objects and will fail
// validation.
if (mAutocompleteResult.isFromCachedResult()) return;
String currentPageUrl = mDataProvider.getCurrentUrl();
int pageClassification =
mDataProvider.getPageClassification(mDelegate.didFocusUrlFromFakebox());
long elapsedTimeSinceModified = getElapsedTimeSinceInputChange();
int autocompleteLength = mUrlBarEditingTextProvider.getTextWithAutocomplete().length()
- mUrlBarEditingTextProvider.getTextWithoutAutocomplete().length();
WebContents webContents =
mDataProvider.hasTab() ? mDataProvider.getTab().getWebContents() : null;
mAutocomplete.onSuggestionSelected(matchPosition, disposition, suggestion.getType(),
currentPageUrl, pageClassification, elapsedTimeSinceModified, autocompleteLength,
webContents);
}
@Override
public void onSuggestionDropdownOverscrolledToTop() {
if (mEnableAdaptiveSuggestionsCount) {
mDelegate.setKeyboardVisibility(true, false);
}
}
/**
* @return elapsed time (in milliseconds) since last input or -1 if user has chosen
* a zero-prefix suggestion.
*/
private long getElapsedTimeSinceInputChange() {
return mNewOmniboxEditSessionTimestamp > 0
? (SystemClock.elapsedRealtime() - mNewOmniboxEditSessionTimestamp)
: -1;
}
/**
* Launches the search URL for the query tile suggestion.
* @param queryTile The query tile user selected.
*/
@SuppressWarnings("VisibleForTests")
private void launchSearchUrlForQueryTileSuggestion(QueryTile queryTile) {
SuggestionsMetrics.recordFocusToOpenTime(System.currentTimeMillis() - mUrlFocusTime);
int position = -1;
int suggestionCount = getSuggestionCount();
AutocompleteMatch suggestion = null;
// Find the suggestion position and hashCode.
for (int i = 0; i < suggestionCount; ++i) {
suggestion = getSuggestionAt(i);
if (suggestion.getType() == OmniboxSuggestionType.TILE_SUGGESTION) {
position = i;
break;
}
}
if (suggestion == null) return;
GURL updatedUrl = mAutocomplete.updateMatchDestinationUrlWithQueryFormulationTime(position,
getElapsedTimeSinceInputChange(), queryTile.queryText, queryTile.searchParams);
// Abort if the Autocomplete has just become invalid/profile was destroyed.
if (updatedUrl == null) return;
// RecordMetrics has to be called before loadUrl, or otherwise the native AutocompleteResult
// object will be reset and the suggestion will fail validation.
recordMetrics(position, WindowOpenDisposition.CURRENT_TAB, suggestion);
mDelegate.loadUrl(updatedUrl.getSpec(), PageTransition.LINK, mLastActionUpTimestamp);
}
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
@EditSessionState
int getEditSessionStateForTest() {
return mEditSessionState;
}
/**
* Schedule Autocomplete action for execution.
* Each Autocomplete action posted here will cancel any previously posted Autocomplete action,
* ensuring that the actions don't compete against each other. Any action scheduled for
* execution before Native libraries are ready will be deferred.
*
* This call should only be used for regular suggest flows. Do not post arbitrary tasks here.
*
* @param action Autocomplete action to execute.
* @param delayMillis The number of milliseconds by which the action should be delayed.
* Use SCHEDULE_FOR_IMMEDIATE_EXECUTION to post action at front of the message queue.
*/
private void postAutocompleteRequest(@NonNull Runnable action, long delayMillis) {
cancelAutocompleteRequests();
mCurrentAutocompleteRequest = new Runnable() {
@Override
public void run() {
action.run();
// Catch any AutocompleteRequests that post subsequent AutocompleteRequest.
// Note: we have to explicitly instantiate a Runnable class, otherwise
// 'this' will resolve into a parent class and Runnable.this won't work.
assert mCurrentAutocompleteRequest == this;
// Release completed Runnable.
mCurrentAutocompleteRequest = null;
}
};
// In the event we got Native Ready signal but no Profile yet (or the other way around),
// delay execution of the Autocomplete request.
if (!mNativeInitialized || mAutocomplete == null) return;
if (delayMillis == SCHEDULE_FOR_IMMEDIATE_EXECUTION) {
// TODO(crbug.com/1174855): Replace the following with postAtFrontOfQueue() and
// correct any tests that expect data instantly.
mCurrentAutocompleteRequest.run();
} else {
mHandler.postDelayed(mCurrentAutocompleteRequest, delayMillis);
}
}
/**
* Cancel any pending autocomplete actions.
*/
private void cancelAutocompleteRequests() {
mShouldCacheSuggestions = false;
if (mCurrentAutocompleteRequest != null) {
mHandler.removeCallbacks(mCurrentAutocompleteRequest);
mCurrentAutocompleteRequest = null;
}
}
/**
* Execute any pending Autocomplete requests, if the Autocomplete subsystem is ready.
*/
private void runPendingAutocompleteRequests() {
if (!mNativeInitialized || mAutocomplete == null) return;
if (mDeferredLoadAction != null) {
// Re-schedule the load action for execution.
mHandler.post(mDeferredLoadAction);
mDeferredLoadAction = null;
cancelAutocompleteRequests();
} else if (mCurrentAutocompleteRequest != null) {
// Re-schedule the autocomplete action for immediate execution.
// These requests are not executed until Native libraries are loaded.
mHandler.postAtFrontOfQueue(mCurrentAutocompleteRequest);
}
}
}