chromium/chrome/android/java/src/org/chromium/chrome/browser/segmentation_platform/ContextualPageActionController.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.segmentation_platform;

import android.os.Handler;
import android.os.Looper;

import androidx.annotation.VisibleForTesting;

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

import org.chromium.base.Callback;
import org.chromium.base.supplier.ObservableSupplier;
import org.chromium.base.supplier.Supplier;
import org.chromium.chrome.browser.bookmarks.BookmarkModel;
import org.chromium.chrome.browser.profiles.Profile;
import org.chromium.chrome.browser.tab.CurrentTabObserver;
import org.chromium.chrome.browser.tab.EmptyTabObserver;
import org.chromium.chrome.browser.tab.Tab;
import org.chromium.chrome.browser.toolbar.adaptive.AdaptiveToolbarButtonController;
import org.chromium.chrome.browser.toolbar.adaptive.AdaptiveToolbarButtonVariant;
import org.chromium.chrome.browser.toolbar.adaptive.AdaptiveToolbarFeatures;
import org.chromium.components.commerce.core.ShoppingService;
import org.chromium.components.segmentation_platform.Constants;
import org.chromium.components.segmentation_platform.InputContext;
import org.chromium.components.segmentation_platform.ProcessedValue;

import java.util.ArrayList;
import java.util.List;

/**
 * Central class for contextual page actions bridging between UI and backend. Registers itself with
 * segmentation platform for on-demand model execution on page load triggers. Provides updated
 * button data to the toolbar when asked for it.
 */
public class ContextualPageActionController {
    /**
     * The interface to be implemented by the individual feature backends to provide signals
     * necessary for the controller in an uniform manner.
     */
    public interface ActionProvider {
        /**
         * Called during a page load to fetch the relevant signals from the action provider.
         * @param tab The current tab for which the action would be shown.
         * @param signalAccumulator An accumulator into which the provider would populate relevant
         *         signals.
         */
        void getAction(Tab tab, SignalAccumulator signalAccumulator);

        /**
         * Called when any contextual page action is shown.
         * @param tab The current tab for which the action was shown.
         * @param action Enum value of the action shown.
         */
        default void onActionShown(Tab tab, @AdaptiveToolbarButtonVariant int action) {}
    }

    private final ObservableSupplier<Profile> mProfileSupplier;
    private ObservableSupplier<Tab> mTabSupplier;
    private final AdaptiveToolbarButtonController mAdaptiveToolbarButtonController;
    private CurrentTabObserver mCurrentTabObserver;

    // The action provider backends.
    protected final List<ActionProvider> mActionProviders = new ArrayList<>();

    /**
     * Constructor.
     * @param profileSupplier The supplier for current profile.
     * @param tabSupplier The supplier of the current tab.
     * @param adaptiveToolbarButtonController The {@link AdaptiveToolbarButtonController} that
     *         handles the logic to decide between multiple buttons to show.
     */
    public ContextualPageActionController(
            ObservableSupplier<Profile> profileSupplier,
            ObservableSupplier<Tab> tabSupplier,
            AdaptiveToolbarButtonController adaptiveToolbarButtonController,
            Supplier<ShoppingService> shoppingServiceSupplier,
            Supplier<BookmarkModel> bookmarkModelSupplier) {
        mProfileSupplier = profileSupplier;
        mTabSupplier = tabSupplier;
        mAdaptiveToolbarButtonController = adaptiveToolbarButtonController;
        profileSupplier.addObserver(
                profile -> {
                    if (profile.isOffTheRecord()) return;

                    // The profile supplier observer will be invoked every time the profile is
                    // changed.
                    // Ignore the subsequent calls since we are only interested in initializing tab
                    // observers once.
                    if (mCurrentTabObserver != null) return;
                    mAdaptiveToolbarButtonController.initializePageLoadMetricsRecorder(tabSupplier);

                    if (!AdaptiveToolbarFeatures.isContextualPageActionsEnabled()) return;

                    // TODO(shaktisahu): Observe the right method to handle tab switch, same-page
                    // navigations. Also handle chrome:// URLs if not already handled.
                    mCurrentTabObserver =
                            new CurrentTabObserver(
                                    tabSupplier,
                                    new EmptyTabObserver() {
                                        @Override
                                        public void didFirstVisuallyNonEmptyPaint(Tab tab) {
                                            if (tab != null) maybeShowContextualPageAction();
                                        }
                                    },
                                    this::activeTabChanged);

                    initActionProviders(shoppingServiceSupplier, bookmarkModelSupplier);
                });
    }

    @VisibleForTesting
    protected void initActionProviders(
            Supplier<ShoppingService> shoppingServiceSupplier,
            Supplier<BookmarkModel> bookmarkModelSupplier) {
        mActionProviders.clear();
        mActionProviders.add(
                new PriceTrackingActionProvider(
                        shoppingServiceSupplier, bookmarkModelSupplier, mProfileSupplier));
        mActionProviders.add(new ReaderModeActionProvider());
        if (AdaptiveToolbarFeatures.isPriceInsightsPageActionEnabled()) {
            mActionProviders.add(new PriceInsightsActionProvider(shoppingServiceSupplier));
        }
    }

    /** Called on destroy. */
    public void destroy() {
        if (mCurrentTabObserver != null) mCurrentTabObserver.destroy();
    }

    private void activeTabChanged(Tab tab) {
        // If the tab is loading or if it's going to load later then we'll also get a call to
        // onPageLoadFinished.
        if (tab != null && !tab.isLoading() && !tab.isFrozen()) {
            maybeShowContextualPageAction();
        }
    }

    private void maybeShowContextualPageAction() {
        Tab tab = getValidActiveTab();
        if (tab == null) {
            // On incognito tabs revert back to static action.
            showDynamicAction(AdaptiveToolbarButtonVariant.UNKNOWN);
            return;
        }
        collectSignals(tab);
    }

    private void collectSignals(Tab tab) {
        if (mActionProviders.isEmpty()) return;
        final SignalAccumulator signalAccumulator =
                new SignalAccumulator(new Handler(Looper.getMainLooper()), tab, mActionProviders);
        signalAccumulator.getSignals(() -> findBestAction(signalAccumulator));
    }

    private void findBestAction(SignalAccumulator signalAccumulator) {
        Tab tab = getValidActiveTab();
        if (tab == null) return;
        InputContext inputContext = new InputContext();
        inputContext.addEntry(
                Constants.CONTEXTUAL_PAGE_ACTIONS_PRICE_TRACKING_INPUT,
                ProcessedValue.fromFloat(signalAccumulator.hasPriceTracking() ? 1.0f : 0.0f));
        inputContext.addEntry(
                Constants.CONTEXTUAL_PAGE_ACTIONS_READER_MODE_INPUT,
                ProcessedValue.fromFloat(signalAccumulator.hasReaderMode() ? 1.0f : 0.0f));
        inputContext.addEntry(
                Constants.CONTEXTUAL_PAGE_ACTIONS_PRICE_INSIGHTS_INPUT,
                ProcessedValue.fromFloat(signalAccumulator.hasPriceInsights() ? 1.0f : 0.0f));
        inputContext.addEntry("url", ProcessedValue.fromGURL(tab.getUrl()));

        ContextualPageActionControllerJni.get()
                .computeContextualPageAction(
                        mProfileSupplier.get(),
                        inputContext,
                        result -> {
                            if (tab.isDestroyed()) return;

                            boolean isSameTab =
                                    mTabSupplier.get() != null
                                            && mTabSupplier.get().getId() == tab.getId();
                            if (!isSameTab) return;

                            showDynamicAction(result);
                        });
    }

    private void showDynamicAction(@AdaptiveToolbarButtonVariant int action) {
        for (ActionProvider actionProvider : mActionProviders) {
            actionProvider.onActionShown(mTabSupplier.get(), action);
        }

        // TODO(crbug.com/40242242): Add logic to inform reader mode backend.
        mAdaptiveToolbarButtonController.showDynamicAction(action);
    }

    /** @return The active regular tab. Null for incognito. */
    private Tab getValidActiveTab() {
        if (mProfileSupplier == null || mProfileSupplier.get().isOffTheRecord()) return null;
        Tab tab = mTabSupplier.get();
        if (tab == null || tab.isIncognito() || tab.isDestroyed()) return null;
        return tab;
    }

    @NativeMethods
    interface Natives {
        void computeContextualPageAction(
                @JniType("Profile*") Profile profile,
                InputContext inputContext,
                Callback<Integer> callback);
    }
}