chromium/chrome/browser/tab/java/src/org/chromium/chrome/browser/tab/state/ShoppingPersistedTabDataService.java

// Copyright 2023 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.tab.state;

import static org.chromium.chrome.browser.preferences.ChromePreferenceKeys.PRICE_TRACKING_IDS_FOR_TABS_WITH_PRICE_DROP;

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

import org.chromium.base.Callback;
import org.chromium.base.ThreadUtils;
import org.chromium.base.cached_flags.BooleanCachedFieldTrialParameter;
import org.chromium.base.shared_preferences.SharedPreferencesManager;
import org.chromium.chrome.browser.flags.ChromeFeatureList;
import org.chromium.chrome.browser.preferences.ChromeSharedPreferences;
import org.chromium.chrome.browser.profiles.Profile;
import org.chromium.chrome.browser.profiles.ProfileKeyedMap;
import org.chromium.chrome.browser.tab.Tab;

import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.concurrent.atomic.AtomicInteger;

/**
 * Service to expose ShoppingPersistedTabData with price drop information. TODO(crbug.com/40941391):
 * This service should be moved out of current folder when we finish the ShoppingPersistedTabData
 * refactor that will move it out of current folder.
 */
public class ShoppingPersistedTabDataService {
    public static final BooleanCachedFieldTrialParameter
            SKIP_SHOPPING_PERSISTED_TAB_DATA_DELAYED_INITIALIZATION =
                    ChromeFeatureList.newBooleanCachedFieldTrialParameter(
                            ChromeFeatureList.PRICE_CHANGE_MODULE,
                            "skip_shopping_persisted_tab_data_delayed_initialization",
                            true);
    private static ProfileKeyedMap<ShoppingPersistedTabDataService> sProfileToPriceDropService;
    private static ShoppingPersistedTabDataService sServiceForTesting;

    private Set<Tab> mTabsWithPriceDrop;
    private boolean mInitialized;
    private final SharedPreferencesManager mSharedPreferencesManager;

    /**
     * Class for a price change item when externtal components ask for price changes from this
     * service.
     */
    public static class PriceChangeItem {
        private Tab mTab;
        private ShoppingPersistedTabData mData;

        public PriceChangeItem(Tab tab, ShoppingPersistedTabData data) {
            mTab = tab;
            mData = data;
        }

        /**
         * @return the corresponding {@link Tab} of the price drop.
         */
        public Tab getTab() {
            return mTab;
        }

        /**
         * @return the corresponding {@link ShoppingPersistedTabData} of the price drop.
         */
        public ShoppingPersistedTabData getData() {
            return mData;
        }
    }

    /** Creates a new instance. */
    @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
    protected ShoppingPersistedTabDataService() {
        mTabsWithPriceDrop = new HashSet<>();
        mSharedPreferencesManager = ChromeSharedPreferences.getInstance();
    }

    /**
     * Creates a new instance or reuses an existing one based on the current {@link Profile}.
     *
     * @param profile the current {@link Profile}.
     * @return {@link ShoppingPersistedTabDataService} instance for the current regular profile.
     */
    public static ShoppingPersistedTabDataService getForProfile(Profile profile) {
        if (sServiceForTesting != null) {
            return sServiceForTesting;
        }
        if (sProfileToPriceDropService == null) {
            sProfileToPriceDropService =
                    new ProfileKeyedMap<>(ProfileKeyedMap.NO_REQUIRED_CLEANUP_ACTION);
        }
        return sProfileToPriceDropService.getForProfile(
                profile, (unused) -> new ShoppingPersistedTabDataService());
    }

    /**
     * Initialize the service by passing in the tabs that could have price drop.
     * TODO(crbug.com/40941391): This method could be part of the constructor once
     * ShoppingPersistedTabData is in a separate target.
     *
     * @param tabs the tabs that could have price drop.
     */
    public void initialize(Set<Tab> tabs) {
        if (mInitialized) {
            return;
        }
        mInitialized = true;
        mTabsWithPriceDrop = new HashSet<>(tabs);
    }

    /**
     * Check if the service is initialized.
     *
     * @return whether the service is initialized.
     */
    public boolean isInitialized() {
        return mInitialized;
    }

    /**
     * Called by {@link ShoppingPersistedTabData} to inform the price drop status of given tab.
     *
     * @param tab the {@link Tab} that this notification is about.
     * @param hasDrop whether the tab has price drop or not.
     */
    protected void notifyPriceDropStatus(Tab tab, boolean hasDrop) {
        ThreadUtils.runOnUiThread(
                () -> {
                    // If the service is not initialized at this point, mark the service as
                    // initialized. This usually happens when the service hasn't been initialized
                    // when the deferred initialization of ShoppingPersistedTabData has finished.
                    if (!mInitialized) {
                        mInitialized = true;
                    }
                    if (tab.isDestroyed()) {
                        mTabsWithPriceDrop.remove(tab);
                        mSharedPreferencesManager.removeFromStringSet(
                                PRICE_TRACKING_IDS_FOR_TABS_WITH_PRICE_DROP,
                                String.valueOf(tab.getId()));
                        return;
                    }
                    if (hasDrop) {
                        mTabsWithPriceDrop.add(tab);
                        mSharedPreferencesManager.addToStringSet(
                                PRICE_TRACKING_IDS_FOR_TABS_WITH_PRICE_DROP,
                                String.valueOf(tab.getId()));
                    } else {
                        mTabsWithPriceDrop.remove(tab);
                        mSharedPreferencesManager.removeFromStringSet(
                                PRICE_TRACKING_IDS_FOR_TABS_WITH_PRICE_DROP,
                                String.valueOf(tab.getId()));
                    }
                });
    }

    /**
     * Called by external components to get all the tabs with price drops. The return value is a
     * list of {@link PriceChangeItem} sorted by time that the corresponding Tab was last accessed.
     *
     * @param callback to return the results.
     */
    public void getAllShoppingPersistedTabDataWithPriceDrop(
            Callback<List<PriceChangeItem>> callback) {
        assert mInitialized;
        if (!ChromeFeatureList.isEnabled(ChromeFeatureList.PRICE_CHANGE_MODULE)
                || mTabsWithPriceDrop.size() == 0
                || !mInitialized) {
            callback.onResult(new ArrayList<>());
            return;
        }

        Set<Tab> currentTabsWithPriceDrop = new HashSet<>(mTabsWithPriceDrop);
        AtomicInteger counter = new AtomicInteger();
        List<PriceChangeItem> results = new ArrayList<>();

        for (Tab tab : currentTabsWithPriceDrop) {
            ShoppingPersistedTabData.from(
                    tab,
                    result -> {
                        if (isDataEligibleForPriceDrop(result) && !tab.isDestroyed()) {
                            results.add(new PriceChangeItem(tab, result));
                        }
                        // Return when all the data fetching has finished.
                        if (counter.incrementAndGet() == currentTabsWithPriceDrop.size()) {
                            callback.onResult(sortShoppingPersistedTabDataWithPriceDrops(results));
                        }
                    },
                    SKIP_SHOPPING_PERSISTED_TAB_DATA_DELAYED_INITIALIZATION.getValue());
        }
    }

    private static List<PriceChangeItem> sortShoppingPersistedTabDataWithPriceDrops(
            List<PriceChangeItem> data) {
        Collections.sort(
                data,
                (p1, p2) ->
                        Long.compare(
                                p2.getTab().getTimestampMillis(),
                                p1.getTab().getTimestampMillis()));
        return data;
    }

    /**
     * Check if a {@link ShoppingPersistedTabData} has all information needed to be rendered in a
     * price change module.
     *
     * @param data the {@link ShoppingPersistedTabData} to check.
     * @return whether the data is eligible.
     */
    protected static boolean isDataEligibleForPriceDrop(@Nullable ShoppingPersistedTabData data) {
        return data != null
                && data.getPriceDrop() != null
                && data.getProductImageUrl() != null
                && data.getProductTitle() != null;
    }

    /** Sets the {@link ShoppingPersistedTabDataService} for testing. */
    public static void setServiceForTesting(ShoppingPersistedTabDataService service) {
        sServiceForTesting = service;
    }

    /** Get the current set of tabs with price drop for testing. */
    protected Set<Tab> getTabsWithPriceDropForTesting() {
        return mTabsWithPriceDrop;
    }
}