chromium/components/search_engines/android/java/src/org/chromium/components/search_engines/SearchEngineChoiceService.java

// Copyright 2024 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.components.search_engines;

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

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

import org.chromium.base.ContextUtils;
import org.chromium.base.Log;
import org.chromium.base.Promise;
import org.chromium.base.ResettersForTesting;
import org.chromium.base.ThreadUtils;
import org.chromium.base.supplier.ObservableSupplier;
import org.chromium.base.supplier.ObservableSupplierImpl;
import org.chromium.base.supplier.OneShotCallback;
import org.chromium.base.supplier.TransitiveObservableSupplier;
import org.chromium.components.search_engines.SearchEngineCountryDelegate.DeviceChoiceEventType;

/**
 * Singleton responsible for communicating with device APIs to expose device-level properties that
 * are relevant for search engine choice screens and other similar UIs. It is the Java counterpart
 * of the native `SearchEngineChoiceService`, propagating some of the properties (notably the device
 * country string from {@link SearchEngineCountryDelegate}) to C++ instances of
 * `SearchEngineChoiceService`.
 *
 * <p>The object is a singleton rather than being profile-scoped as device properties apply to all
 * profiles, it also allows an instance to be created before the native is initialized.
 */
public class SearchEngineChoiceService {
    private static final String TAG = "DeviceChoiceDialog";
    private static SearchEngineChoiceService sInstance;

    /**
     * Gets reset to {@code null} after the device country is obtained.
     *
     * <p>TODO(b/355054098): Rely on disconnections inside the delegate instead of giving it up to
     * garbage collection. This will allow reconnecting if we need the delegate for other purposes.
     */
    private @Nullable SearchEngineCountryDelegate mDelegate;

    /**
     * Cached status associated with initiating a device country fetch when the object is
     * instantiated.
     *
     * <p>Possible states:
     *
     * <ul>
     *   <li>Pending: The fetch is not completed.
     *   <li>Fulfilled: The fetch succeeded, the value should be a non-null String. (note: it might
     *       still be an invalid or unknown country code!)
     *   <li>Rejected: An error occurred.
     * </ul>
     */
    private final Promise<String> mDeviceCountryPromise;

    /** Returns the instance of the singleton. Creates the instance if needed. */
    @MainThread
    public static SearchEngineChoiceService getInstance() {
        ThreadUtils.checkUiThread();
        if (sInstance == null) {
            var context = ContextUtils.getApplicationContext();
            var delegate =
                    SearchEnginesFeatures.isEnabled(SearchEnginesFeatures.CLAY_BLOCKING)
                                    && SearchEnginesFeatureUtils.clayBlockingUseFakeBackend()
                            ? new FakeSearchEngineCountryDelegate(
                                    context, /* enableLogging= */ true)
                            : new SearchEngineCountryDelegateImpl(context);
            sInstance = new SearchEngineChoiceService(delegate);
        }
        return sInstance;
    }

    /** Overrides the instance of the singleton for tests. */
    @MainThread
    @VisibleForTesting
    public static void setInstanceForTests(SearchEngineChoiceService instance) {
        ThreadUtils.checkUiThread();
        sInstance = instance;
        if (instance != null) {
            ResettersForTesting.register(() -> setInstanceForTests(null)); // IN-TEST
        }
    }

    @VisibleForTesting
    @MainThread
    public SearchEngineChoiceService(@NonNull SearchEngineCountryDelegate delegate) {
        ThreadUtils.checkUiThread();
        mDelegate = delegate;

        mDeviceCountryPromise = mDelegate.getDeviceCountry();

        mDeviceCountryPromise.then(
                countryCode -> {
                    assert countryCode != null : "Contract violation, country code should be null";
                },
                unusedException -> {});

        if (!SearchEnginesFeatures.isEnabled(SearchEnginesFeatures.CLAY_BLOCKING)) {
            // We request the country code once per run, so it is safe to free up
            // the delegate now.
            mDeviceCountryPromise.andFinally(() -> mDelegate = null);
        }
    }

    /**
     * Returns a promise that will resolve to a CLDR country code, see
     * https://www.unicode.org/cldr/charts/45/supplemental/territory_containment_un_m_49.html.
     * Fulfilled promises are guaranteed to return a non-nullable string, but rejected ones also
     * need to be handled, indicating some error in obtaining the device country.
     *
     * <p>If {@link SearchEnginesFeatures#CLAY_BLOCKING} is enabled, no rejection will be
     * propagated, the promise will be kept pending instead. Implement some timeout if that's
     * needed.
     *
     * <p>TODO(b/328040066): Ensure this is ACL'ed.
     */
    @MainThread
    public Promise<String> getDeviceCountry() {
        ThreadUtils.checkUiThread();
        return mDeviceCountryPromise;
    }

    /**
     * Returns whether the app should attempt to prompt the user to complete their choices of system
     * default apps.
     *
     * <p>This call might be relying on cached data, and the result of {@link
     * #shouldShowDeviceChoiceDialog} or {@link #getIsDeviceChoiceRequiredSupplier} should be
     * checked afterwards to ensure that the dialog is actually required.
     */
    @MainThread
    public boolean isDeviceChoiceDialogEligible() {
        ThreadUtils.checkUiThread();
        if (!SearchEnginesFeatures.isEnabled(SearchEnginesFeatures.CLAY_BLOCKING)) return false;
        assert mDelegate != null;

        return mDelegate.isDeviceChoiceDialogEligible();
    }

    /**
     * Returns a {@link Promise} that will be fulfilled with a determination of whether the user is
     * required to complete their choices of system default apps before continuing to use this app.
     *
     * @deprecated Prefer using {@link #getIsDeviceChoiceRequiredSupplier()} instead.
     */
    @Deprecated
    @MainThread
    public Promise<Boolean> shouldShowDeviceChoiceDialog() {
        ThreadUtils.checkUiThread();
        if (!SearchEnginesFeatures.isEnabled(SearchEnginesFeatures.CLAY_BLOCKING)) {
            return Promise.rejected();
        }

        var promise = new Promise<Boolean>();
        new OneShotCallback<>(getIsDeviceChoiceRequiredSupplier(), promise::fulfill);
        return promise;
    }

    /**
     * Supplier allowing to subscribe to changes in whether Chrome should require the user to
     * complete the device choices.
     *
     * <p>Possible return values:
     *
     * <ul>
     *   <li>null/no value: The service is not currently connected.
     *   <li>true: The dialog should be shown and block.
     *   <li>false: Blocking is not needed.
     * </ul>
     */
    @MainThread
    public ObservableSupplier<Boolean> getIsDeviceChoiceRequiredSupplier() {
        ThreadUtils.checkUiThread();
        var alwaysFalseSupplier = new ObservableSupplierImpl<>(false);

        if (!SearchEnginesFeatures.isEnabled(SearchEnginesFeatures.CLAY_BLOCKING)) {
            return alwaysFalseSupplier;
        }

        assert mDelegate != null;

        var supplier = mDelegate.getIsDeviceChoiceRequiredSupplier();
        if (SearchEnginesFeatureUtils.clayBlockingIsDarkLaunch()) {
            // We want to call into the backend to be able to verify it's working, but we intercept
            // its returned values to prevent it from affecting the user experience.
            return new TransitiveObservableSupplier<>(supplier, ignored -> alwaysFalseSupplier);
        }

        return supplier;
    }

    /**
     * Requests the device to launch its flow allowing the user to complete their choices of system
     * default apps.
     */
    @MainThread
    public void launchDeviceChoiceScreens() {
        ThreadUtils.checkUiThread();
        if (!SearchEnginesFeatures.isEnabled(SearchEnginesFeatures.CLAY_BLOCKING)) {
            return;
        }

        assert !SearchEnginesFeatureUtils.clayBlockingIsDarkLaunch();
        assert mDelegate != null;
        Log.i(TAG, "launchChoiceScreens()");
        mDelegate.launchDeviceChoiceScreens();
    }

    /** Notifies the service that the UI preventing the user from using the app has been shown. */
    @MainThread
    public void notifyDeviceChoiceBlockShown() {
        notifyDeviceChoiceEvent(DeviceChoiceEventType.BLOCK_SHOWN);
    }

    /** Notifies the service that the UI preventing the user from using the app has been removed. */
    @MainThread
    public void notifyDeviceChoiceBlockCleared() {
        notifyDeviceChoiceEvent(DeviceChoiceEventType.BLOCK_CLEARED);
    }

    /**
     * To be called when some key events (see {@link DeviceChoiceEventType}) happen in the app UI.
     *
     * <p>Private because {@link DeviceChoiceEventType} has to be part of the delegate API
     * definition, not of the service API definition. (build targets setup limitation).
     */
    @MainThread
    private void notifyDeviceChoiceEvent(@DeviceChoiceEventType int eventType) {
        ThreadUtils.checkUiThread();
        if (!SearchEnginesFeatures.isEnabled(SearchEnginesFeatures.CLAY_BLOCKING)) {
            return;
        }

        assert mDelegate != null;
        Log.i(TAG, "log(%d)", eventType);
        mDelegate.log(eventType);
    }

    private void requestCountryFromPlayApiInternal(long ptrToNativeCallback) {
        if (mDeviceCountryPromise.isPending()) {
            // When `SearchEngineCountryDelegate` replies with the result - the result will be
            // reported to native using the queued callback.
            mDeviceCountryPromise.then(
                    deviceCountry ->
                            SearchEngineChoiceServiceJni.get()
                                    .processCountryFromPlayApi(ptrToNativeCallback, deviceCountry),
                    ignoredException ->
                            SearchEngineChoiceServiceJni.get()
                                    .processCountryFromPlayApi(ptrToNativeCallback, null));
            return;
        }
        // The result is ready - call native so it can save the result in prefs.
        SearchEngineChoiceServiceJni.get()
                .processCountryFromPlayApi(
                        ptrToNativeCallback,
                        mDeviceCountryPromise.isFulfilled()
                                ? mDeviceCountryPromise.getResult()
                                : null);
    }

    @CalledByNative
    private static void requestCountryFromPlayApi(long ptrToNativeCallback) {
        ThreadUtils.checkUiThread();
        getInstance().requestCountryFromPlayApiInternal(ptrToNativeCallback);
    }

    @NativeMethods
    public interface Natives {
        void processCountryFromPlayApi(long ptrToNativeCallback, @Nullable String deviceCountry);
    }
}