chromium/chrome/browser/ui/android/omnibox/java/src/org/chromium/chrome/browser/omnibox/suggestions/AutocompleteController.java

// Copyright 2014 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

package org.chromium.chrome.browser.omnibox.suggestions;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.Px;
import androidx.annotation.VisibleForTesting;

import org.jni_zero.CalledByNative;
import org.jni_zero.JniType;
import org.jni_zero.NativeMethods;

import org.chromium.chrome.browser.omnibox.OmniboxMetrics;
import org.chromium.chrome.browser.omnibox.suggestions.action.OmniboxAnswerAction;
import org.chromium.chrome.browser.omnibox.voice.VoiceRecognitionHandler.VoiceResult;
import org.chromium.chrome.browser.profiles.Profile;
import org.chromium.chrome.browser.tab.Tab;
import org.chromium.components.omnibox.AutocompleteMatch;
import org.chromium.components.omnibox.AutocompleteResult;
import org.chromium.components.omnibox.AutocompleteResult.VerificationPoint;
import org.chromium.content_public.browser.NavigationHandle;
import org.chromium.content_public.browser.WebContents;
import org.chromium.url.GURL;

import java.util.HashSet;
import java.util.List;
import java.util.Optional;
import java.util.Set;

/**
 * Bridge to the native AutocompleteControllerAndroid.
 *
 * <p>The bridge is created and maintained by the AutocompleteControllerAndroid native class. The
 * Native class is created on request for supplied profiles and remains available until the Profile
 * gets destroyed, making this instance follow the same life cycle.
 *
 * <p>Instances of this class should not be acquired directly; instead, when a profile-specific
 * AutocompleteController is required, please acquire one using the AutocompleteControllerFactory.
 *
 * <p>When User Profile gets destroyed, native class gets destroyed as well, and during the
 * destruction calls the #notifyNativeDestroyed() method, which signals the Java
 * AutocompleteController is no longer valid, and removes it from the AutocompleteControllerFactory
 * cache.
 */
public class AutocompleteController {
    // Maximum number of voice suggestions to show.
    private static final int MAX_VOICE_SUGGESTION_COUNT = 3;

    private final @NonNull Set<OnSuggestionsReceivedListener> mListeners = new HashSet<>();
    private long mNativeController;
    private @NonNull Optional<AutocompleteResult> mAutocompleteResult = Optional.empty();

    /** Listener for receiving OmniboxSuggestions. */
    public interface OnSuggestionsReceivedListener {
        /**
         * Receive autocomplete matches for currently executing query.
         *
         * @param autocompleteResult The current set of autocomplete matches for previously supplied
         *     query.
         * @param isFinal Whether this result is transitory (false) or final (true). Final result
         *     always comes in last, even if the query is canceled.
         */
        void onSuggestionsReceived(@NonNull AutocompleteResult autocompleteResult, boolean isFinal);
    }

    /**
     * Acquire an instance of AutocompleteController associated with the supplied Profile.
     *
     * @return An existing (if one is available) or new (otherwise) instance of the
     *     AutocompleteController associated with the supplied profile.
     */
    @CalledByNative
    private AutocompleteController(long nativeController) {
        mNativeController = nativeController;
    }

    /**
     * @param listener The listener to be notified when new suggestions are available.
     */
    public void addOnSuggestionsReceivedListener(@NonNull OnSuggestionsReceivedListener listener) {
        mListeners.add(listener);
    }

    /**
     * @param listener A previously registered new suggestions listener to be removed.
     */
    public void removeOnSuggestionsReceivedListener(
            @NonNull OnSuggestionsReceivedListener listener) {
        mListeners.remove(listener);
    }

    /**
     * Starts querying for omnibox suggestions for a given text.
     *
     * @param url The URL of the current tab, used to suggest query refinements.
     * @param pageClassification The page classification of the current tab.
     * @param text The text to query autocomplete suggestions for.
     * @param cursorPosition The position of the cursor within the text. Set to -1 if the cursor is
     *     not focused on the text.
     * @param preventInlineAutocomplete Whether autocomplete suggestions should be prevented.
     */
    @VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE)
    public void start(
            @NonNull GURL url,
            int pageClassification,
            @NonNull String text,
            int cursorPosition,
            boolean preventInlineAutocomplete) {
        if (mNativeController == 0) return;

        AutocompleteControllerJni.get()
                .start(
                        mNativeController,
                        text,
                        cursorPosition,
                        null,
                        url.getSpec(),
                        pageClassification,
                        preventInlineAutocomplete,
                        false,
                        false,
                        true);
    }

    /**
     * Issue a prefetch request for zero prefix suggestions. Prefetch is a fire-and-forget operation
     * that yields no results.
     *
     * @param url The URL of the current tab, used to suggest query refinements.
     * @param pageClassification The page classification of the current tab.
     */
    void startPrefetch(@NonNull GURL url, int pageClassification) {
        if (mNativeController == 0) return;
        AutocompleteControllerJni.get()
                .startPrefetch(mNativeController, url.getSpec(), pageClassification);
    }

    /**
     * Given some string |text| that the user wants to use for navigation, determines how it should
     * be interpreted. This is a fallback in case the user didn't select a visible suggestion (e.g.
     * the user pressed enter before omnibox suggestions had been shown).
     *
     * <p>Note: this updates the internal state of the autocomplete controller just as start() does.
     * Future calls that reference autocomplete results by index, e.g. onSuggestionSelected(),
     * should reference the returned suggestion by index 0.
     *
     * @param text The user's input text to classify (i.e. what they typed in the omnibox)
     * @return The AutocompleteMatch specifying where to navigate, the transition type, etc. May be
     *     null if the input is invalid.
     */
    public AutocompleteMatch classify(@NonNull String text) {
        if (mNativeController == 0) return null;
        return AutocompleteControllerJni.get().classify(mNativeController, text);
    }

    /**
     * Starts a query for suggestions before any input is available from the user.
     *
     * @param omniboxText The text displayed in the omnibox.
     * @param url The url of the currently loaded web page.
     * @param pageClassification The page classification of the current tab.
     * @param title The title of the currently loaded web page.
     */
    public void startZeroSuggest(
            @NonNull String omniboxText,
            @NonNull GURL url,
            int pageClassification,
            @NonNull String title) {
        if (mNativeController == 0) return;

        AutocompleteControllerJni.get()
                .onOmniboxFocused(
                        mNativeController, omniboxText, url.getSpec(), pageClassification, title);
    }

    /**
     * Stops generating autocomplete suggestions for the currently specified text from {@link
     * #start(Profile,String, String, boolean)}.
     *
     * @param clear Whether to clear the most recent autocomplete results. When true, the {@link
     *     #onSuggestionsReceived(AutocompleteResult, String)} will be called with an empty result
     *     set.
     */
    @VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE)
    public void stop(boolean clear) {
        if (mNativeController == 0) return;
        AutocompleteControllerJni.get().stop(mNativeController, clear);
    }

    /**
     * Resets session for autocomplete controller. This happens every time we start typing new input
     * into the omnibox.
     */
    @VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE)
    public void resetSession() {
        if (mNativeController == 0) return;
        AutocompleteControllerJni.get().resetSession(mNativeController);
    }

    private boolean hasValidNativeObjectRef(
            AutocompleteMatch match, @VerificationPoint int reason) {
        // Skip suggestions from cache.
        OmniboxMetrics.recordUsedSuggestionFromCache(match.getNativeObjectRef() == 0L);
        if (match.getNativeObjectRef() == 0L) return false;
        return mAutocompleteResult
                .map(res -> res.verifyCoherency(AutocompleteResult.NO_SUGGESTION_INDEX, reason))
                .orElse(false);
    }

    /**
     * Partially deletes an omnibox suggestion. This call should be used by compound suggestion
     * types (such as carousel) that host multiple components inside (eg. MostVisitedTiles).
     *
     * @param match the match to delete elements of
     * @param elementIndex the element within the match that needs to be deleted
     */
    @VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE)
    public void deleteMatchElement(AutocompleteMatch match, int elementIndex) {
        if (mNativeController == 0) return;
        if (!hasValidNativeObjectRef(match, VerificationPoint.DELETE_MATCH)) return;

        // Skip suggestions from cache.
        if (match.getNativeObjectRef() == 0L) return;
        AutocompleteControllerJni.get()
                .deleteMatchElement(mNativeController, match.getNativeObjectRef(), elementIndex);
    }

    /**
     * Deletes an omnibox suggestion, if possible.
     *
     * @param match the match to delete
     */
    @VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE)
    public void deleteMatch(AutocompleteMatch match) {
        if (mNativeController == 0) return;
        if (!hasValidNativeObjectRef(match, VerificationPoint.DELETE_MATCH)) return;

        // Skip suggestions from cache.
        if (match.getNativeObjectRef() == 0L) return;
        AutocompleteControllerJni.get().deleteMatch(mNativeController, match.getNativeObjectRef());
    }

    @CalledByNative
    @VisibleForTesting
    public void onSuggestionsReceived(
            @NonNull AutocompleteResult autocompleteResult, boolean isFinal) {
        mAutocompleteResult = Optional.of(autocompleteResult);

        // Notify callbacks of suggestions.
        for (OnSuggestionsReceivedListener listener : mListeners) {
            listener.onSuggestionsReceived(autocompleteResult, isFinal);
        }
    }

    @CalledByNative
    private void notifyNativeDestroyed() {
        mNativeController = 0;
    }

    /**
     * Called whenever a navigation happens from the omnibox to record metrics about the user's
     * interaction with the omnibox.
     *
     * @param match AutocompleteMatch that was selected by the user
     * @param suggestionLine the index of the line the match is presented on
     * @param disposition the window open disposition
     * @param currentPageUrl the URL of the current page
     * @param pageClassification the page classification of the current tab
     * @param elapsedTimeSinceModified the number of ms that passed between the user first modifying
     *     text in the omnibox and selecting a suggestion
     * @param completedLength the length of the default match's inline autocompletion if any
     * @param webContents the web contents for the tab where the selected suggestion will be shown
     */
    @VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE)
    public void onSuggestionSelected(
            AutocompleteMatch match,
            int suggestionLine,
            int disposition,
            @NonNull GURL currentPageUrl,
            int pageClassification,
            long elapsedTimeSinceModified,
            int completedLength,
            @Nullable WebContents webContents) {
        if (mNativeController == 0) return;
        if (!hasValidNativeObjectRef(match, VerificationPoint.SELECT_MATCH)) return;

        AutocompleteControllerJni.get()
                .onSuggestionSelected(
                        mNativeController,
                        match.getNativeObjectRef(),
                        suggestionLine,
                        disposition,
                        currentPageUrl.getSpec(),
                        pageClassification,
                        elapsedTimeSinceModified,
                        completedLength,
                        webContents);
    }

    /**
     * Create a native navigation observser on native side.
     *
     * @param navigationHandle The NavigationHandle for the current navigation.
     * @param match AutocompleteMatch that was selected by the user
     */
    void createNavigationObserver(NavigationHandle navigationHandle, AutocompleteMatch match) {
        if (mNativeController == 0) return;
        if (!hasValidNativeObjectRef(match, VerificationPoint.SELECT_MATCH)) return;

        AutocompleteControllerJni.get()
                .createNavigationObserver(
                        mNativeController,
                        navigationHandle.nativeNavigationHandlePtr(),
                        match.getNativeObjectRef());
    }

    /**
     * Called when the user touches down on a suggestion. Only called for search suggestions.
     *
     * @param match the match that received the touch
     * @param matchIndex the vertical position at which the match is located
     * @param webContents the web contents for the tab where suggestion could be used
     * @return whether or not a prefetch was started
     */
    public boolean onSuggestionTouchDown(
            AutocompleteMatch match, int matchIndex, @Nullable WebContents webContents) {
        if (mNativeController == 0) return false;
        if (!hasValidNativeObjectRef(match, VerificationPoint.ON_TOUCH_MATCH)) return false;

        return AutocompleteControllerJni.get()
                .onSuggestionTouchDown(
                        mNativeController, match.getNativeObjectRef(), matchIndex, webContents);
    }

    /**
     * Pass the voice provider a list representing the results of a voice recognition.
     *
     * @param results A list containing the results of a voice recognition.
     */
    void onVoiceResults(@Nullable List<VoiceResult> results) {
        if (mNativeController == 0) return;
        if (results == null || results.size() == 0) return;
        final int count = Math.min(results.size(), MAX_VOICE_SUGGESTION_COUNT);
        String[] voiceMatches = new String[count];
        float[] confidenceScores = new float[count];
        for (int i = 0; i < count; i++) {
            voiceMatches[i] = results.get(i).getMatch();
            confidenceScores[i] = results.get(i).getConfidence();
        }
        AutocompleteControllerJni.get()
                .setVoiceMatches(mNativeController, voiceMatches, confidenceScores);
    }

    /**
     * Updates searchbox stats parameters on the selected match that we will navigate to and
     * returns the updated URL.
     *
     * @param match the AutocompleteMatch object to get the updated destination URL for
     * @param elapsedTimeSinceInputChange the number of ms between the time the user started typing
     *     in the omnibox and the time the user has selected a suggestion
     */
    @Nullable
    GURL updateMatchDestinationUrlWithQueryFormulationTime(
            AutocompleteMatch match, long elapsedTimeSinceInputChange) {
        if (mNativeController == 0) return null;
        if (!hasValidNativeObjectRef(match, VerificationPoint.UPDATE_MATCH)) return null;

        return AutocompleteControllerJni.get()
                .updateMatchDestinationURLWithAdditionalSearchboxStats(
                        mNativeController, match.getNativeObjectRef(), elapsedTimeSinceInputChange);
    }

    /**
     * Returns the final url for navigating to the SRP for the given answer action. The returned URL
     * is augmented with the final searchbox stats.
     */
    @Nullable
    GURL getAnswerActionDestinationURL(
            AutocompleteMatch match,
            long elapsedTimeSinceInputChange,
            OmniboxAnswerAction answerAction) {
        if (mNativeController == 0) return null;
        assert hasValidNativeObjectRef(match, VerificationPoint.UPDATE_MATCH);
        if (!hasValidNativeObjectRef(match, VerificationPoint.UPDATE_MATCH)) return null;

        return AutocompleteControllerJni.get()
                .getAnswerActionDestinationURL(
                        mNativeController,
                        match.getNativeObjectRef(),
                        elapsedTimeSinceInputChange,
                        answerAction.getNativeInstance());
    }

    /**
     * Retrieves matching tab for suggestion at specific index.
     *
     * @param match the AutocompleteMatch to retrieve Tab info for
     * @return tab that hosts matching URL
     */
    @Nullable
    Tab getMatchingTabForSuggestion(AutocompleteMatch match) {
        if (mNativeController == 0) return null;
        if (!hasValidNativeObjectRef(match, VerificationPoint.GET_MATCHING_TAB)) return null;
        return AutocompleteControllerJni.get()
                .getMatchingTabForSuggestion(mNativeController, match.getNativeObjectRef());
    }

    /**
     * Pass the UI specific measurement information to Native code to aid Adaptive Suggestions.
     *
     * @param dropdownHeightWithKeyboardActive the height of visible part of the suggestions
     *     dropdown with software keyboard showing, expressed in pixels
     * @param suggestionHeight the nominal height of a suggestion, expressed in pixels
     */
    void onSuggestionDropdownHeightChanged(
            @Px int dropdownHeightWithKeyboardActive, @Px int suggestionHeight) {
        if (mNativeController == 0) return;
        AutocompleteControllerJni.get()
                .onSuggestionDropdownHeightChanged(
                        mNativeController, dropdownHeightWithKeyboardActive, suggestionHeight);
    }

    /**
     * Acquire an instance of AutocompleteController associated with the supplied Profile.
     *
     * @param profile The profile to get the AutocompleteController for.
     * @return An existing (if one is available) or new (otherwise) instance of the
     *     AutocompleteController associated with the supplied profile.
     */
    public static Optional<AutocompleteController> getForProfile(Profile profile) {
        return Optional.ofNullable(
                profile == null ? null : AutocompleteControllerJni.get().getForProfile(profile));
    }

    @VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE)
    @NativeMethods
    public interface Natives {
        void start(
                long nativeAutocompleteControllerAndroid,
                String text,
                int cursorPosition,
                String desiredTld,
                String currentUrl,
                int pageClassification,
                boolean preventInlineAutocomplete,
                boolean preferKeyword,
                boolean allowExactKeywordMatch,
                boolean wantAsynchronousMatches);

        AutocompleteMatch classify(long nativeAutocompleteControllerAndroid, String text);

        void stop(long nativeAutocompleteControllerAndroid, boolean clearResults);

        void resetSession(long nativeAutocompleteControllerAndroid);

        void onSuggestionSelected(
                long nativeAutocompleteControllerAndroid,
                long nativeAutocompleteMatch,
                int matchIndex,
                int disposition,
                String currentPageUrl,
                int pageClassification,
                long elapsedTimeSinceModified,
                int completedLength,
                WebContents webContents);

        boolean onSuggestionTouchDown(
                long nativeAutocompleteControllerAndroid,
                long nativeAutocompleteMatch,
                int matchIndex,
                WebContents webContents);

        void onOmniboxFocused(
                long nativeAutocompleteControllerAndroid,
                String omniboxText,
                String currentUrl,
                int pageClassification,
                String currentTitle);

        void deleteMatchElement(
                long nativeAutocompleteControllerAndroid,
                long nativeAutocompleteMatch,
                int elementIndex);

        void deleteMatch(long nativeAutocompleteControllerAndroid, long nativeAutocompleteMatch);

        GURL updateMatchDestinationURLWithAdditionalSearchboxStats(
                long nativeAutocompleteControllerAndroid,
                long nativeAutocompleteMatch,
                long elapsedTimeSinceInputChange);

        GURL getAnswerActionDestinationURL(
                long nativeAutocompleteControllerAndroid,
                long nativeAutocompleteMatch,
                long elapsedTimeSinceInputChange,
                long nativeAnswerAction);

        Tab getMatchingTabForSuggestion(
                long nativeAutocompleteControllerAndroid, long nativeAutocompleteMatch);

        void setVoiceMatches(
                long nativeAutocompleteControllerAndroid,
                String[] matches,
                float[] confidenceScores);

        // Sends a zero suggest request to the server in order to pre-populate the result cache.
        void startPrefetch(
                long nativeAutocompleteControllerAndroid,
                String currentUrl,
                int pageClassification);

        // Create a navigation observser.
        void createNavigationObserver(
                long nativeAutocompleteControllerAndroid,
                long mNativeNavigationHandle,
                long nativeAutocompleteMatch);

        // Pass the information about the height of the visible Omnibox Dropdown area and
        // Suggestion Height expressed in Pixels.
        void onSuggestionDropdownHeightChanged(
                long nativeAutocompleteControllerAndroid,
                @Px int dropdownHeightWithKeyboardActive,
                @Px int suggestionHeight);

        /** Acquire an instance of AutocompleteController associated with the supplied profile. */
        AutocompleteController getForProfile(@JniType("Profile*") Profile profile);
    }
}