chromium/chrome/browser/search_resumption/java/src/org/chromium/chrome/browser/search_resumption/SearchResumptionModuleMediator.java

// Copyright 2022 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.search_resumption;

import android.view.ViewStub;

import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;

import org.chromium.base.metrics.RecordUserAction;
import org.chromium.chrome.browser.flags.ChromeFeatureList;
import org.chromium.chrome.browser.omnibox.suggestions.AutocompleteController;
import org.chromium.chrome.browser.omnibox.suggestions.AutocompleteController.OnSuggestionsReceivedListener;
import org.chromium.chrome.browser.preferences.ChromePreferenceKeys;
import org.chromium.chrome.browser.preferences.ChromeSharedPreferences;
import org.chromium.chrome.browser.profiles.Profile;
import org.chromium.chrome.browser.search_engines.TemplateUrlServiceFactory;
import org.chromium.chrome.browser.search_resumption.SearchResumptionModuleUtils.ModuleNotShownReason;
import org.chromium.chrome.browser.search_resumption.SearchResumptionUserData.SuggestionResult;
import org.chromium.chrome.browser.signin.services.IdentityServicesProvider;
import org.chromium.chrome.browser.signin.services.SigninManager;
import org.chromium.chrome.browser.signin.services.SigninManager.SignInStateObserver;
import org.chromium.chrome.browser.sync.SyncServiceFactory;
import org.chromium.chrome.browser.tab.Tab;
import org.chromium.components.metrics.OmniboxEventProtos.OmniboxEventProto.PageClassification;
import org.chromium.components.omnibox.AutocompleteMatch;
import org.chromium.components.omnibox.AutocompleteResult;
import org.chromium.components.search_engines.TemplateUrlService;
import org.chromium.components.search_engines.TemplateUrlService.TemplateUrlServiceObserver;
import org.chromium.components.sync.SyncService;
import org.chromium.ui.modelutil.PropertyModel;
import org.chromium.ui.modelutil.PropertyModelChangeProcessor;
import org.chromium.url.GURL;

import java.util.List;

/** This class holds querying search suggestions related business logic. */
public class SearchResumptionModuleMediator
        implements OnSuggestionsReceivedListener,
                SignInStateObserver,
                SyncService.SyncStateChangedListener {
    private final ViewStub mStub;
    private final Tab mTabToTrackSuggestion;
    private final Tab mCurrentTab;
    private final SearchResumptionTileBuilder mTileBuilder;
    private final SigninManager mSignInManager;
    private final SyncService mSyncService;
    private final TemplateUrlService mTemplateUrlService;
    private final TemplateUrlServiceObserver mTemplateUrlServiceObserver;
    private AutocompleteController mAutoComplete;
    private PropertyModel mModel;
    // Set the default values of these variable true since all of them have been checked before
    // creating the coordinator in SearchResumptionModuleUtils#shouldShowSearchResumptionModule.
    private boolean mIsDefaultSearchEngineGoogle = true;
    private boolean mIsSignedIn = true;
    private boolean mHasKeepEverythingSynced = true;
    private boolean mUseNewServiceEnabled;

    private @Nullable SearchResumptionModuleView mModuleLayoutView;
    private @Nullable SearchResumptionModuleBridge mSearchResumptionModuleBridge;

    SearchResumptionModuleMediator(
            ViewStub moduleStub,
            Tab tabToTrack,
            Tab currentTab,
            Profile profile,
            SearchResumptionTileBuilder tileBuilder,
            SuggestionResult cachedSuggestions) {
        mStub = moduleStub;
        mTabToTrackSuggestion = tabToTrack;
        mCurrentTab = currentTab;
        mTileBuilder = tileBuilder;
        mUseNewServiceEnabled =
                ChromeFeatureList.getFieldTrialParamByFeatureAsBoolean(
                        ChromeFeatureList.SEARCH_RESUMPTION_MODULE_ANDROID,
                        SearchResumptionModuleUtils.USE_NEW_SERVICE_PARAM,
                        false);
        mTemplateUrlService = TemplateUrlServiceFactory.getForProfile(profile);
        mTemplateUrlServiceObserver = this::onTemplateURLServiceChanged;
        mTemplateUrlService.addObserver(mTemplateUrlServiceObserver);

        if (cachedSuggestions != null) {
            showCachedSuggestions(cachedSuggestions);
        } else {
            start(profile);
        }
        mSignInManager = IdentityServicesProvider.get().getSigninManager(profile);
        mSignInManager.addSignInStateObserver(this);
        mSyncService = SyncServiceFactory.getForProfile(profile);
        mSyncService.addSyncStateChangedListener(this);
    }

    @Override
    public void onSuggestionsReceived(AutocompleteResult autocompleteResult, boolean isFinal) {
        if (!isFinal || mModel != null) return;

        if (!shouldShowSuggestionModule(autocompleteResult.getSuggestionsList())) {
            SearchResumptionModuleUtils.recordModuleNotShownReason(
                    ModuleNotShownReason.NOT_ENOUGH_RESULT);
            return;
        }

        showSearchSuggestionModule(
                autocompleteResult.getSuggestionsList(), /* useCachedResults= */ false);
    }

    /** SyncService.SyncStateChangedListener implementation, listens to sync state changes. */
    @Override
    public void syncStateChanged() {
        mHasKeepEverythingSynced = mSyncService.hasKeepEverythingSynced();
        updateVisibility();
    }

    @Override
    public void onSignedIn() {
        mIsSignedIn = true;
        updateVisibility();
    }

    @Override
    public void onSignedOut() {
        mIsSignedIn = false;
        updateVisibility();
    }

    /**
     * Called when the search suggestions are available using the new service API.
     * @param suggestionTexts The display texts of the suggestions.
     * @param suggestionUrls The URLs of the suggestions.
     */
    void onSuggestionsAvailable(String[] suggestionTexts, GURL[] suggestionUrls) {
        if (mModel != null) return;

        if (!shouldShowSuggestionModule(suggestionUrls, suggestionTexts)) {
            SearchResumptionModuleUtils.recordModuleNotShownReason(
                    ModuleNotShownReason.NOT_ENOUGH_RESULT);
            return;
        }

        showSearchSuggestionModule(suggestionTexts, suggestionUrls, /* useCachedResults= */ false);
    }

    /**
     * Inflates the search_resumption_layout and shows the suggestions on the module.
     * @param autocompleteMatches The suggestions to show on the module.
     */
    void showSearchSuggestionModule(
            List<AutocompleteMatch> autocompleteMatches, boolean useCachedResults) {
        if (!initializeModule()) return;

        mTileBuilder.buildSuggestionTile(
                autocompleteMatches,
                mModuleLayoutView.findViewById(R.id.search_resumption_module_tiles_container));
        SearchResumptionModuleUtils.recordModuleShown(useCachedResults);
        if (!useCachedResults) {
            SearchResumptionUserData.getInstance()
                    .cacheSuggestions(
                            mCurrentTab, mTabToTrackSuggestion.getUrl(), autocompleteMatches);
        }
    }

    /** Inflates the search_resumption_layout and shows the suggestions on the module. */
    void showSearchSuggestionModule(String[] texts, GURL[] urls, boolean useCachedResults) {
        if (!initializeModule()) return;

        mTileBuilder.buildSuggestionTile(
                texts,
                urls,
                mModuleLayoutView.findViewById(R.id.search_resumption_module_tiles_container));
        SearchResumptionModuleUtils.recordModuleShown(useCachedResults);
        if (!useCachedResults) {
            SearchResumptionUserData.getInstance()
                    .cacheSuggestions(mCurrentTab, mTabToTrackSuggestion.getUrl(), texts, urls);
        }
    }

    void destroy() {
        if (mAutoComplete != null) {
            mAutoComplete.removeOnSuggestionsReceivedListener(this);
        }
        if (mModuleLayoutView != null) {
            mModuleLayoutView.destroy();
        }
        if (mSearchResumptionModuleBridge != null) {
            mSearchResumptionModuleBridge.destroy();
        }
        mTemplateUrlService.removeObserver(mTemplateUrlServiceObserver);
        mSignInManager.removeSignInStateObserver(this);
        mSyncService.removeSyncStateChangedListener(this);
    }

    /** Starts the querying the search suggestions based on the Tab to track. */
    private void start(Profile profile) {
        if (!mUseNewServiceEnabled) {
            AutocompleteController.getForProfile(profile)
                    .ifPresent(
                            controller -> {
                                mAutoComplete = controller;
                                mAutoComplete.addOnSuggestionsReceivedListener(this);
                                int pageClassification = getPageClassification();
                                mAutoComplete.startZeroSuggest(
                                        "",
                                        mTabToTrackSuggestion.getUrl(),
                                        pageClassification,
                                        mTabToTrackSuggestion.getTitle());
                            });
        } else {
            mSearchResumptionModuleBridge = new SearchResumptionModuleBridge(profile);
            mSearchResumptionModuleBridge.fetchSuggestions(
                    mTabToTrackSuggestion.getUrl().getSpec(), this::onSuggestionsAvailable);
        }
    }

    private void showCachedSuggestions(SuggestionResult cachedSuggestions) {
        if (mUseNewServiceEnabled) {
            showSearchSuggestionModule(
                    cachedSuggestions.getSuggestionTexts(),
                    cachedSuggestions.getSuggestionUrls(),
                    /* useCachedResults= */ true);
        } else {
            showSearchSuggestionModule(
                    cachedSuggestions.getSuggestions(), /* useCachedResults= */ true);
        }
    }

    /**
     * Gets the page classification based on whether the tracking Tab is a search results page or
     * not.
     */
    private int getPageClassification() {
        if (mTemplateUrlService.isSearchResultsPageFromDefaultSearchProvider(
                mTabToTrackSuggestion.getUrl())) {
            return PageClassification.SEARCH_RESULT_PAGE_NO_SEARCH_TERM_REPLACEMENT_VALUE;
        } else {
            return PageClassification.OTHER_VALUE;
        }
    }

    /**
     * Returns whether to show the search resumption module. Only showing the module if at least
     * {@link SearchResumptionTileBuilder#MAX_TILES_NUMBER} -1 suggestions are given.
     */
    private boolean shouldShowSuggestionModule(List<AutocompleteMatch> suggestions) {
        if (suggestions.size() < SearchResumptionTileBuilder.MAX_TILES_NUMBER - 1) return false;

        int count = 0;
        for (AutocompleteMatch suggestion : suggestions) {
            if (SearchResumptionTileBuilder.isSearchSuggestion(suggestion)) {
                count++;
            }
            if (count >= SearchResumptionTileBuilder.MAX_TILES_NUMBER - 1) {
                return true;
            }
        }
        return false;
    }

    /**
     * Returns whether to show the search resumption module. Only showing the module if at least
     * {@link SearchResumptionTileBuilder#MAX_TILES_NUMBER} -1 suggestions are given.
     */
    private boolean shouldShowSuggestionModule(GURL[] urls, String[] texts) {
        if (urls.length != texts.length
                || urls.length < SearchResumptionTileBuilder.MAX_TILES_NUMBER - 1) {
            return false;
        }

        int count = 0;
        for (int i = 0; i < urls.length; i++) {
            if (SearchResumptionTileBuilder.isSuggestionValid(texts[i])) {
                count++;
            }
            if (count >= SearchResumptionTileBuilder.MAX_TILES_NUMBER - 1) {
                return true;
            }
        }
        return false;
    }

    /**
     * Inflates the module and initializes the property model.
     * @return Whether the module is inflated.
     */
    private boolean initializeModule() {
        if (mModel != null) return false;

        mModuleLayoutView = (SearchResumptionModuleView) mStub.inflate();
        mModel = new PropertyModel(SearchResumptionModuleProperties.ALL_KEYS);
        PropertyModelChangeProcessor.create(
                mModel, mModuleLayoutView, new SearchResumptionModuleViewBinder());
        mModel.set(
                SearchResumptionModuleProperties.EXPAND_COLLAPSE_CLICK_CALLBACK,
                this::onExpandedOrCollapsed);
        return true;
    }

    private void updateVisibility() {
        if (mModel != null) {
            mModel.set(
                    SearchResumptionModuleProperties.IS_VISIBLE,
                    mIsDefaultSearchEngineGoogle && mIsSignedIn && mHasKeepEverythingSynced);
        }
    }

    /**
     * Saves the user's choice of expanding/collapsing of the suggestions in the SharedPreference.
     */
    @VisibleForTesting
    void onExpandedOrCollapsed(Boolean expanded) {
        ChromeSharedPreferences.getInstance()
                .writeBoolean(
                        ChromePreferenceKeys.SEARCH_RESUMPTION_MODULE_COLLAPSE_ON_NTP, !expanded);
        RecordUserAction.record(
                expanded
                        ? SearchResumptionModuleUtils.ACTION_EXPAND
                        : SearchResumptionModuleUtils.ACTION_COLLAPSE);
    }

    @VisibleForTesting
    void onTemplateURLServiceChanged() {
        mIsDefaultSearchEngineGoogle = mTemplateUrlService.isDefaultSearchEngineGoogle();
        updateVisibility();
    }
}