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

// Copyright 2019 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.base;

import android.content.Context;
import android.graphics.Typeface;
import android.text.Spannable;
import android.text.style.StyleSpan;

import androidx.annotation.CallSuper;
import androidx.annotation.DrawableRes;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;

import org.chromium.base.metrics.RecordUserAction;
import org.chromium.base.metrics.TimingMetric;
import org.chromium.chrome.browser.omnibox.MatchClassificationStyle;
import org.chromium.chrome.browser.omnibox.OmniboxMetrics;
import org.chromium.chrome.browser.omnibox.R;
import org.chromium.chrome.browser.omnibox.styles.OmniboxDrawableState;
import org.chromium.chrome.browser.omnibox.styles.OmniboxImageSupplier;
import org.chromium.chrome.browser.omnibox.styles.OmniboxResourceProvider;
import org.chromium.chrome.browser.omnibox.suggestions.SuggestionHost;
import org.chromium.chrome.browser.omnibox.suggestions.SuggestionProcessor;
import org.chromium.chrome.browser.omnibox.suggestions.base.BaseSuggestionViewProperties.Action;
import org.chromium.components.omnibox.AutocompleteMatch;
import org.chromium.components.omnibox.AutocompleteMatch.MatchClassification;
import org.chromium.components.omnibox.OmniboxFeatures;
import org.chromium.ui.modelutil.PropertyModel;
import org.chromium.url.GURL;

import java.util.List;
import java.util.Optional;

/** A class that handles base properties and model for most suggestions. */
public abstract class BaseSuggestionViewProcessor implements SuggestionProcessor {
    protected final @NonNull Context mContext;
    protected final @NonNull SuggestionHost mSuggestionHost;
    private final @NonNull ActionChipsProcessor mActionChipsProcessor;
    private final @NonNull Optional<OmniboxImageSupplier> mImageSupplier;
    private final int mDesiredFaviconWidthPx;
    private final int mDecorationImageSizePx;
    private final int mSuggestionSizePx;

    /**
     * @param context Current context.
     * @param host A handle to the object using the suggestions.
     * @param imageSupplier A mechanism to use to retrieve favicons.
     */
    public BaseSuggestionViewProcessor(
            @NonNull Context context,
            @NonNull SuggestionHost host,
            @NonNull Optional<OmniboxImageSupplier> imageSupplier) {
        mContext = context;
        mSuggestionHost = host;
        mImageSupplier = imageSupplier;
        mDesiredFaviconWidthPx =
                mContext.getResources()
                        .getDimensionPixelSize(R.dimen.omnibox_suggestion_favicon_size);
        mDecorationImageSizePx =
                mContext.getResources()
                        .getDimensionPixelSize(R.dimen.omnibox_suggestion_decoration_image_size);
        mSuggestionSizePx =
                mContext.getResources()
                        .getDimensionPixelSize(R.dimen.omnibox_suggestion_content_height);
        mActionChipsProcessor = new ActionChipsProcessor(host);
    }

    /**
     * @return The desired size of Omnibox suggestion favicon.
     */
    protected int getDesiredFaviconSize() {
        return mDesiredFaviconWidthPx;
    }

    /**
     * @return The size of suggestion decoration images in pixels.
     */
    protected int getDecorationImageSize() {
        return mDecorationImageSizePx;
    }

    /** Return whether this suggestion can host OmniboxAction chips. */
    protected boolean allowOmniboxActions() {
        return true;
    }

    @Override
    public int getMinimumViewHeight() {
        return mSuggestionSizePx;
    }

    /**
     * Retrieve fallback icon for a given suggestion. Must be completed synchromously.
     *
     * @param suggestion AutocompleteMatch instance to retrieve fallback icon for
     * @return OmniboxDrawableState that can be immediately applied to suggestion view
     */
    protected @NonNull OmniboxDrawableState getFallbackIcon(@NonNull AutocompleteMatch match) {
        int icon =
                match.isSearchSuggestion()
                        ? R.drawable.ic_suggestion_magnifier
                        : R.drawable.ic_globe_24dp;
        return OmniboxDrawableState.forSmallIcon(mContext, icon, true);
    }

    /**
     * Specify OmniboxDrawableState for suggestion decoration.
     *
     * @param model the PropertyModel to apply the decoration to
     * @param decoration the OmniboxDrawableState to apply
     */
    protected void setOmniboxDrawableState(
            @NonNull PropertyModel model, @NonNull OmniboxDrawableState decoration) {
        model.set(BaseSuggestionViewProperties.ICON, decoration);
    }

    /**
     * Specify OmniboxDrawableState for action button.
     *
     * @param model Property model to update.
     * @param actions List of actions for the suggestion.
     */
    protected void setActionButtons(@NonNull PropertyModel model, @Nullable List<Action> actions) {
        model.set(BaseSuggestionViewProperties.ACTION_BUTTONS, actions);
    }

    /**
     * Setup action icon base on the suggestion, either show query build arrow or switch to tab.
     *
     * @param model Property model to update.
     * @param suggestion Suggestion associated with the action button.
     * @param position The position of the button in the list.
     */
    @VisibleForTesting(otherwise = VisibleForTesting.PROTECTED)
    public void setTabSwitchOrRefineAction(
            @NonNull PropertyModel model, @NonNull AutocompleteMatch suggestion, int position) {
        @DrawableRes int icon;
        String iconString;
        Runnable action;
        if (suggestion.hasTabMatch()) {
            icon = R.drawable.switch_to_tab;
            iconString =
                    OmniboxResourceProvider.getString(
                            mContext, R.string.accessibility_omnibox_switch_to_tab);
            action = () -> mSuggestionHost.onSwitchToTab(suggestion, position);
        } else {
            iconString =
                    OmniboxResourceProvider.getString(
                            mContext,
                            R.string.accessibility_omnibox_btn_refine,
                            suggestion.getFillIntoEdit());
            icon = R.drawable.btn_suggestion_refine;
            action =
                    () -> {
                        if (suggestion.isSearchSuggestion()) {
                            RecordUserAction.record("MobileOmniboxRefineSuggestion.Search");
                        } else {
                            RecordUserAction.record("MobileOmniboxRefineSuggestion.Url");
                        }
                        mSuggestionHost.onRefineSuggestion(suggestion);
                    };
        }
        setActionButtons(
                model,
                List.of(
                        new Action(
                                OmniboxDrawableState.forSmallIcon(mContext, icon, true),
                                iconString,
                                action)));
    }

    /**
     * Process the click event.
     *
     * @param suggestion Selected suggestion.
     * @param position Position of the suggestion on the list.
     */
    protected void onSuggestionClicked(@NonNull AutocompleteMatch suggestion, int position) {
        mSuggestionHost.onSuggestionClicked(suggestion, position, suggestion.getUrl());
    }

    /**
     * Process the long-click event.
     *
     * @param suggestion Selected suggestion.
     */
    protected void onSuggestionLongClicked(@NonNull AutocompleteMatch suggestion) {
        mSuggestionHost.onDeleteMatch(suggestion, suggestion.getDisplayText());
    }

    /**
     * Process the touch down event. Only handles search suggestions.
     *
     * @param suggestion Selected suggestion.
     * @param position Position of the suggesiton on the list.
     */
    protected void onSuggestionTouchDownEvent(@NonNull AutocompleteMatch suggestion, int position) {
        try (TimingMetric metric = OmniboxMetrics.recordTouchDownProcessTime()) {
            mSuggestionHost.onSuggestionTouchDown(suggestion, position);
        }
    }

    @Override
    public void populateModel(
            @NonNull AutocompleteMatch suggestion, @NonNull PropertyModel model, int position) {
        model.set(
                BaseSuggestionViewProperties.ON_CLICK,
                () -> onSuggestionClicked(suggestion, position));
        model.set(
                BaseSuggestionViewProperties.ON_LONG_CLICK,
                () -> onSuggestionLongClicked(suggestion));
        model.set(
                BaseSuggestionViewProperties.ON_FOCUS_VIA_SELECTION,
                () -> mSuggestionHost.setOmniboxEditingText(suggestion.getFillIntoEdit()));
        setActionButtons(model, null);

        model.set(BaseSuggestionViewProperties.USE_LARGE_DECORATION, false);
        model.set(BaseSuggestionViewProperties.SHOW_DECORATION, true);
        model.set(
                BaseSuggestionViewProperties.ACTION_CHIP_LEAD_IN_SPACING,
                OmniboxResourceProvider.getSuggestionDecorationIconSizeWidth(mContext));
        model.set(BaseSuggestionViewProperties.TOP_PADDING, 0);

        if (OmniboxFeatures.isTouchDownTriggerForPrefetchEnabled()
                && !OmniboxFeatures.isLowMemoryDevice()
                && suggestion.isSearchSuggestion()) {
            model.set(
                    BaseSuggestionViewProperties.ON_TOUCH_DOWN_EVENT,
                    () -> onSuggestionTouchDownEvent(suggestion, position));
        }

        if (allowOmniboxActions()) {
            mActionChipsProcessor.populateModel(suggestion, model, position);
        }

        var icon = getFallbackIcon(suggestion);
        assert icon != null;
        setOmniboxDrawableState(model, icon);
        if (suggestion.isSearchSuggestion()) {
            fetchImage(model, suggestion.getImageUrl());
        }
    }

    @Override
    @CallSuper
    public void onOmniboxSessionStateChange(boolean activated) {
        mActionChipsProcessor.onOmniboxSessionStateChange(activated);
    }

    @Override
    @CallSuper
    public void onSuggestionsReceived() {
        mActionChipsProcessor.onSuggestionsReceived();
    }

    /**
     * Apply In-Place highlight to matching sections of Suggestion text.
     *
     * @param text Suggestion text to apply highlight to.
     * @param classifications Classifications describing how to format text.
     * @return true, if at least one highlighted match section was found.
     */
    protected static boolean applyHighlightToMatchRegions(
            Spannable text, List<MatchClassification> classifications) {
        if (text == null || classifications == null) return false;

        boolean hasAtLeastOneMatch = false;
        for (int i = 0; i < classifications.size(); i++) {
            MatchClassification classification = classifications.get(i);
            if ((classification.style & MatchClassificationStyle.MATCH)
                    == MatchClassificationStyle.MATCH) {
                int matchStartIndex = classification.offset;
                int matchEndIndex;
                if (i == classifications.size() - 1) {
                    matchEndIndex = text.length();
                } else {
                    matchEndIndex = classifications.get(i + 1).offset;
                }
                matchStartIndex = Math.min(matchStartIndex, text.length());
                matchEndIndex = Math.min(matchEndIndex, text.length());

                hasAtLeastOneMatch = true;
                // Bold the part of the URL that matches the user query.
                text.setSpan(
                        new StyleSpan(Typeface.BOLD),
                        matchStartIndex,
                        matchEndIndex,
                        Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
            }
        }
        return hasAtLeastOneMatch;
    }

    /**
     * Fetch suggestion favicon, if one is available. Updates icon decoration in supplied |model| if
     * |url| is not null and points to an already visited website.
     *
     * @param model Model representing current suggestion.
     * @param url Target URL the suggestion points to.
     */
    protected void fetchSuggestionFavicon(@NonNull PropertyModel model, @NonNull GURL url) {
        mImageSupplier.ifPresent(
                s ->
                        s.fetchFavicon(
                                url,
                                icon -> {
                                    if (icon != null) {
                                        setOmniboxDrawableState(
                                                model,
                                                OmniboxDrawableState.forFavIcon(mContext, icon));
                                    }
                                }));
    }

    /**
     * Fetch suggestion image. Updates icon decoration in supplied |model| if |imageUrl| is valid,
     * points to an image, and was successfully retrieved and decompressed.
     *
     * @param model the PropertyModel to update with retrieved image
     * @param imageUrl the URL of the image to retrieve and decode
     */
    protected void fetchImage(@NonNull PropertyModel model, @NonNull GURL imageUrl) {
        mImageSupplier.ifPresent(
                s ->
                        s.fetchImage(
                                imageUrl,
                                bitmap -> {
                                    if (bitmap != null) {
                                        setOmniboxDrawableState(
                                                model,
                                                OmniboxDrawableState.forImage(mContext, bitmap));
                                    }
                                }));
    }
}