chromium/chrome/browser/ui/android/searchactivityutils/java/src/org/chromium/chrome/browser/ui/searchactivityutils/SearchActivityPreferencesManager.java

// Copyright 2021 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.ui.searchactivityutils;

import static org.chromium.chrome.browser.preferences.ChromePreferenceKeys.SEARCH_WIDGET_IS_GOOGLE_LENS_AVAILABLE;
import static org.chromium.chrome.browser.preferences.ChromePreferenceKeys.SEARCH_WIDGET_IS_INCOGNITO_AVAILABLE;
import static org.chromium.chrome.browser.preferences.ChromePreferenceKeys.SEARCH_WIDGET_IS_VOICE_SEARCH_AVAILABLE;
import static org.chromium.chrome.browser.preferences.ChromePreferenceKeys.SEARCH_WIDGET_SEARCH_ENGINE_SHORTNAME;
import static org.chromium.chrome.browser.preferences.ChromePreferenceKeys.SEARCH_WIDGET_SEARCH_ENGINE_URL;

import android.content.Context;
import android.text.TextUtils;

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

import org.chromium.base.ObserverList;
import org.chromium.base.ThreadUtils;
import org.chromium.base.shared_preferences.SharedPreferencesManager;
import org.chromium.base.task.PostTask;
import org.chromium.base.task.TaskTraits;
import org.chromium.chrome.browser.incognito.IncognitoUtils;
import org.chromium.chrome.browser.lens.LensController;
import org.chromium.chrome.browser.lens.LensEntryPoint;
import org.chromium.chrome.browser.lens.LensQueryParams;
import org.chromium.chrome.browser.omnibox.voice.VoiceRecognitionUtil;
import org.chromium.chrome.browser.preferences.ChromeSharedPreferences;
import org.chromium.chrome.browser.profiles.ProfileManager;
import org.chromium.chrome.browser.search_engines.TemplateUrlServiceFactory;
import org.chromium.components.search_engines.TemplateUrl;
import org.chromium.components.search_engines.TemplateUrlService;
import org.chromium.components.search_engines.TemplateUrlService.LoadListener;
import org.chromium.components.search_engines.TemplateUrlService.TemplateUrlServiceObserver;
import org.chromium.ui.base.DeviceFormFactor;
import org.chromium.ui.permissions.AndroidPermissionDelegate;
import org.chromium.url.GURL;

import java.util.Arrays;
import java.util.function.Consumer;

/** Facilitates access to and updates of the cached SearchActivityPreferences. */
public class SearchActivityPreferencesManager implements LoadListener, TemplateUrlServiceObserver {
    /** Data-only class representiing current SearchActivity preferences. */
    public static final class SearchActivityPreferences {
        /** Name of the Default Search Engine. */
        public final @Nullable String searchEngineName;

        /** URL of the Default Search Engine. */
        public final @NonNull GURL searchEngineUrl;

        /** Whether Voice Search functionality is available. */
        public final boolean voiceSearchAvailable;

        /** Whether Google Lens functionality is available. */
        public final boolean googleLensAvailable;

        /** Whether Incognito browsing functionality is available. */
        public final boolean incognitoAvailable;

        @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
        public SearchActivityPreferences(
                @Nullable String searchEngineName,
                @Nullable GURL searchEngineUrl,
                boolean voiceSearchAvailable,
                boolean googleLensAvailable,
                boolean incognitoAvailable) {
            this.searchEngineName = searchEngineName;
            this.searchEngineUrl = searchEngineUrl != null ? searchEngineUrl : GURL.emptyGURL();
            this.voiceSearchAvailable = voiceSearchAvailable;
            this.googleLensAvailable = googleLensAvailable;
            this.incognitoAvailable = incognitoAvailable;
        }

        @Override
        public boolean equals(Object otherObj) {
            if (otherObj == this) return true;
            if (!(otherObj instanceof SearchActivityPreferences)) return false;

            SearchActivityPreferences other = (SearchActivityPreferences) otherObj;
            return voiceSearchAvailable == other.voiceSearchAvailable
                    && googleLensAvailable == other.googleLensAvailable
                    && incognitoAvailable == other.incognitoAvailable
                    && TextUtils.equals(searchEngineName, other.searchEngineName)
                    && searchEngineUrl.equals(other.searchEngineUrl);
        }

        @Override
        public int hashCode() {
            return Arrays.hashCode(
                    new Object[] {
                        searchEngineName,
                        searchEngineUrl,
                        voiceSearchAvailable,
                        googleLensAvailable,
                        incognitoAvailable
                    });
        }
    }

    /** The default/fallback value describing Voice Search availability. */
    private static final boolean DEFAULT_VOICE_SEARCH_AVAILABILITY = true;

    /** The default/fallback value describing Gooogle Lens availability. */
    private static final boolean DEFAULT_GOOGLE_LENS_AVAILABILITY = false;

    /** The default/fallback value describing Incognito browsing availability. */
    private static final boolean DEFAULT_INCOGNITO_AVAILABILITY = true;

    private static @Nullable SearchActivityPreferencesManager sInstance;
    private final @NonNull ObserverList<Consumer<SearchActivityPreferences>> mObservers =
            new ObserverList<>();
    private @NonNull SearchActivityPreferences mCurrentlyLoadedPreferences;

    /**
     * Initialize instance of SearchActivityPreferencesManager. Note that the class operates as a
     * singleton, because it may - and will be invoked from multiple independent contexts.
     */
    private SearchActivityPreferencesManager() {}

    /**
     * @return The instance of the SearchActivityPreferencesManager singleton.
     */
    public static SearchActivityPreferencesManager get() {
        ThreadUtils.assertOnUiThread();
        if (sInstance == null) {
            sInstance = new SearchActivityPreferencesManager();
            initializeFromCache();
        }
        return sInstance;
    }

    /** Returns current knowh SharedActivityPreferences values. */
    public static @NonNull SearchActivityPreferences getCurrent() {
        return get().mCurrentlyLoadedPreferences;
    }

    /**
     * Fetch previously cached Search Widget details, if any. When no previous values were found,
     * the code will initialize values to safe defaults.
     *
     * <p>If stored values are different than current values, the update will be propagated to
     * registered listeners.
     */
    private static void initializeFromCache() {
        SharedPreferencesManager manager = ChromeSharedPreferences.getInstance();
        String encodedUrl = manager.readString(SEARCH_WIDGET_SEARCH_ENGINE_URL, null);

        boolean shouldUpdateStorageToSaveSerializedGurl = false;
        GURL url = GURL.emptyGURL();
        if (!TextUtils.isEmpty(encodedUrl)) {
            url = GURL.deserialize(encodedUrl);
            // Deserializing may fail if the URL is not a serialized GURL.
            if (url.isEmpty()) {
                // This will be slow once, as it will attempt to initialize part of native library.
                url = new GURL(encodedUrl);
                shouldUpdateStorageToSaveSerializedGurl = true;
            }
        }

        setCurrentlyLoadedPreferences(
                new SearchActivityPreferences(
                        manager.readString(SEARCH_WIDGET_SEARCH_ENGINE_SHORTNAME, null),
                        url,
                        manager.readBoolean(
                                SEARCH_WIDGET_IS_VOICE_SEARCH_AVAILABLE,
                                DEFAULT_VOICE_SEARCH_AVAILABILITY),
                        manager.readBoolean(
                                SEARCH_WIDGET_IS_GOOGLE_LENS_AVAILABLE,
                                DEFAULT_GOOGLE_LENS_AVAILABILITY),
                        manager.readBoolean(
                                SEARCH_WIDGET_IS_INCOGNITO_AVAILABLE,
                                DEFAULT_INCOGNITO_AVAILABILITY)),
                shouldUpdateStorageToSaveSerializedGurl);
    }

    /**
     * Clear all cached preferences. If reset values are different than current values, the update
     * will be propagated to registered listeners.
     */
    public static void resetCachedValues() {
        SharedPreferencesManager manager = ChromeSharedPreferences.getInstance();
        manager.removeKey(SEARCH_WIDGET_SEARCH_ENGINE_SHORTNAME);
        manager.removeKey(SEARCH_WIDGET_SEARCH_ENGINE_URL);
        manager.removeKey(SEARCH_WIDGET_IS_VOICE_SEARCH_AVAILABLE);
        manager.removeKey(SEARCH_WIDGET_IS_GOOGLE_LENS_AVAILABLE);
        manager.removeKey(SEARCH_WIDGET_IS_INCOGNITO_AVAILABLE);
        initializeFromCache();
    }

    /**
     * Specify current SearchActivityPreferences values. If the supplied values are different than
     * current values, the update will be propagated to registered listeners.
     *
     * @param prefs Current preferences.
     * @param updateStorage Whether to update on-disk cache.
     */
    @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
    static void setCurrentlyLoadedPreferences(
            @NonNull SearchActivityPreferences prefs, boolean updateStorage) {
        SearchActivityPreferencesManager self = get();
        if (prefs.equals(self.mCurrentlyLoadedPreferences)) return;
        self.mCurrentlyLoadedPreferences = prefs;

        // Notify all listeners about update.
        PostTask.postTask(
                TaskTraits.UI_DEFAULT,
                () -> {
                    // Note: it takes about 6.5ms to update a single property on debug-enabled
                    // builds.
                    if (updateStorage) {
                        SharedPreferencesManager manager = ChromeSharedPreferences.getInstance();
                        manager.writeString(
                                SEARCH_WIDGET_SEARCH_ENGINE_SHORTNAME, prefs.searchEngineName);
                        manager.writeString(
                                SEARCH_WIDGET_SEARCH_ENGINE_URL, prefs.searchEngineUrl.serialize());
                        manager.writeBoolean(
                                SEARCH_WIDGET_IS_VOICE_SEARCH_AVAILABLE,
                                prefs.voiceSearchAvailable);
                        manager.writeBoolean(
                                SEARCH_WIDGET_IS_GOOGLE_LENS_AVAILABLE, prefs.googleLensAvailable);
                        manager.writeBoolean(
                                SEARCH_WIDGET_IS_INCOGNITO_AVAILABLE, prefs.incognitoAvailable);
                    }

                    for (Consumer<SearchActivityPreferences> observer : self.mObservers) {
                        observer.accept(prefs);
                    }
                });
    }

    /**
     * Add a new preference change observer. This method guarantees that the newly added observer
     * will instantly receive information about current preferences.
     *
     * @param observer The observer to be added.
     */
    public static void addObserver(@NonNull Consumer<SearchActivityPreferences> observer) {
        ThreadUtils.assertOnUiThread();
        SearchActivityPreferencesManager self = get();
        if (!self.mObservers.hasObserver(observer)) {
            self.mObservers.addObserver(observer);
            observer.accept(self.mCurrentlyLoadedPreferences);
        }
    }

    /**
     * Creates the observer that will monitor for search engine changes. The native library and the
     * browser process must have been fully loaded before calling this.
     */
    public static void onNativeLibraryReady() {
        SearchActivityPreferencesManager self = get();
        TemplateUrlService service =
                TemplateUrlServiceFactory.getForProfile(ProfileManager.getLastUsedRegularProfile());
        service.registerLoadListener(self);
        service.addObserver(self);
        if (!service.isLoaded()) {
            service.load();
        }
    }

    /**
     * Update feature availability. Retrieves availability information from multiple sources and
     * updates local cache.
     *
     * @param context Current context.
     * @param permissionDelegate The delegate serving permission information.
     */
    public static void updateFeatureAvailability(
            Context context, AndroidPermissionDelegate permissionDelegate) {
        SearchActivityPreferences prefs = getCurrent();
        setCurrentlyLoadedPreferences(
                new SearchActivityPreferences(
                        prefs.searchEngineName,
                        prefs.searchEngineUrl,
                        VoiceRecognitionUtil.isVoiceSearchEnabled(permissionDelegate),
                        LensController.getInstance()
                                .isLensEnabled(
                                        new LensQueryParams.Builder(
                                                        LensEntryPoint.QUICK_ACTION_SEARCH_WIDGET,
                                                        false,
                                                        DeviceFormFactor
                                                                .isNonMultiDisplayContextOnTablet(
                                                                        context))
                                                .build()),
                        IncognitoUtils.isIncognitoModeEnabled(
                                ProfileManager.getLastUsedRegularProfile())),
                true);
    }

    /**
     * Retrieve the current search engine name and URL and update cached preferences. Requires that
     * the Native libraries are initialized.
     */
    private void updateDefaultSearchEngineInfo() {
        // Getting an instance of the TemplateUrlService requires that the native library be
        // loaded, but the TemplateUrlService also itself needs to be initialized.
        TemplateUrlService service =
                TemplateUrlServiceFactory.getForProfile(ProfileManager.getLastUsedRegularProfile());

        // Update the URL that we show for zero-suggest.
        TemplateUrl dseTemplateUrl = service.getDefaultSearchEngineTemplateUrl();
        if (dseTemplateUrl == null) return;

        GURL url = new GURL(service.getSearchEngineUrlFromTemplateUrl(dseTemplateUrl.getKeyword()));

        setCurrentlyLoadedPreferences(
                new SearchActivityPreferences(
                        dseTemplateUrl.getShortName(),
                        url.getOrigin(),
                        mCurrentlyLoadedPreferences.voiceSearchAvailable,
                        mCurrentlyLoadedPreferences.googleLensAvailable,
                        mCurrentlyLoadedPreferences.incognitoAvailable),
                true);
    }

    @Override
    public void onTemplateUrlServiceLoaded() {
        TemplateUrlServiceFactory.getForProfile(ProfileManager.getLastUsedRegularProfile())
                .unregisterLoadListener(this);
        updateDefaultSearchEngineInfo();
    }

    @Override
    public void onTemplateURLServiceChanged() {
        updateDefaultSearchEngineInfo();
    }

    /**
     * Reset the global instance of the SearchActivityPreferencesManager for the purpose of testing.
     */
    static void resetForTesting() {
        sInstance = null;
    }
}