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

// Copyright 2020 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 androidx.annotation.IntDef;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;

import com.google.common.primitives.UnsignedLongs;
import com.google.protobuf.InvalidProtocolBufferException;

import org.chromium.base.Callback;
import org.chromium.base.FeatureList;
import org.chromium.base.Log;
import org.chromium.base.metrics.RecordHistogram;
import org.chromium.base.supplier.ObservableSupplierImpl;
import org.chromium.base.task.PostTask;
import org.chromium.base.task.TaskTraits;
import org.chromium.build.annotations.DoNotClassMerge;
import org.chromium.chrome.browser.commerce.PriceUtils;
import org.chromium.chrome.browser.flags.ChromeFeatureList;
import org.chromium.chrome.browser.optimization_guide.OptimizationGuideBridge;
import org.chromium.chrome.browser.optimization_guide.OptimizationGuideBridgeFactory;
import org.chromium.chrome.browser.profiles.Profile;
import org.chromium.chrome.browser.profiles.ProfileManager;
import org.chromium.chrome.browser.tab.EmptyTabObserver;
import org.chromium.chrome.browser.tab.Tab;
import org.chromium.chrome.browser.tab.proto.ShoppingPersistedTabData.ShoppingPersistedTabDataProto;
import org.chromium.components.commerce.PriceTracking.BuyableProduct;
import org.chromium.components.commerce.PriceTracking.PriceTrackingData;
import org.chromium.components.commerce.PriceTracking.ProductPriceUpdate;
import org.chromium.components.optimization_guide.OptimizationGuideDecision;
import org.chromium.components.optimization_guide.proto.HintsProto;
import org.chromium.components.payments.CurrencyFormatter;
import org.chromium.content_public.browser.NavigationHandle;
import org.chromium.ui.base.PageTransition;
import org.chromium.url.GURL;

import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.nio.ByteBuffer;
import java.util.ArrayDeque;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Queue;
import java.util.concurrent.TimeUnit;

/**
 * {@link PersistedTabData} for Shopping related websites
 *
 * This class should not be merged because it is being used as a key in a Map
 * in PersistedTabDataConfiguration.java.
 */
@DoNotClassMerge
public class ShoppingPersistedTabData extends PersistedTabData {
    private static final String TAG = "SPTD";
    private static final String STALE_TAB_THRESHOLD_SECONDS_PARAM =
            "price_tracking_stale_tab_threshold_seconds";
    private static final String TIME_TO_LIVE_MS_PARAM = "price_tracking_time_to_live_ms";
    private static final String DISPLAY_TIME_MS_PARAM = "price_tracking_display_time_ms";
    private static final String RETURN_EMPTY_PRICE_DROPS_UNTIL_INIT_PARAM =
            "return_empty_price_drops_until_init";
    private static final String CHECK_IF_PRICE_DROP_IS_SEEN_PARAM = "check_if_price_drop_is_seen";
    private static final String METRICS_IDENTIFIER_PREFIX = "NavigationComplete";

    private static final Class<ShoppingPersistedTabData> USER_DATA_KEY =
            ShoppingPersistedTabData.class;
    private static final int MICROS_TO_UNITS = 1000000;
    private static final long TWO_UNITS = 2 * MICROS_TO_UNITS;
    private static final int MINIMUM_DROP_PERCENTAGE = 10;
    private static final int ONE_WEEK_MS = (int) TimeUnit.DAYS.toMillis(7);

    @VisibleForTesting public static final long ONE_HOUR_MS = TimeUnit.HOURS.toMillis(1);

    private static final int NINETY_DAYS_SECONDS = (int) TimeUnit.DAYS.toSeconds(90);

    @VisibleForTesting public static final long NO_TRANSITIONS_OCCURRED = -1;

    @VisibleForTesting public static final long NO_PRICE_KNOWN = -1;

    private static boolean sPriceTrackingWithOptimizationGuideForTesting;

    private static Queue<ShoppingDataRequest> sShoppingDataRequests = new ArrayDeque<>();
    private static boolean sDelayedInitFinished;

    public long mLastPriceChangeTimeMs = NO_TRANSITIONS_OCCURRED;

    private PriceDropData mPriceDropData = new PriceDropData();
    private PriceDropMetricsLogger mPriceDropMetricsLogger;
    private Map<String, CurrencyFormatter> mCurrencyFormatterMap = new HashMap<>();

    @VisibleForTesting
    protected ObservableSupplierImpl<Boolean> mIsTabSaveEnabledSupplier =
            new ObservableSupplierImpl<>();

    @VisibleForTesting protected EmptyTabObserver mUrlUpdatedObserver;

    static {
        PersistedTabData.addSupportedMaintenanceClass(USER_DATA_KEY);
    }

    // Lazy initialization of OptimizationGuideBridgeFactory
    private static class OptimizationGuideBridgeHolder {
        private static final OptimizationGuideBridge sOptimizationGuideBridge;

        static {
            List<HintsProto.OptimizationType> optimizationTypes;
            Profile profile = ProfileManager.getLastUsedRegularProfile();
            sOptimizationGuideBridge = OptimizationGuideBridgeFactory.getForProfile(profile);
            if (sOptimizationGuideBridge != null) {
                sOptimizationGuideBridge.registerOptimizationTypes(
                        Arrays.asList(
                                HintsProto.OptimizationType.SHOPPING_PAGE_PREDICTOR,
                                HintsProto.OptimizationType.PRICE_TRACKING));
            }
        }
    }

    /**
     * Provides a deep copy of a previous {@link ShoppingPersistedTabData} for client side price
     * drop tracking. Only fields required for price drop identification are retained.
     */
    private static class PriceDataSnapshot {
        public long priceMicros;
        public long previousPriceMicros;
        public long lastPriceChangeTimeMs;

        PriceDataSnapshot(ShoppingPersistedTabData shoppingPersistedTabData) {
            this.priceMicros = shoppingPersistedTabData.getPriceMicros();
            this.previousPriceMicros = shoppingPersistedTabData.getPreviousPriceMicros();
            this.lastPriceChangeTimeMs = shoppingPersistedTabData.getLastPriceChangeTimeMs();
        }
    }

    /**
     * Used to defer initialization/acquisition of {@link ShoppingPersistedTabData}
     * until DeferredStartup.
     */
    private static class ShoppingDataRequest {
        public Tab tab;
        public Callback<ShoppingPersistedTabData> callback;

        /**
         * @param tab {@link Tab} {@link ShoppingPersistedTabData} is being acquired for
         * @param callback {@link Callback} {@link ShoppingPersistedTabData} is passed back in
         */
        ShoppingDataRequest(Tab tab, Callback<ShoppingPersistedTabData> callback) {
            this.tab = tab;
            this.callback = callback;
        }
    }

    /**
     * Raw price drop data acquired from backend service. This is converted to formatted
     * price strings in the public API getPriceDrop().
     */
    @VisibleForTesting
    protected static class PriceDropData {
        public long priceMicros;
        public long previousPriceMicros;
        public String currencyCode;
        public String offerId;
        public GURL gurl;
        public boolean isCurrentPriceDropSeen;
        public String productTitle;
        public GURL productImageUrl;

        PriceDropData() {
            this.priceMicros = NO_PRICE_KNOWN;
            this.previousPriceMicros = NO_PRICE_KNOWN;
        }

        /**
         * @param priceMicros the price in micros
         * @param previousPriceMicros previous price in micros
         * @param currencyCode the currency code of the price
         * @param offerId the offer id associated with the price drop
         */
        PriceDropData(
                long priceMicros, long previousPriceMicros, String currencyCode, String offerId) {
            this.priceMicros = priceMicros;
            this.previousPriceMicros = previousPriceMicros;
            this.currencyCode = currencyCode;
            this.offerId = offerId;
        }
    }

    /**
     * A price drop for the offer {@link ShoppingPersistedTabData}
     * refers to
     */
    public static class PriceDrop {
        public final String price;
        public final String previousPrice;

        /**
         * @param price {@link String} representation of the price
         * @param previousPrice {@link String} representation of the previous price
         */
        public PriceDrop(String price, String previousPrice) {
            this.price = price;
            this.previousPrice = previousPrice;
        }

        @Override
        public boolean equals(Object object) {
            if (!(object instanceof PriceDrop)) return false;
            PriceDrop priceDrop = (PriceDrop) object;
            return this.price.equals(priceDrop.price)
                    && this.previousPrice.equals(priceDrop.previousPrice);
        }

        @Override
        public int hashCode() {
            int result = 17;
            result = 31 * result + (price == null ? 0 : price.hashCode());
            result = 31 * result + (previousPrice == null ? 0 : previousPrice.hashCode());
            return result;
        }
    }

    @VisibleForTesting(otherwise = VisibleForTesting.PROTECTED)
    public ShoppingPersistedTabData(Tab tab) {
        super(
                tab,
                PersistedTabDataConfiguration.get(ShoppingPersistedTabData.class, tab.isIncognito())
                        .getStorage(),
                PersistedTabDataConfiguration.get(ShoppingPersistedTabData.class, tab.isIncognito())
                        .getId());
        setupPersistence(tab);
        mPriceDropMetricsLogger = new PriceDropMetricsLogger(this);
    }

    private void prefetchOnNewNavigation(Tab tab, NavigationHandle navigationHandle) {
        prefetchOnNewNavigation(tab, navigationHandle, null);
    }

    /**
     * @param tab {@link Tab} associated with {@link ShoppingPersistedTabData}
     * @param navigationHandle the {@link NavigationHandle} associated with the new navigation
     * @param onCompleteForTesting runnable which indicates when the callback has completed for test
     *         synchronization
     */
    @VisibleForTesting
    protected void prefetchOnNewNavigation(
            Tab tab, NavigationHandle navigationHandle, Runnable onCompleteForTesting) {
        if (!navigationHandle.isInPrimaryMainFrame()) {
            return;
        }
        if (OptimizationGuideBridgeHolder.sOptimizationGuideBridge == null) return;
        ShoppingPersistedTabDataService service =
                ShoppingPersistedTabDataService.getForProfile(tab.getProfile());
        OptimizationGuideBridge.OptimizationGuideCallback optimizationCallback =
                (decision, metadata) -> {
                    if (!tab.isInitialized()) {
                        if (onCompleteForTesting != null) {
                            onCompleteForTesting.run();
                        }
                        return;
                    }
                    if (!tab.getUrl().equals(navigationHandle.getUrl())
                            || decision != OptimizationGuideDecision.TRUE) {
                        if (onCompleteForTesting != null) {
                            onCompleteForTesting.run();
                        }
                        mPriceDropMetricsLogger.logPriceDropMetrics(
                                METRICS_IDENTIFIER_PREFIX, getTimeSinceTabLastOpenedMs(tab));
                        if (ChromeFeatureList.isEnabled(ChromeFeatureList.PRICE_CHANGE_MODULE)) {
                            service.notifyPriceDropStatus(tab, false);
                        }
                        return;
                    }
                    try {
                        PriceTrackingData priceTrackingDataProto =
                                PriceTrackingData.parseFrom(metadata.getValue());
                        parsePriceTrackingDataProto(tab, priceTrackingDataProto, null);
                        setLastUpdatedMs(System.currentTimeMillis());
                        mPriceDropMetricsLogger.logPriceDropMetrics(
                                METRICS_IDENTIFIER_PREFIX, getTimeSinceTabLastOpenedMs(tab));
                        if (ChromeFeatureList.isEnabled(ChromeFeatureList.PRICE_CHANGE_MODULE)) {
                            service.notifyPriceDropStatus(
                                    tab,
                                    ShoppingPersistedTabDataService.isDataEligibleForPriceDrop(
                                            this));
                        }
                    } catch (InvalidProtocolBufferException e) {
                        Log.i(TAG, "Problem parsing PriceTrackingDataProto", e);
                    }
                    if (onCompleteForTesting != null) {
                        onCompleteForTesting.run();
                    }
                };
        OptimizationGuideBridgeHolder.sOptimizationGuideBridge.canApplyOptimization(
                navigationHandle.getUrl(),
                HintsProto.OptimizationType.PRICE_TRACKING,
                optimizationCallback);
    }

    /**
     * Log price drop metrics, if we have price drop data
     *
     * @param locationIdentifier where in the user experience the metrics were called from.
     */
    public void logPriceDropMetrics(String locationIdentifier) {
        if (mPriceDropMetricsLogger != null) {
            mPriceDropMetricsLogger.logPriceDropMetrics(
                    locationIdentifier, getTimeSinceTabLastOpenedMs(mTab));
        }
    }

    protected PriceDropMetricsLogger getPriceDropMetricsLoggerForTesting() {
        return mPriceDropMetricsLogger;
    }

    @VisibleForTesting
    protected ShoppingPersistedTabData(
            Tab tab, PersistedTabDataStorage storage, String persistedTabDataId) {
        super(tab, storage, persistedTabDataId);
        setupPersistence(tab);
        mPriceDropMetricsLogger = new PriceDropMetricsLogger(this);
    }

    private void setupPersistence(Tab tab) {
        // ShoppingPersistedTabData is not saved by default - only when its fields are populated
        // (after a successful endpoint repsonse)
        disableSaving();
        registerIsTabSaveEnabledSupplier(mIsTabSaveEnabledSupplier);
        // In the below, resetting price data is decoupled from prefetching because
        // when we couple the two together, we sometimes delete too aggressively. The example is -
        // after a restart the active Tab is reloaded. If the page had a price drop which was
        // persisted across restarts, the reload of the active Tab results in resetting the price
        // data but at that point, OptimizationGuide is not returning results yet - so we
        // essentially can't persisted any price drops of the active Tab across restarts.
        mUrlUpdatedObserver =
                new EmptyTabObserver() {
                    @Override
                    public void onDidStartNavigationInPrimaryMainFrame(
                            Tab tab, NavigationHandle navigationHandle) {
                        if (navigationHandle.isSameDocument()) {
                            return;
                        }
                        // User is navigating to a different page - as detected by a change in URL
                        if (!tab.getUrl().equals(navigationHandle.getUrl())) {
                            resetPriceData();
                        }
                    }

                    @Override
                    public void onDidFinishNavigationInPrimaryMainFrame(
                            Tab tab, NavigationHandle navigationHandle) {
                        if (navigationHandle.isSameDocument() || !navigationHandle.hasCommitted()) {
                            return;
                        }

                        // User navigating to a different page, as detected by a search or typing
                        // something into the address bar.
                        boolean fromAddressBar =
                                (navigationHandle.pageTransition()
                                                & PageTransition.FROM_ADDRESS_BAR)
                                        != 0;
                        if (navigationHandle.isValidSearchFormUrl() || fromAddressBar) {
                            resetPriceData();
                        }

                        prefetchOnNewNavigation(tab, navigationHandle);
                    }
                };
        tab.addObserver(mUrlUpdatedObserver);
    }

    protected void resetPriceData() {
        delete();
        mPriceDropData = new PriceDropData();
    }

    public EmptyTabObserver getUrlUpdatedObserverForTesting() {
        return mUrlUpdatedObserver;
    }

    public PriceDropData getPriceDropDataForTesting() {
        return mPriceDropData;
    }

    /**
     * Initializes {@link ShoppingPersistedTabData} for a {@link Tab}. This results in
     * a {@link ShoppingPersistedTabData} being acquired from storage, via a network call
     * or a blank one being created. In any case, a {@link ShoppingPersistedTabData} object will be
     * created which enables pricing data to be prefetched on each new navigation. The only scenario
     * where no {@link ShoppingPersistedTabData} will be returned is if the {@link Tab} was
     * destroyed shortly after calling this method.
     * @param tab {@link Tab} for which {@link ShoppingPersistedTabData} is initialized.
     */
    public static void initialize(Tab tab) {
        Callback<ShoppingPersistedTabData> callback =
                (res) -> {
                    if (res == null) {
                        // If there is no ShoppingPersistedTabData found from storage, we create
                        // an empty ShoppingPersistedTabData so the pricing data can be prefetched
                        // on each new navigation. We gate this with an isDestroyed() check to
                        // protect against the Tab being destroyed in the meantime.
                        if (!tab.isDestroyed()) {
                            ShoppingPersistedTabData.from(tab);
                        }
                    }
                    if (ChromeFeatureList.isEnabled(ChromeFeatureList.PRICE_CHANGE_MODULE)) {
                        ShoppingPersistedTabDataService service =
                                ShoppingPersistedTabDataService.getForProfile(tab.getProfile());
                        service.notifyPriceDropStatus(
                                tab,
                                !tab.isDestroyed()
                                        && ShoppingPersistedTabDataService
                                                .isDataEligibleForPriceDrop(res));
                    }
                };
        if (sDelayedInitFinished) {
            ShoppingPersistedTabData.from(tab, callback);
        } else {
            sShoppingDataRequests.add(new ShoppingDataRequest(tab, callback));
        }
    }

    /**
     * Acquire {@link ShoppingPersistedTabData} for a {@link Tab}
     * @param tab {@link Tab} ShoppingPersistedTabData is acquired for
     * @param callback {@link Callback} receiving the Tab's {@link ShoppingPersistedTabData}
     * The result in the callback wil be null for a:
     * - Custom Tab
     * - Incognito Tab
     * - Tab greater than 90 days old
     * - Tab with a non-shopping related page currently navigated to
     * - Tab with a shopping related page for which no shopping related data was found
     * - Uninitialized Tab
     */
    public static void from(Tab tab, Callback<ShoppingPersistedTabData> callback) {
        if (tab == null || tab.isDestroyed()) {
            PostTask.runOrPostTask(
                    TaskTraits.UI_DEFAULT,
                    () -> {
                        callback.onResult(null);
                    });
            return;
        }
        if (sDelayedInitFinished) {
            fromWithoutDelayedInit(tab, callback);
        } else {
            @DelayedInitMethod int delayedInitMethod = getDelayedInitMethod();
            if (delayedInitMethod == DelayedInitMethod.EMPTY_RESPONSES_UNTIL_INIT) {
                PostTask.postTask(
                        TaskTraits.UI_DEFAULT,
                        () -> {
                            callback.onResult(null);
                        });
            } else if (delayedInitMethod == DelayedInitMethod.DELAY_RESPONSES_UNTIL_INIT) {
                sShoppingDataRequests.add(new ShoppingDataRequest(tab, callback));
            } else {
                assert false : "Unknown DelayedInitMethod: " + delayedInitMethod;
            }
        }
    }

    /**
     * Acquire {@link ShoppingPersistedTabData} for a {@link Tab}, with an option to skip delayed
     * initialization and initialize immediately.
     * @param tab {@link Tab} ShoppingPersistedTabData is acquired for
     * @param callback {@link Callback} receiving the Tab's {@link ShoppingPersistedTabData}
     * @param skipDelayedInit whether to skip the delayed initialization of {@link
     * ShoppingPersistedTabData} and initialize immediately
     * The result in the callback wil be null for a:
     * - Custom Tab
     * - Incognito Tab
     * - Tab greater than 90 days old
     * - Tab with a non-shopping related page currently navigated to
     * - Tab with a shopping related page for which no shopping related data was found
     * - Uninitialized Tab
     */
    static void from(
            Tab tab, Callback<ShoppingPersistedTabData> callback, boolean skipDelayedInit) {
        if (skipDelayedInit) {
            fromWithoutDelayedInit(tab, callback);
        } else {
            from(tab, callback);
        }
    }

    private static void fromWithoutDelayedInit(
            Tab tab, Callback<ShoppingPersistedTabData> callback) {
        // Shopping related data is not available for incognito, Custom or Destroyed Tabs. For
        // example, for incognito Tabs it is not possible to call a backend service with the user's
        // URL.
        if (tab == null || tab.isDestroyed() || tab.isIncognito() || tab.isCustomTab()) {
            PostTask.postTask(
                    TaskTraits.UI_DEFAULT,
                    () -> {
                        callback.onResult(null);
                    });
            return;
        }
        PersistedTabData.from(
                tab,
                (data, storage, id, factoryCallback) -> {
                    PostTask.postTask(
                            TaskTraits.UI_DEFAULT,
                            () -> {
                                ShoppingPersistedTabData shoppingPersistedTabData =
                                        tab.isDestroyed()
                                                ? null
                                                : ShoppingPersistedTabData.from(tab);
                                PostTask.postTask(
                                        TaskTraits.USER_BLOCKING_MAY_BLOCK,
                                        () -> {
                                            if (shoppingPersistedTabData != null) {
                                                shoppingPersistedTabData.deserializeAndLog(data);
                                            }
                                            PostTask.postTask(
                                                    TaskTraits.UI_DEFAULT,
                                                    () -> {
                                                        factoryCallback.onResult(
                                                                shoppingPersistedTabData);
                                                    });
                                        });
                            });
                },
                (supplierCallback) -> {
                    if (tab.isDestroyed()
                            || OptimizationGuideBridgeHolder.sOptimizationGuideBridge == null
                            || getTimeSinceTabLastOpenedMs(tab)
                                    > TimeUnit.SECONDS.toMillis(getStaleTabThresholdSeconds())) {
                        supplierCallback.onResult(null);
                        return;
                    }
                    PriceDataSnapshot previous =
                            PersistedTabData.from(tab, USER_DATA_KEY) == null
                                    ? null
                                    : new PriceDataSnapshot(
                                            PersistedTabData.from(tab, USER_DATA_KEY));
                    OptimizationGuideBridge.OptimizationGuideCallback optimizationCallback =
                            (decision, metadata) -> {
                                if (tab.isDestroyed()) {
                                    supplierCallback.onResult(null);
                                    return;
                                }
                                if (decision != OptimizationGuideDecision.TRUE) {
                                    ShoppingPersistedTabData res =
                                            getEmptyShoppingPersistedTabData(tab);
                                    res.logPriceDropMetrics(METRICS_IDENTIFIER_PREFIX);
                                    supplierCallback.onResult(res);
                                    return;
                                }
                                try {
                                    PriceTrackingData priceTrackingDataProto =
                                            PriceTrackingData.parseFrom(metadata.getValue());
                                    ShoppingPersistedTabData sptd =
                                            ShoppingPersistedTabData.from(tab);
                                    sptd.parsePriceTrackingDataProto(
                                            tab, priceTrackingDataProto, previous);
                                    sptd.logPriceDropMetrics(METRICS_IDENTIFIER_PREFIX);
                                    supplierCallback.onResult(sptd);
                                } catch (InvalidProtocolBufferException e) {
                                    Log.i(TAG, "Problem with PriceTrackingDataProto.Details", e);
                                    supplierCallback.onResult(null);
                                }
                            };
                    OptimizationGuideBridgeHolder.sOptimizationGuideBridge.canApplyOptimization(
                            tab.getUrl(),
                            HintsProto.OptimizationType.PRICE_TRACKING,
                            optimizationCallback);
                },
                ShoppingPersistedTabData.class,
                callback);
    }

    /**
     * @return {@link ShoppingPersistedTabData} from {@link UserDataHost}
     */
    public static ShoppingPersistedTabData from(Tab tab) {
        ShoppingPersistedTabData shoppingPersistedTabData =
                PersistedTabData.from(tab, USER_DATA_KEY);
        if (shoppingPersistedTabData == null) {
            shoppingPersistedTabData =
                    tab.getUserDataHost()
                            .setUserData(USER_DATA_KEY, new ShoppingPersistedTabData(tab));
        }
        return shoppingPersistedTabData;
    }

    /**
     * @param tab {@link Tab} of interest.
     * @return true if the {@link Tab} has a price drop associated with it.
     */
    public static boolean hasPriceDrop(Tab tab) {
        ShoppingPersistedTabData shoppingPersistedTabData =
                PersistedTabData.from(tab, USER_DATA_KEY);
        if (shoppingPersistedTabData == null) {
            return false;
        }
        return shoppingPersistedTabData.getPriceDrop() != null;
    }

    /** Whether a BuyableProductAnnotation was found or not */
    @IntDef({
        FoundBuyableProduct.NOT_FOUND,
        FoundBuyableProduct.FOUND,
        FoundBuyableProduct.FOUND_WITH_PRICE_UPDATE
    })
    @Retention(RetentionPolicy.SOURCE)
    @interface FoundBuyableProduct {
        int NOT_FOUND = 0;
        int FOUND = 1;
        int FOUND_WITH_PRICE_UPDATE = 2;
        int NUM_ENTRIES = 3;
    }

    @IntDef({
        FoundBuyableProductAnnotation.NOT_FOUND,
        FoundBuyableProductAnnotation.FOUND,
        FoundBuyableProductAnnotation.FOUND_WITH_PRICE_UPDATE
    })
    @Retention(RetentionPolicy.SOURCE)
    @interface FoundBuyableProductAnnotation {
        int NOT_FOUND = 0;
        int FOUND = 1;
        int FOUND_WITH_PRICE_UPDATE = 2;
        int NUM_ENTRIES = 3;
    }

    @IntDef({
        DelayedInitMethod.DELAY_RESPONSES_UNTIL_INIT,
        DelayedInitMethod.EMPTY_RESPONSES_UNTIL_INIT
    })
    @Retention(RetentionPolicy.SOURCE)
    @interface DelayedInitMethod {
        /**
         * Responses from public API ShoppingPersistedTabData.from are delayed until {@link
         * ShoppingPersistedTabData} is initialized. This is achieved by onDeferredStartup() being
         * called and the Queue<ShoppingDataRequest> being flushed.
         */
        int DELAY_RESPONSES_UNTIL_INIT = 0;

        /**
         * Responses from public API ShoppingPersistedTabData.from are returned as empty until
         * {@link ShoppingPersistedTabData} is initialized. This is achieved by onDeferredStartup()
         * being called and the Queue<ShoppingDataRequest> being flushed.
         */
        int EMPTY_RESPONSES_UNTIL_INIT = 1;
    }

    /** Enable saving of {@link ShoppingPersistedTabData} */
    protected void enableSaving() {
        mIsTabSaveEnabledSupplier.set(true);
    }

    /**
     * Disable saving of {@link ShoppingPersistedTabData}. Deletes previously saved {@link
     * ShoppingPersistedTabData} as well.
     */
    public void disableSaving() {
        mIsTabSaveEnabledSupplier.set(false);
    }

    @VisibleForTesting
    protected void parsePriceTrackingDataProto(
            Tab tab, PriceTrackingData priceTrackingData, PriceDataSnapshot previousPricingData) {
        @FoundBuyableProduct int foundBuyableProduct = FoundBuyableProduct.NOT_FOUND;

        ProductPriceUpdate productUpdate = priceTrackingData.getProductUpdate();
        BuyableProduct buyableProduct = priceTrackingData.getBuyableProduct();

        if (hasPriceUpdate(priceTrackingData)) {
            if (hasPriceChange(productUpdate)) {
                setIsCurrentPriceDropSeen(false);
            }
            setPriceMicros(productUpdate.getNewPrice().getAmountMicros());
            setPreviousPriceMicros(productUpdate.getOldPrice().getAmountMicros());
            setCurrencyCode(productUpdate.getOldPrice().getCurrencyCode());
            setLastUpdatedMs(System.currentTimeMillis());
            // Use UnsignedLongs to convert OfferId to avoid overflow.
            setMainOfferId(UnsignedLongs.toString(buyableProduct.getOfferId()));
            setPriceDropGurl(tab.getUrl());
            setProductTitle(buyableProduct.getTitle());
            setProductImageUrl(new GURL(buyableProduct.getImageUrl()));
            foundBuyableProduct = FoundBuyableProduct.FOUND_WITH_PRICE_UPDATE;
        }

        RecordHistogram.recordEnumeratedHistogram(
                "Tabs.ShoppingPersistedTabData.FoundBuyableProduct",
                foundBuyableProduct,
                FoundBuyableProduct.NUM_ENTRIES);
        // Only persist this ShoppingPersistedTabData if it was correctly populated from the
        // response
        if (foundBuyableProduct == FoundBuyableProduct.FOUND
                || foundBuyableProduct == FoundBuyableProduct.FOUND_WITH_PRICE_UPDATE) {
            enableSaving();
        }
    }

    private static boolean hasPriceUpdate(PriceTrackingData priceTrackingDataProto) {
        if (!priceTrackingDataProto.hasBuyableProduct()
                || !priceTrackingDataProto.hasProductUpdate()) {
            return false;
        }
        ProductPriceUpdate productUpdateProto = priceTrackingDataProto.getProductUpdate();
        if (!productUpdateProto.hasNewPrice() || !productUpdateProto.hasOldPrice()) {
            return false;
        }
        if (!productUpdateProto.getNewPrice().hasCurrencyCode()
                || !productUpdateProto.getOldPrice().hasCurrencyCode()) {
            return false;
        }
        if (!productUpdateProto
                .getNewPrice()
                .getCurrencyCode()
                .equals(productUpdateProto.getOldPrice().getCurrencyCode())) {
            return false;
        }
        return true;
    }

    private static boolean hasPrice(PriceTrackingData priceTrackingDataProto) {
        if (!priceTrackingDataProto.hasBuyableProduct()) {
            return false;
        }
        if (!priceTrackingDataProto.getBuyableProduct().hasCurrentPrice()) {
            return false;
        }
        if (!priceTrackingDataProto.getBuyableProduct().getCurrentPrice().hasAmountMicros()
                || !priceTrackingDataProto
                        .getBuyableProduct()
                        .getCurrentPrice()
                        .hasCurrencyCode()) {
            return false;
        }
        return true;
    }

    @VisibleForTesting(otherwise = VisibleForTesting.PROTECTED)
    public void setPriceMicros(long priceMicros) {
        mPriceDropData.priceMicros = priceMicros;
        save();
    }

    protected void setCurrencyCode(String currencyCode) {
        mPriceDropData.currencyCode = currencyCode;
        save();
    }

    @VisibleForTesting
    protected void setPriceDropGurl(GURL gurl) {
        mPriceDropData.gurl = gurl;
        save();
    }

    @VisibleForTesting
    protected void setProductTitle(String productTitle) {
        mPriceDropData.productTitle = productTitle;
        save();
    }

    @VisibleForTesting
    public String getProductTitle() {
        return mPriceDropData.productTitle;
    }

    @VisibleForTesting
    protected void setProductImageUrl(GURL imageUrl) {
        mPriceDropData.productImageUrl = imageUrl;
        save();
    }

    @VisibleForTesting
    public GURL getProductImageUrl() {
        return mPriceDropData.productImageUrl;
    }

    @VisibleForTesting
    protected String getCurrencyCode() {
        return mPriceDropData.currencyCode;
    }

    @VisibleForTesting
    protected void setPreviousPriceMicros(long previousPriceMicros) {
        mPriceDropData.previousPriceMicros = previousPriceMicros;
        save();
    }

    @VisibleForTesting(otherwise = VisibleForTesting.PROTECTED)
    public long getPriceMicros() {
        return mPriceDropData.priceMicros;
    }

    /**
     * @return true if there is a price
     */
    protected boolean hasPriceMicros() {
        return mPriceDropData.priceMicros != NO_PRICE_KNOWN;
    }

    @VisibleForTesting(otherwise = VisibleForTesting.PROTECTED)
    public long getPreviousPriceMicros() {
        return mPriceDropData.previousPriceMicros;
    }

    /**
     * @return true if there is a previous price
     */
    protected boolean hasPreviousPriceMicros() {
        return mPriceDropData.previousPriceMicros != NO_PRICE_KNOWN;
    }

    public void setPriceMicrosForTesting(long priceMicros) {
        mPriceDropData.priceMicros = priceMicros;
    }

    public void setPreviousPriceMicrosForTesting(long previousPriceMicros) {
        mPriceDropData.previousPriceMicros = previousPriceMicros;
    }

    public void setMainOfferId(String offerId) {
        mPriceDropData.offerId = offerId;
        save();
    }

    public String getMainOfferId() {
        return mPriceDropData.offerId;
    }

    /**
     * @return {@link PriceDrop} relating to the main offer in the page.
     */
    public PriceDrop getPriceDrop() {
        if (!isValidPriceDropUpdate()
                || isPriceChangeStale()
                || !mTab.getUrl().equals(mPriceDropData.gurl)) {
            return null;
        }
        return createPriceDrop(mPriceDropData.priceMicros, mPriceDropData.previousPriceMicros);
    }

    private boolean isValidPriceDropUpdate() {
        return mPriceDropData.priceMicros != NO_PRICE_KNOWN
                && mPriceDropData.previousPriceMicros != NO_PRICE_KNOWN
                && mPriceDropData.priceMicros < mPriceDropData.previousPriceMicros;
    }

    private PriceDrop createPriceDrop(long priceMicros, long previousPriceMicros) {
        String formattedPrice = formatPrice(priceMicros);
        String formattedPreviousPrice = formatPrice(previousPriceMicros);
        if (formattedPrice.equals(formattedPreviousPrice)) {
            return null;
        }
        return new PriceDrop(formattedPrice, formattedPreviousPrice);
    }

    private boolean isPriceChangeStale() {
        return mLastPriceChangeTimeMs != NO_TRANSITIONS_OCCURRED
                && System.currentTimeMillis() - mLastPriceChangeTimeMs > getDisplayTimeMs();
    }

    private boolean isQualifyingPriceDrop() {
        if (mPriceDropData.previousPriceMicros - mPriceDropData.priceMicros
                < getMinimumDropThresholdAbsolute()) {
            return false;
        }
        if ((100L * mPriceDropData.priceMicros) / mPriceDropData.previousPriceMicros
                > (100 - getMinimumDroppedThresholdPercentage())) {
            return false;
        }
        return true;
    }

    /**
     * Sets whether the current price drop has been viewed in the tab switcher grid.
     * @param isSeen  is true if the current price drop has been seen.
     */
    public void setIsCurrentPriceDropSeen(boolean isSeen) {
        if (isCheckIfPriceDropIsSeenEnabled()) {
            mPriceDropData.isCurrentPriceDropSeen = isSeen;
            save();
        }
    }

    /**
     * @return returns whether the current price drop has been seen.
     */
    public boolean getIsCurrentPriceDropSeen() {
        if (isCheckIfPriceDropIsSeenEnabled()) {
            return mPriceDropData.isCurrentPriceDropSeen;
        }
        return false;
    }

    // TODO(crbug.com/40158181) Make parameters finch configurable
    private static int getMinimumDroppedThresholdPercentage() {
        return MINIMUM_DROP_PERCENTAGE;
    }

    private static long getMinimumDropThresholdAbsolute() {
        return TWO_UNITS;
    }

    // TODO(crbug.com/40720561) support all currencies
    private String formatPrice(long priceMicros) {
        if (mPriceDropData.currencyCode == null) {
            return "";
        }
        CurrencyFormatter currencyFormatter = getCurrencyFormatter(mPriceDropData.currencyCode);
        return PriceUtils.formatPrice(currencyFormatter, priceMicros);
    }

    private CurrencyFormatter getCurrencyFormatter(String currencyCode) {
        if (mCurrencyFormatterMap.get(currencyCode) == null) {
            mCurrencyFormatterMap.put(
                    currencyCode,
                    new CurrencyFormatter(mPriceDropData.currencyCode, Locale.getDefault()));
        }
        return mCurrencyFormatterMap.get(currencyCode);
    }

    @Override
    public Serializer<ByteBuffer> getSerializer() {
        ShoppingPersistedTabDataProto.Builder builder =
                ShoppingPersistedTabDataProto.newBuilder()
                        .setPriceMicros(mPriceDropData.priceMicros)
                        .setPreviousPriceMicros(mPriceDropData.previousPriceMicros)
                        .setLastUpdatedMs(getLastUpdatedMs())
                        .setLastPriceChangeTimeMs(mLastPriceChangeTimeMs)
                        .setIsCurrentPriceDropSeen(mPriceDropData.isCurrentPriceDropSeen);
        if (mPriceDropData.offerId != null) {
            builder.setMainOfferId(mPriceDropData.offerId);
        }

        if (mPriceDropData.currencyCode != null) {
            builder.setPriceCurrencyCode(mPriceDropData.currencyCode);
        }

        if (mPriceDropData.gurl != null) {
            builder.setSerializedGurl(mPriceDropData.gurl.serialize());
        }

        if (mPriceDropData.productTitle != null) {
            builder.setProductTitle(mPriceDropData.productTitle);
        }

        if (mPriceDropData.productImageUrl != null) {
            builder.setProductImageUrl(mPriceDropData.productImageUrl.serialize());
        }

        return () -> {
            return builder.build().toByteString().asReadOnlyByteBuffer();
        };
    }

    @Override
    public boolean deserialize(@Nullable ByteBuffer bytes) {
        // TODO(crbug.com/40151939) add in metrics for serialize and deserialize
        // Do not attempt to deserialize if the bytes are null
        if (bytes == null || !bytes.hasRemaining()) {
            return false;
        }
        try {
            ShoppingPersistedTabDataProto shoppingPersistedTabDataProto =
                    ShoppingPersistedTabDataProto.parseFrom(bytes);
            mPriceDropData.priceMicros = shoppingPersistedTabDataProto.getPriceMicros();
            mPriceDropData.previousPriceMicros =
                    shoppingPersistedTabDataProto.getPreviousPriceMicros();
            setLastUpdatedMs(shoppingPersistedTabDataProto.getLastUpdatedMs());
            mLastPriceChangeTimeMs = shoppingPersistedTabDataProto.getLastPriceChangeTimeMs();
            mPriceDropData.offerId = shoppingPersistedTabDataProto.getMainOfferId();
            mPriceDropData.currencyCode = shoppingPersistedTabDataProto.getPriceCurrencyCode();
            mPriceDropData.gurl =
                    GURL.deserialize(shoppingPersistedTabDataProto.getSerializedGurl());
            mPriceDropData.isCurrentPriceDropSeen =
                    shoppingPersistedTabDataProto.getIsCurrentPriceDropSeen();
            mPriceDropData.productTitle = shoppingPersistedTabDataProto.getProductTitle();
            mPriceDropData.productImageUrl =
                    GURL.deserialize(shoppingPersistedTabDataProto.getProductImageUrl());
            return true;
        } catch (InvalidProtocolBufferException e) {
            Log.e(
                    TAG,
                    String.format(
                            Locale.US,
                            "There was a problem deserializing "
                                    + "ShoppingPersistedTabData. Details: %s",
                            e.getMessage()));
        }
        return false;
    }

    @Override
    public String getUmaTag() {
        return "SPTD";
    }

    @Override
    public long getTimeToLiveMs() {
        if (FeatureList.isInitialized()) {
            return ChromeFeatureList.getFieldTrialParamByFeatureAsInt(
                    ChromeFeatureList.COMMERCE_PRICE_TRACKING,
                    TIME_TO_LIVE_MS_PARAM,
                    (int) ONE_HOUR_MS);
        }
        return (int) ONE_HOUR_MS;
    }

    @VisibleForTesting
    public long getLastPriceChangeTimeMs() {
        return mLastPriceChangeTimeMs;
    }

    public void setLastPriceChangeTimeMsForTesting(long lastPriceChangeTimeMs) {
        mLastPriceChangeTimeMs = lastPriceChangeTimeMs;
    }

    @Override
    protected boolean needsUpdate() {
        if (mPriceDropData.gurl != null && !mTab.getUrl().equals(mPriceDropData.gurl)) {
            return true;
        }
        return super.needsUpdate();
    }

    @Override
    public void destroy() {
        mTab.removeObserver(mUrlUpdatedObserver);
        for (CurrencyFormatter currencyFormatter : mCurrencyFormatterMap.values()) {
            assert currencyFormatter != null;
            currencyFormatter.destroy();
        }
        mCurrencyFormatterMap.clear();
        mPriceDropMetricsLogger.destroy();
        mPriceDropMetricsLogger = null;
        super.destroy();
    }

    /**
     * @return the threshold in seconds at which a {@link Tab} is considered stale
     * (and we no longer acquire shopping data for stale tabs).
     */
    public static int getStaleTabThresholdSeconds() {
        if (FeatureList.isInitialized()) {
            return ChromeFeatureList.getFieldTrialParamByFeatureAsInt(
                    ChromeFeatureList.COMMERCE_PRICE_TRACKING,
                    STALE_TAB_THRESHOLD_SECONDS_PARAM,
                    NINETY_DAYS_SECONDS);
        }
        return NINETY_DAYS_SECONDS;
    }

    /**
     * @return true if checking if a price drop is spotted is enabled.
     */
    public static boolean isCheckIfPriceDropIsSeenEnabled() {
        if (FeatureList.isInitialized()) {
            return ChromeFeatureList.getFieldTrialParamByFeatureAsBoolean(
                    ChromeFeatureList.COMMERCE_PRICE_TRACKING,
                    CHECK_IF_PRICE_DROP_IS_SEEN_PARAM,
                    false);
        }
        return false;
    }

    private static @DelayedInitMethod int getDelayedInitMethod() {
        if (FeatureList.isInitialized()
                && ChromeFeatureList.getFieldTrialParamByFeatureAsBoolean(
                        ChromeFeatureList.COMMERCE_PRICE_TRACKING,
                        RETURN_EMPTY_PRICE_DROPS_UNTIL_INIT_PARAM,
                        false)) {
            return DelayedInitMethod.EMPTY_RESPONSES_UNTIL_INIT;
        }
        return DelayedInitMethod.DELAY_RESPONSES_UNTIL_INIT;
    }

    public static void enablePriceTrackingWithOptimizationGuideForTesting() {
        sPriceTrackingWithOptimizationGuideForTesting = true;
    }

    private static int getDisplayTimeMs() {
        if (FeatureList.isInitialized()) {
            return ChromeFeatureList.getFieldTrialParamByFeatureAsInt(
                    ChromeFeatureList.COMMERCE_PRICE_TRACKING, DISPLAY_TIME_MS_PARAM, ONE_WEEK_MS);
        }
        return ONE_WEEK_MS;
    }

    private static long getTimeSinceTabLastOpenedMs(Tab tab) {
        return System.currentTimeMillis() - tab.getTimestampMillis();
    }

    /**
     * Returns true if there is an incoming change for the price.
     * @param productPriceUpdate incoming price update data.
     * @return
     */
    private boolean hasPriceChange(ProductPriceUpdate productPriceUpdate) {
        if (productPriceUpdate.getNewPrice().getAmountMicros() != mPriceDropData.priceMicros) {
            return true;
        }
        if (!productPriceUpdate
                .getNewPrice()
                .getCurrencyCode()
                .equals(mPriceDropData.currencyCode)) {
            return true;
        }
        return false;
    }

    /**
     * Called when it is appropriate to initialize and acquire {@link ShoppingPersistedTabData}.
     * Initialization and acquisition are delayed to avoid consuming system resources when
     * the system is busy with other urgent tasks on startup.
     */
    public static void onDeferredStartup() {
        processNextItemOnQueue();
    }

    private static void processNextItemOnQueue() {
        if (sDelayedInitFinished) {
            assert sShoppingDataRequests.isEmpty();
            return;
        }
        if (sShoppingDataRequests.isEmpty()) {
            sDelayedInitFinished = true;
            return;
        }
        ShoppingDataRequest shoppingDataRequest = sShoppingDataRequests.poll();
        if (shoppingDataRequest.tab.isDestroyed()) {
            // If Tab was destroyed we should just return null and not try and
            // create and associate {@link ShoppingPersistedTabData} with a
            // destroyed {@link Tab}.
            PostTask.postTask(
                    TaskTraits.UI_DEFAULT,
                    () -> {
                        shoppingDataRequest.callback.onResult(null);
                    });
            processNextItemOnQueue();
            return;
        }
        ShoppingPersistedTabData.fromWithoutDelayedInit(
                shoppingDataRequest.tab,
                (res) -> {
                    shoppingDataRequest.callback.onResult(res);
                    processNextItemOnQueue();
                });
    }

    private static ShoppingPersistedTabData getEmptyShoppingPersistedTabData(Tab tab) {
        ShoppingPersistedTabData shoppingPersistedTabData = ShoppingPersistedTabData.from(tab);
        shoppingPersistedTabData.resetPriceData();
        return shoppingPersistedTabData;
    }
}