// 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.components.commerce.core;
import androidx.annotation.VisibleForTesting;
import org.jni_zero.CalledByNative;
import org.jni_zero.JNINamespace;
import org.jni_zero.NativeMethods;
import org.chromium.base.Callback;
import org.chromium.base.ObserverList;
import org.chromium.base.ResettersForTesting;
import org.chromium.components.bookmarks.BookmarkId;
import org.chromium.components.bookmarks.BookmarkType;
import org.chromium.url.GURL;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
/** A central hub for accessing shopping and product information. */
@JNINamespace("commerce")
public class ShoppingService {
private static Boolean sShoppingListEligibleForTestsing;
/** A data container for product info provided by the shopping service. */
public static final class ProductInfo {
public final String title;
public final GURL imageUrl;
public final Optional<Long> productClusterId;
public final Optional<Long> offerId;
public final String currencyCode;
public final long amountMicros;
public final Optional<Long> previousAmountMicros;
public final String countryCode;
public ProductInfo(
String title,
GURL imageUrl,
Optional<Long> productClusterId,
Optional<Long> offerId,
String currencyCode,
long amountMicros,
String countryCode,
Optional<Long> previousAmountMicros) {
this.title = title;
this.imageUrl = imageUrl;
this.productClusterId = productClusterId;
this.offerId = offerId;
this.currencyCode = currencyCode;
this.amountMicros = amountMicros;
this.previousAmountMicros = previousAmountMicros;
this.countryCode = countryCode;
}
}
/** A data container for merchant info provided by the shopping service. */
public static final class MerchantInfo {
public final float starRating;
public final int countRating;
public final GURL detailsPageUrl;
public final boolean hasReturnPolicy;
public final float nonPersonalizedFamiliarityScore;
public final boolean containsSensitiveContent;
public final boolean proactiveMessageDisabled;
@VisibleForTesting(otherwise = VisibleForTesting.PROTECTED)
public MerchantInfo(
float starRating,
int countRating,
GURL detailsPageUrl,
boolean hasReturnPolicy,
float nonPersonalizedFamiliarityScore,
boolean containsSensitiveContent,
boolean proactiveMessageDisabled) {
this.starRating = starRating;
this.countRating = countRating;
this.detailsPageUrl = detailsPageUrl;
this.hasReturnPolicy = hasReturnPolicy;
this.nonPersonalizedFamiliarityScore = nonPersonalizedFamiliarityScore;
this.containsSensitiveContent = containsSensitiveContent;
this.proactiveMessageDisabled = proactiveMessageDisabled;
}
}
/** A price point consisting of a date and the price on it. */
public static final class PricePoint {
public final String date;
public final long price;
public PricePoint(String date, long price) {
this.date = date;
this.price = price;
}
}
/** A data container for price insights info provided by the shopping service. */
public static final class PriceInsightsInfo {
public final Optional<Long> productClusterId;
public final String currencyCode;
public final Optional<Long> typicalLowPriceMicros;
public final Optional<Long> typicalHighPriceMicros;
public final Optional<String> catalogAttributes;
public final List<PricePoint> catalogHistoryPrices;
public final Optional<GURL> jackpotUrl;
public final @PriceBucket int priceBucket;
public final boolean hasMultipleCatalogs;
public PriceInsightsInfo(
Optional<Long> productClusterId,
String currencyCode,
Optional<Long> typicalLowPriceMicros,
Optional<Long> typicalHighPriceMicros,
Optional<String> catalogAttributes,
List<PricePoint> catalogHistoryPrices,
Optional<GURL> jackpotUrl,
@PriceBucket int priceBucket,
boolean hasMultipleCatalogs) {
this.productClusterId = productClusterId;
this.currencyCode = currencyCode;
this.typicalLowPriceMicros = typicalLowPriceMicros;
this.typicalHighPriceMicros = typicalHighPriceMicros;
this.catalogAttributes = catalogAttributes;
this.catalogHistoryPrices = catalogHistoryPrices;
this.jackpotUrl = jackpotUrl;
this.priceBucket = priceBucket;
this.hasMultipleCatalogs = hasMultipleCatalogs;
}
}
/** A callback for acquiring product information about a page. */
public interface ProductInfoCallback {
/**
* A notification that fetching product information for the URL has completed.
* @param url The URL the product info was fetched for.
* @param info The product info for the URL or {@code null} if none is available.
*/
void onResult(GURL url, ProductInfo info);
}
/** A callback for acquiring merchant information about a page. */
public interface MerchantInfoCallback {
/**
* A notification that fetching merchant information for the URL has completed.
* @param url The URL the merchant info was fetched for.
* @param info The merchant info for the URL or {@code null} if none is available.
*/
void onResult(GURL url, MerchantInfo info);
}
/** A callback for acquiring price insights information about a page. */
public interface PriceInsightsInfoCallback {
/**
* A notification that fetching price insights information for the URL has completed.
*
* @param url The URL the price insights info was fetched for.
* @param info The price insights info for the URL or {@code null} if none is available.
*/
void onResult(GURL url, PriceInsightsInfo info);
}
/** A callback for acquiring discounts information about a page. */
public interface DiscountInfoCallback {
/**
* A notification that fetching discounts information for the URL has completed.
*
* @param url The URL the discounts info was fetched for.
* @param info A list of available discounts for the URL or empty if none is available.
*/
void onResult(GURL url, List<DiscountInfo> info);
}
/** A pointer to the native side of the object. */
private long mNativeShoppingServiceAndroid;
private final ObserverList<SubscriptionsObserver> mSubscriptionsObservers =
new ObserverList<>();
/** Private constructor to ensure construction only happens by native. */
private ShoppingService(long nativePtr) {
mNativeShoppingServiceAndroid = nativePtr;
}
/**
* Fetch information about a product for a URL.
* @param url The URL to fetch product info for.
* @param callback The callback that will run after the fetch is completed. The product info
* object will be null if there is none available.
*/
public void getProductInfoForUrl(GURL url, ProductInfoCallback callback) {
if (mNativeShoppingServiceAndroid == 0) {
callback.onResult(url, null);
return;
}
ShoppingServiceJni.get()
.getProductInfoForUrl(mNativeShoppingServiceAndroid, this, url, callback);
}
/**
* Get the currently available product information for the specified URL. This method may return
* {@code null} or partial data if the page has not yet been completely processed. This is less
* reliable than {@link #getProductInfoForUrl(GURL, ProductInfoCallback)}.
* @param url The URL to fetch product info for.
*/
public ProductInfo getAvailableProductInfoForUrl(GURL url) {
if (mNativeShoppingServiceAndroid == 0) return null;
return ShoppingServiceJni.get()
.getAvailableProductInfoForUrl(mNativeShoppingServiceAndroid, this, url);
}
/**
* Fetch information about a merchant for a URL.
* @param url The URL to fetch merchant info for.
* @param callback The callback that will run after the fetch is completed. The merchant info
* object will be null if there is none available.
*/
public void getMerchantInfoForUrl(GURL url, MerchantInfoCallback callback) {
if (mNativeShoppingServiceAndroid == 0) {
callback.onResult(url, null);
return;
}
ShoppingServiceJni.get()
.getMerchantInfoForUrl(mNativeShoppingServiceAndroid, this, url, callback);
}
/**
* Fetch price insights information for a URL.
*
* @param url The URL to fetch price insights info for.
* @param callback The callback that will run after the fetch is completed. The price insights
* info object will be null if there is none available.
*/
public void getPriceInsightsInfoForUrl(GURL url, PriceInsightsInfoCallback callback) {
if (mNativeShoppingServiceAndroid == 0) {
callback.onResult(url, null);
return;
}
ShoppingServiceJni.get()
.getPriceInsightsInfoForUrl(mNativeShoppingServiceAndroid, this, url, callback);
}
/**
* Fetch discounts information for a URL.
*
* @param url The URL to fetch price insights info for.
* @param callback The callback that will run after the fetch is completed.
*/
public void getDiscountInfoForUrl(GURL url, DiscountInfoCallback callback) {
if (mNativeShoppingServiceAndroid == 0) {
callback.onResult(url, null);
return;
}
ShoppingServiceJni.get()
.getDiscountInfoForUrl(mNativeShoppingServiceAndroid, this, url, callback);
}
/**
* Requests that the service fetch the price notification email preference from the backend.
* This call will update the preference kept by the pref service directly -- changes to the
* value should also be observed through the pref service. This method should only be used in
* the context of settings UI.
*/
public void fetchPriceEmailPref() {
if (mNativeShoppingServiceAndroid == 0) return;
ShoppingServiceJni.get().fetchPriceEmailPref(mNativeShoppingServiceAndroid, this);
}
/** Schedules updates for all products that the user has saved in the bookmarks system. */
public void scheduleSavedProductUpdate() {
if (mNativeShoppingServiceAndroid == 0) return;
ShoppingServiceJni.get().scheduleSavedProductUpdate(mNativeShoppingServiceAndroid, this);
}
/** Create new subscriptions in batch. */
public void subscribe(CommerceSubscription sub, Callback<Boolean> callback) {
if (mNativeShoppingServiceAndroid == 0) {
callback.onResult(false);
return;
}
assert sub.userSeenOffer != null;
ShoppingServiceJni.get()
.subscribe(
mNativeShoppingServiceAndroid,
this,
sub.type,
sub.idType,
sub.managementType,
sub.id,
sub.userSeenOffer.offerId,
sub.userSeenOffer.userSeenPrice,
sub.userSeenOffer.countryCode,
sub.userSeenOffer.locale,
callback);
}
/** Delete existing subscriptions in batch. */
public void unsubscribe(CommerceSubscription sub, Callback<Boolean> callback) {
if (mNativeShoppingServiceAndroid == 0) {
callback.onResult(false);
return;
}
ShoppingServiceJni.get()
.unsubscribe(
mNativeShoppingServiceAndroid,
this,
sub.type,
sub.idType,
sub.managementType,
sub.id,
callback);
}
/**
* Check if a subscription exists.
* @param sub The subscription details to check.
* @param callback A callback executed when the state of the subscription is known.
*/
public void isSubscribed(CommerceSubscription sub, Callback<Boolean> callback) {
if (mNativeShoppingServiceAndroid == 0) {
callback.onResult(false);
return;
}
ShoppingServiceJni.get()
.isSubscribed(
mNativeShoppingServiceAndroid,
this,
sub.type,
sub.idType,
sub.managementType,
sub.id,
callback);
}
/**
* Check if a subscription exists from cached information. Use of the the callback-based version
* {@link #isSubscribed(CommerceSubscription, Callback)} is preferred.
* @param sub The subscription details to check.
* @return Whether the provided subscription is tracked by the user.
*/
public boolean isSubscribedFromCache(CommerceSubscription sub) {
if (mNativeShoppingServiceAndroid == 0) return false;
return ShoppingServiceJni.get()
.isSubscribedFromCache(
mNativeShoppingServiceAndroid,
this,
sub.type,
sub.idType,
sub.managementType,
sub.id);
}
public void addSubscriptionsObserver(SubscriptionsObserver observer) {
mSubscriptionsObservers.addObserver(observer);
}
public void removeSubscriptionsObserver(SubscriptionsObserver observer) {
mSubscriptionsObservers.removeObserver(observer);
}
public void getAllPriceTrackedBookmarks(Callback<List<BookmarkId>> callback) {
if (mNativeShoppingServiceAndroid == 0) {
callback.onResult(new ArrayList<>());
return;
}
ShoppingServiceJni.get()
.getAllPriceTrackedBookmarks(mNativeShoppingServiceAndroid, this, callback);
}
@CalledByNative
private static void runGetAllPriceTrackedBookmarksCallback(
Callback<List<BookmarkId>> callback, long[] trackedBookmarkIds) {
ArrayList<BookmarkId> bookmarks = new ArrayList<>();
for (int i = 0; i < trackedBookmarkIds.length; i++) {
// All product bookmarks will have a "Normal" type.
bookmarks.add(new BookmarkId(trackedBookmarkIds[i], BookmarkType.NORMAL));
}
callback.onResult(bookmarks);
}
/**
* This is a feature check for the "shopping list". This will only return true if the user has
* the feature flag enabled, is signed-in, has MSBB enabled, has webapp activity enabled, is
* allowed by enterprise policy, and (if applicable) in an eligible country and locale. The
* value returned by this method can change at runtime, so it should not be used when deciding
* whether to create critical, feature-related infrastructure.
*
* @return Whether the user is eligible to use the shopping list feature.
*/
public boolean isShoppingListEligible() {
if (sShoppingListEligibleForTestsing != null) return sShoppingListEligibleForTestsing;
if (mNativeShoppingServiceAndroid == 0) return false;
return ShoppingServiceJni.get().isShoppingListEligible(mNativeShoppingServiceAndroid, this);
}
// This is a feature check for the "merchant viewer", which will return true if the user has the
// feature flag enabled or (if applicable) is in an eligible country and locale.
public boolean isMerchantViewerEnabled() {
if (mNativeShoppingServiceAndroid == 0) return false;
return ShoppingServiceJni.get()
.isMerchantViewerEnabled(mNativeShoppingServiceAndroid, this);
}
// This is a feature check for the "price tracking", which will return true if the user has the
// feature flag enabled or (if applicable) is in an eligible country and locale.
public boolean isCommercePriceTrackingEnabled() {
if (mNativeShoppingServiceAndroid == 0) return false;
return ShoppingServiceJni.get()
.isCommercePriceTrackingEnabled(mNativeShoppingServiceAndroid, this);
}
// This is a feature check for the "price insights", which will return true
// if the user has the feature flag enabled, has MSBB enabled, and (if
// applicable) is in an eligible country and locale.
public boolean isPriceInsightsEligible() {
if (mNativeShoppingServiceAndroid == 0) return false;
return ShoppingServiceJni.get()
.isPriceInsightsEligible(mNativeShoppingServiceAndroid, this);
}
// This is a feature check for the "discounts on navigation", which will return true
// if the user has the feature flag enabled, has MSBB enabled, and (if
// applicable) is in an eligible country and locale.
public boolean isDiscountEligibleToShowOnNavigation() {
if (mNativeShoppingServiceAndroid == 0) return false;
return ShoppingServiceJni.get()
.isDiscountEligibleToShowOnNavigation(mNativeShoppingServiceAndroid, this);
}
@CalledByNative
private void destroy() {
mNativeShoppingServiceAndroid = 0;
mSubscriptionsObservers.clear();
}
@CalledByNative
private static ShoppingService create(long nativePtr) {
return new ShoppingService(nativePtr);
}
@CalledByNative
private static ProductInfo createProductInfo(
String title,
GURL imageUrl,
boolean hasProductClusterId,
long productClusterId,
boolean hasOfferId,
long offerId,
String currencyCode,
long amountMicros,
String countryCode,
boolean hasPreviousPrice,
long previousAmountMicros) {
Optional<Long> offer = !hasOfferId ? Optional.empty() : Optional.of(offerId);
Optional<Long> cluster =
!hasProductClusterId ? Optional.empty() : Optional.of(productClusterId);
Optional<Long> previousPrice =
!hasPreviousPrice ? Optional.empty() : Optional.of(previousAmountMicros);
return new ProductInfo(
title,
imageUrl,
cluster,
offer,
currencyCode,
amountMicros,
countryCode,
previousPrice);
}
@CalledByNative
private static void runProductInfoCallback(
ProductInfoCallback callback, GURL url, ProductInfo info) {
callback.onResult(url, info);
}
@CalledByNative
private static MerchantInfo createMerchantInfo(
float starRating,
int countRating,
GURL detailsPageUrl,
boolean hasReturnPolicy,
float nonPersonalizedFamilarityScore,
boolean containsSensitiveContent,
boolean proactiveMessageDisabled) {
return new MerchantInfo(
starRating,
countRating,
detailsPageUrl,
hasReturnPolicy,
nonPersonalizedFamilarityScore,
containsSensitiveContent,
proactiveMessageDisabled);
}
@CalledByNative
private static void runMerchantInfoCallback(
MerchantInfoCallback callback, GURL url, MerchantInfo info) {
callback.onResult(url, info);
}
@CalledByNative
private static List<PricePoint> createPricePointAndAddToList(
List<PricePoint> points, String date, long price) {
if (points == null) {
points = new ArrayList<>();
}
PricePoint point = new PricePoint(date, price);
points.add(point);
return points;
}
@CalledByNative
private static PriceInsightsInfo createPriceInsightsInfo(
boolean hasProductClusterId,
long productClusterId,
String currencyCode,
boolean hasTypicalLowPrice,
long typicalLowPriceMicros,
boolean hasTypicalHighPrice,
long typicalHighPriceMicros,
boolean hasCatalogAttributes,
String catalogAttributes,
List<PricePoint> catalogHistoryPrices,
boolean hasJackpotUrl,
GURL jackpotUrl,
int priceBucket,
boolean hasMultipleCatalogs) {
Optional<Long> clusterId =
hasProductClusterId ? Optional.of(productClusterId) : Optional.empty();
Optional<Long> lowPrice =
hasTypicalLowPrice ? Optional.of(typicalLowPriceMicros) : Optional.empty();
Optional<Long> highPrice =
hasTypicalHighPrice ? Optional.of(typicalHighPriceMicros) : Optional.empty();
Optional<String> attributes =
hasCatalogAttributes ? Optional.of(catalogAttributes) : Optional.empty();
Optional<GURL> jackpot = hasJackpotUrl ? Optional.of(jackpotUrl) : Optional.empty();
if (catalogHistoryPrices == null) {
catalogHistoryPrices = new ArrayList<>();
}
return new PriceInsightsInfo(
clusterId,
currencyCode,
lowPrice,
highPrice,
attributes,
catalogHistoryPrices,
jackpot,
priceBucket,
hasMultipleCatalogs);
}
@CalledByNative
private static void runPriceInsightsInfoCallback(
PriceInsightsInfoCallback callback, GURL url, PriceInsightsInfo info) {
callback.onResult(url, info);
}
@CalledByNative
private static void runDiscountInfoCallback(
DiscountInfoCallback callback, GURL url, List<DiscountInfo> infos) {
callback.onResult(url, infos);
}
@CalledByNative
private static CommerceSubscription createSubscription(
int type, int idType, int managementType, String id) {
return new CommerceSubscription(type, idType, id, managementType, null);
}
@CalledByNative
private void onSubscribe(CommerceSubscription sub, boolean succeeded) {
for (SubscriptionsObserver o : mSubscriptionsObservers) {
o.onSubscribe(sub, succeeded);
}
}
@CalledByNative
private void onUnsubscribe(CommerceSubscription sub, boolean succeeded) {
for (SubscriptionsObserver o : mSubscriptionsObservers) {
o.onUnsubscribe(sub, succeeded);
}
}
public static void setShoppingListEligibleForTesting(Boolean eligible) {
sShoppingListEligibleForTestsing = eligible;
ResettersForTesting.register(() -> sShoppingListEligibleForTestsing = null);
}
public static Boolean isShoppingListEligibleForTesting() {
return sShoppingListEligibleForTestsing;
}
@NativeMethods
interface Natives {
void getProductInfoForUrl(
long nativeShoppingServiceAndroid,
ShoppingService caller,
GURL url,
ProductInfoCallback callback);
ProductInfo getAvailableProductInfoForUrl(
long nativeShoppingServiceAndroid, ShoppingService caller, GURL url);
void getMerchantInfoForUrl(
long nativeShoppingServiceAndroid,
ShoppingService caller,
GURL url,
MerchantInfoCallback callback);
void fetchPriceEmailPref(long nativeShoppingServiceAndroid, ShoppingService caller);
void scheduleSavedProductUpdate(long nativeShoppingServiceAndroid, ShoppingService caller);
void subscribe(
long nativeShoppingServiceAndroid,
ShoppingService caller,
int type,
int idType,
int managementType,
String id,
String seenOfferId,
long seenPrice,
String seenCountry,
String seenLocale,
Callback<Boolean> callback);
void unsubscribe(
long nativeShoppingServiceAndroid,
ShoppingService caller,
int type,
int idType,
int managementType,
String id,
Callback<Boolean> callback);
void isSubscribed(
long nativeShoppingServiceAndroid,
ShoppingService caller,
int type,
int idType,
int managementType,
String id,
Callback<Boolean> callback);
boolean isSubscribedFromCache(
long nativeShoppingServiceAndroid,
ShoppingService caller,
int type,
int idType,
int managementType,
String id);
void getAllPriceTrackedBookmarks(
long nativeShoppingServiceAndroid,
ShoppingService caller,
Callback<List<BookmarkId>> callback);
boolean isShoppingListEligible(long nativeShoppingServiceAndroid, ShoppingService caller);
boolean isMerchantViewerEnabled(long nativeShoppingServiceAndroid, ShoppingService caller);
boolean isCommercePriceTrackingEnabled(
long nativeShoppingServiceAndroid, ShoppingService caller);
void getPriceInsightsInfoForUrl(
long nativeShoppingServiceAndroid,
ShoppingService caller,
GURL url,
PriceInsightsInfoCallback callback);
boolean isPriceInsightsEligible(long nativeShoppingServiceAndroid, ShoppingService caller);
void getDiscountInfoForUrl(
long nativeShoppingServiceAndroid,
ShoppingService caller,
GURL url,
DiscountInfoCallback callback);
boolean isDiscountEligibleToShowOnNavigation(
long nativeShoppingServiceAndroid, ShoppingService caller);
}
}