// 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.
#import "ios/chrome/browser/ui/price_notifications/price_notifications_price_tracking_mediator.h"
#import "base/memory/raw_ptr.h"
#import "base/metrics/histogram_functions.h"
#import "base/strings/string_number_conversions.h"
#import "base/strings/sys_string_conversions.h"
#import "base/task/bind_post_task.h"
#import "components/commerce/core/commerce_constants.h"
#import "components/commerce/core/price_tracking_utils.h"
#import "components/commerce/core/shopping_service.h"
#import "components/image_fetcher/core/image_data_fetcher.h"
#import "components/payments/core/currency_formatter.h"
#import "components/power_bookmarks/core/power_bookmark_utils.h"
#import "components/power_bookmarks/core/proto/power_bookmark_meta.pb.h"
#import "components/power_bookmarks/core/proto/shopping_specifics.pb.h"
#import "ios/chrome/browser/price_insights/coordinator/price_insights_consumer.h"
#import "ios/chrome/browser/push_notification/model/push_notification_client_id.h"
#import "ios/chrome/browser/push_notification/model/push_notification_service.h"
#import "ios/chrome/browser/push_notification/model/push_notification_util.h"
#import "ios/chrome/browser/shared/public/commands/bookmarks_commands.h"
#import "ios/chrome/browser/shared/public/commands/price_notifications_commands.h"
#import "ios/chrome/browser/tabs/model/tab_title_util.h"
#import "ios/chrome/browser/ui/price_notifications/cells/price_notifications_table_view_item.h"
#import "ios/chrome/browser/ui/price_notifications/price_notifications_alert_presenter.h"
#import "ios/chrome/browser/ui/price_notifications/price_notifications_consumer.h"
#import "ios/web/public/web_state.h"
#import "url/gurl.h"
namespace {
// The histogram used to record a product's new tracking state when a user
// initates a state change.
const char kPriceTrackingStatusHistogram[] =
"Commerce.PriceTracking.IOS.PriceTracking.ProductStatus";
// The histogram used to record a product's new tracking state when a user
// initates a state change.
const char kPriceInsightsTrackingStatusHistogram[] =
"Commerce.PriceTracking.IOS.PriceInsights.ProductStatus";
// This enum is used to represent the different tracking states a product can
// observe.
enum class PriceNotificationProductStatus {
kTrack,
kUntrack,
kMaxValue = kUntrack
};
// This enum is used to represent the different sources of tracking a product.
enum class PriceNotificationTrackingSource {
kPriceTracking,
kPriceInsights,
kMaxValue = kPriceInsights
};
} // namespace
using PriceNotificationItems =
NSMutableArray<PriceNotificationsTableViewItem*>*;
@interface PriceNotificationsPriceTrackingMediator () {
// The service responsible for fetching a product's image data.
std::unique_ptr<image_fetcher::ImageDataFetcher> _imageFetcher;
}
// The service responsible for interacting with commerce's price data
// infrastructure.
@property(nonatomic, assign) commerce::ShoppingService* shoppingService;
// The service responsible for managing bookmarks.
@property(nonatomic, readonly) bookmarks::BookmarkModel* bookmarkModel;
// The current browser state's webstate.
@property(nonatomic, assign) base::WeakPtr<web::WebState> webState;
// The product data for the product contained on the site the user is currently
// viewing.
@property(nonatomic, assign) std::optional<commerce::ProductInfo>
currentSiteProductInfo;
// The service responsible for updating the user's chrome-level push
// notification permissions for Price Tracking.
@property(nonatomic, assign) PushNotificationService* pushNotificationService;
@end
@implementation PriceNotificationsPriceTrackingMediator
- (instancetype)
initWithShoppingService:(commerce::ShoppingService*)service
bookmarkModel:(bookmarks::BookmarkModel*)bookmarkModel
imageFetcher:
(std::unique_ptr<image_fetcher::ImageDataFetcher>)fetcher
webState:(base::WeakPtr<web::WebState>)webState
pushNotificationService:(PushNotificationService*)pushNotificationService {
self = [super init];
if (self) {
DCHECK(service);
DCHECK(bookmarkModel);
DCHECK(fetcher);
DCHECK(webState);
DCHECK(pushNotificationService);
_shoppingService = service;
_bookmarkModel = bookmarkModel;
_imageFetcher = std::move(fetcher);
_webState = webState;
_pushNotificationService = pushNotificationService;
}
return self;
}
- (void)setConsumer:(id<PriceNotificationsConsumer>)consumer {
if (_consumer == consumer) {
return;
}
_consumer = consumer;
[self fetchPriceTrackingData];
}
- (void)setPriceInsightsConsumer:(id<PriceInsightsConsumer>)consumer {
if (_priceInsightsConsumer == consumer) {
return;
}
_priceInsightsConsumer = consumer;
}
#pragma mark - PriceNotificationsMutator
- (void)trackItem:(PriceNotificationsTableViewItem*)item {
__weak PriceNotificationsPriceTrackingMediator* weakSelf = self;
[self presentNotificationPermission:^(BOOL granted, BOOL promptShown,
NSError* error) {
if (!error && !promptShown && !granted) {
// This callback can be executed on a background thread, make sure the UI
// is displayed on the main thread.
dispatch_async(dispatch_get_main_queue(), ^{
[weakSelf.presenter presentPushNotificationPermissionAlert];
});
} else if (!error && promptShown && granted) {
// This callback can be executed on a background thread causing this
// function to fail. Thus, the invocation is scheduled to run on the main
// thread.
dispatch_async(dispatch_get_main_queue(), ^{
weakSelf.pushNotificationService->SetPreference(
weakSelf.gaiaID, PushNotificationClientId::kCommerce, true);
});
}
}];
[self trackForURL:item.entryURL
title:item.title
completionHandler:^(bool success) {
[weakSelf didTrackItem:item successfully:success];
}];
}
- (void)stopTrackingItem:(PriceNotificationsTableViewItem*)item {
__weak PriceNotificationsPriceTrackingMediator* weakSelf = self;
[self stopTrackingForURL:item.entryURL
withCompletionHandler:^(bool success) {
if (!success) {
[weakSelf.presenter presentStopPriceTrackingErrorAlertForItem:item];
return;
}
[weakSelf didStopTrackingItem:item];
}];
}
- (void)navigateToWebpageForItem:(PriceNotificationsTableViewItem*)item {
DCHECK(item.tracking);
[self navigateToWebpageForURL:item.entryURL
disposition:WindowOpenDisposition::CURRENT_TAB];
[self.handler hidePriceNotifications];
}
- (void)navigateToBookmarks {
[self.handler hidePriceNotifications];
GURL URL = _webState->GetLastCommittedURL();
[self.bookmarksHandler openToExternalBookmark:URL];
}
#pragma mark - PriceInsightsMutator
- (void)tryPriceInsightsTrackItem:(PriceInsightsItem*)item {
__weak PriceNotificationsPriceTrackingMediator* weakSelf = self;
auto callback = base::BindPostTask(
base::SequencedTaskRunner::GetCurrentDefault(),
base::BindOnce(^(BOOL granted, BOOL promptShown, NSError* error) {
[weakSelf onNotificationPermissionRequestForItem:item
permissionGranted:granted
promptShown:promptShown
error:error];
}));
[self
presentNotificationPermission:base::CallbackToBlock(std::move(callback))];
}
- (void)priceInsightsTrackItem:(PriceInsightsItem*)item
notificationsGranted:(BOOL)granted
showCompletion:(BOOL)showCompletion {
__weak PriceNotificationsPriceTrackingMediator* weakSelf = self;
[self trackForURL:item.productURL
title:item.title
completionHandler:^(bool success) {
[weakSelf onPriceInsightsTrackItem:item
success:success
permissionGranted:granted
showCompletion:showCompletion];
}];
}
- (void)priceInsightsStopTrackingItem:(PriceInsightsItem*)item {
__weak PriceNotificationsPriceTrackingMediator* weakSelf = self;
[self stopTrackingForURL:item.productURL
clusterId:item.clusterId
withCompletionHandler:^(bool success) {
[weakSelf onPriceInsightsStopTrackingItem:item success:success];
}];
}
- (void)priceInsightsNavigateToWebpageForItem:(PriceInsightsItem*)item {
DCHECK(item.buyingOptionsURL.is_valid());
[self navigateToWebpageForURL:item.buyingOptionsURL
disposition:WindowOpenDisposition::NEW_FOREGROUND_TAB];
[self.priceInsightsConsumer
didStartNavigationToWebpageWithPriceBucket:item.priceBucket];
}
#pragma mark - Private
// This function fetches the product data for the item on the currently visible
// page and populates the data into the Price Notifications UI.
- (void)fetchTrackableItemDataAtSite:(const GURL&)URL {
if ([self isPriceTrackingURL:URL]) {
[self.consumer setTrackableItem:nil currentlyTracking:YES];
return;
}
[self displayProduct:self.currentSiteProductInfo fromSite:URL];
}
// Creates a `PriceNotificationsTableViewItem` object and sends the newly
// created object to the Price Notifications UI.
- (void)displayProduct:(const std::optional<commerce::ProductInfo>&)productInfo
fromSite:(const GURL&)URL {
if (!commerce::CanTrackPrice(productInfo)) {
[self.consumer setTrackableItem:nil currentlyTracking:NO];
return;
}
__weak PriceNotificationsPriceTrackingMediator* weakSelf = self;
PriceNotificationsTableViewItem* item =
[self createPriceNotificationTableViewItem:NO
fromProductInfo:productInfo
atURL:URL];
self.shoppingService->IsSubscribed(
commerce::BuildUserSubscriptionForClusterId(
productInfo->product_cluster_id.value()),
base::BindOnce(^(bool isTracked) {
[weakSelf.consumer setTrackableItem:item currentlyTracking:isTracked];
}));
// Fetches the current item's trackable image.
_imageFetcher->FetchImageData(
productInfo->image_url,
base::BindOnce(^(const std::string& imageData,
const image_fetcher::RequestMetadata& metadata) {
[weakSelf updateItem:item withImage:imageData];
}),
NO_TRAFFIC_ANNOTATION_YET);
}
// Adds the downloaded product image to the `PriceNotificationsTableViewItem`
// and sends the amended item to the Price Notifications UI.
- (void)updateItem:(PriceNotificationsTableViewItem*)item
withImage:(const std::string&)imageData {
NSData* data = [NSData dataWithBytes:imageData.data()
length:imageData.size()];
if (data) {
item.productImage = [UIImage imageWithData:data
scale:[UIScreen mainScreen].scale];
}
[self.consumer reconfigureCellsForItems:@[ item ]];
}
// Creates a localized price string.
- (NSString*)extractFormattedCurrentPrice:(BOOL)forCurrentPrice
fromProductInfo:
(const std::optional<commerce::ProductInfo>&)
productInfo {
if (!productInfo) {
return nil;
}
if (!forCurrentPrice && !productInfo->previous_amount_micros) {
return nil;
}
int64_t amountMicro = forCurrentPrice
? productInfo->amount_micros
: productInfo->previous_amount_micros.value();
float price = static_cast<float>(amountMicro) /
static_cast<float>(commerce::kToMicroCurrency);
payments::CurrencyFormatter formatter(productInfo->currency_code,
productInfo->country_code);
formatter.SetMaxFractionalDigits(2);
return base::SysUTF16ToNSString(
formatter.Format(base::NumberToString(price)));
}
// This function handles the response from the user attempting to subscribe to
// an item with the ShoppingService.
- (void)didTrackItem:(PriceNotificationsTableViewItem*)trackableItem
successfully:(BOOL)success {
if (!success) {
[self.presenter presentStartPriceTrackingErrorAlertForItem:trackableItem];
return;
}
trackableItem.tracking = YES;
[self.consumer reconfigureCellsForItems:@[ trackableItem ]];
[self.consumer didStartPriceTrackingForItem:trackableItem];
[self recordProductStatusFromSource:PriceNotificationTrackingSource::
kPriceTracking
status:PriceNotificationProductStatus::kTrack];
}
// This function handles the response from the user attempting to unsubscribe to
// an item with the ShoppingService.
- (void)didStopTrackingItem:(PriceNotificationsTableViewItem*)item {
__weak PriceNotificationsPriceTrackingMediator* weakSelf = self;
self.shoppingService->GetProductInfoForUrl(
item.entryURL,
base::BindOnce(^(
const GURL& productURL,
const std::optional<const commerce::ProductInfo>& productInfo) {
PriceNotificationsPriceTrackingMediator* strongSelf = weakSelf;
if (!strongSelf) {
return;
}
BOOL isProductOnCurrentSite =
[strongSelf isCurrentSiteEqualToProductInfo:productInfo];
[strongSelf.consumer didStopPriceTrackingItem:item
onCurrentSite:isProductOnCurrentSite];
}));
[self recordProductStatusFromSource:PriceNotificationTrackingSource::
kPriceTracking
status:PriceNotificationProductStatus::kUntrack];
}
// This function fetches the product data for the items the user has subscribed
// to and populates the data into the Price Notifications UI.
- (void)fetchTrackedItems {
__weak PriceNotificationsPriceTrackingMediator* weakSelf = self;
self.shoppingService->GetAllPriceTrackedBookmarks(
base::BindOnce(
^(std::vector<const bookmarks::BookmarkNode*> subscribedItems) {
if (!weakSelf) {
return;
}
for (const bookmarks::BookmarkNode* bookmark : subscribedItems) {
std::unique_ptr<power_bookmarks::PowerBookmarkMeta> meta =
power_bookmarks::GetNodePowerBookmarkMeta(
weakSelf.bookmarkModel, bookmark);
if (!meta || !meta->has_shopping_specifics()) {
continue;
}
const power_bookmarks::ShoppingSpecifics specifics =
meta->shopping_specifics();
// To build the PriceNotificationTableViewItem for product on
// current page which are not being tracked, we have to use its
// ProductInfo. To avoid duplicate APIs, here we also convert
// BookmarkMeta to ProductInfo to build the
// PriceNotificationTableViewItem for tracked products, instead of
// passing BookmarkMeta directly.
std::optional<commerce::ProductInfo> info;
info.emplace();
info->title = specifics.title();
info->image_url = GURL(meta->lead_image().url());
if (specifics.has_product_cluster_id()) {
info->product_cluster_id.emplace(
specifics.product_cluster_id());
}
if (specifics.has_offer_id()) {
info->offer_id.emplace(specifics.offer_id());
}
info->currency_code = specifics.current_price().currency_code();
info->amount_micros = specifics.current_price().amount_micros();
info->country_code = specifics.country_code();
if (specifics.has_previous_price() &&
specifics.previous_price().amount_micros() >
specifics.current_price().amount_micros()) {
info->previous_amount_micros.emplace(
specifics.previous_price().amount_micros());
}
[weakSelf addTrackedItem:info fromSite:bookmark->url()];
}
}));
}
// Retrieves the product data for the items the user has subscribed to and the
// item contained on the webpage the user is currently viewing.
- (void)fetchPriceTrackingData {
const GURL& currentSiteURL = self.webState->GetVisibleURL();
__weak PriceNotificationsPriceTrackingMediator* weakSelf = self;
self.shoppingService->GetProductInfoForUrl(
currentSiteURL,
base::BindOnce(
^(const GURL& productURL,
const std::optional<const commerce::ProductInfo>& productInfo) {
PriceNotificationsPriceTrackingMediator* strongSelf = weakSelf;
if (!strongSelf) {
return;
}
strongSelf.currentSiteProductInfo = productInfo;
[strongSelf fetchTrackableItemDataAtSite:currentSiteURL];
[strongSelf fetchTrackedItems];
}));
}
// Creates a `PriceNotificationsTableViewItem` object and sends the newly
// created object to the Price Notifications UI.
- (void)addTrackedItem:(const std::optional<commerce::ProductInfo>&)productInfo
fromSite:(const GURL&)URL {
if (!productInfo) {
return;
}
PriceNotificationsTableViewItem* item =
[self createPriceNotificationTableViewItem:YES
fromProductInfo:productInfo
atURL:URL];
[self.consumer
addTrackedItem:item
toBeginning:[self isCurrentSiteEqualToProductInfo:productInfo]];
__weak PriceNotificationsPriceTrackingMediator* weakSelf = self;
// Fetches the current item's trackable image.
_imageFetcher->FetchImageData(
productInfo->image_url,
base::BindOnce(^(const std::string& imageData,
const image_fetcher::RequestMetadata& metadata) {
[weakSelf updateItem:item withImage:imageData];
}),
NO_TRAFFIC_ANNOTATION_YET);
}
// Creates and initializes the values of a new PriceNotificationTableViewItem
// based on the given `productInfo` object.
- (PriceNotificationsTableViewItem*)
createPriceNotificationTableViewItem:(BOOL)forTrackedItem
fromProductInfo:
(const std::optional<commerce::ProductInfo>&)
productInfo
atURL:(const GURL&)URL {
PriceNotificationsTableViewItem* item =
[[PriceNotificationsTableViewItem alloc] initWithType:0];
item.title = base::SysUTF8ToNSString(productInfo->title);
item.entryURL = URL;
item.tracking = forTrackedItem;
item.currentPrice = [self extractFormattedCurrentPrice:YES
fromProductInfo:productInfo];
item.previousPrice = [self extractFormattedCurrentPrice:NO
fromProductInfo:productInfo];
if (!item.previousPrice) {
item.previousPrice = item.currentPrice;
item.currentPrice = nil;
}
return item;
}
// Compares two commerce::ProductInfo objects for equality based on the
// `product_cluster_id` property.
- (BOOL)isCurrentSiteEqualToProductInfo:
(const std::optional<commerce::ProductInfo>&)productInfo {
if (!productInfo || !productInfo->product_cluster_id.has_value() ||
!self.currentSiteProductInfo ||
!self.currentSiteProductInfo->product_cluster_id.has_value()) {
return false;
}
return productInfo->product_cluster_id.value() ==
self.currentSiteProductInfo->product_cluster_id.value();
}
// Checks if the item being offered at `URL` is already
// bookmarked and being price tracked.
- (BOOL)isPriceTrackingURL:(const GURL&)URL {
if (!self.bookmarkModel->IsBookmarked(URL)) {
return false;
}
for (const bookmarks::BookmarkNode* node :
self.bookmarkModel->GetNodesByURL(URL)) {
std::unique_ptr<power_bookmarks::PowerBookmarkMeta> meta =
power_bookmarks::GetNodePowerBookmarkMeta(self.bookmarkModel, node);
if (!meta || !meta->has_shopping_specifics() ||
!meta->shopping_specifics().has_product_cluster_id()) {
continue;
}
// TODO: b/355423868 - This should use the async version of IsSubscribed.
if (self.shoppingService->IsSubscribedFromCache(
commerce::BuildUserSubscriptionForClusterId(
meta->shopping_specifics().product_cluster_id()))) {
return true;
}
}
return false;
}
- (void)recordProductStatusFromSource:(PriceNotificationTrackingSource)source
status:(PriceNotificationProductStatus)status {
switch (source) {
case PriceNotificationTrackingSource::kPriceTracking:
base::UmaHistogramEnumeration(kPriceTrackingStatusHistogram, status);
break;
case PriceNotificationTrackingSource::kPriceInsights:
base::UmaHistogramEnumeration(kPriceInsightsTrackingStatusHistogram,
status);
break;
}
}
- (void)navigateToWebpageForURL:(const GURL&)URL
disposition:(WindowOpenDisposition)disposition {
self.webState->OpenURL(web::WebState::OpenURLParams(
URL, web::Referrer(), disposition, ui::PAGE_TRANSITION_GENERATED,
/*is_renderer_initiated=*/false));
}
- (void)stopTrackingForURL:(const GURL&)URL
withCompletionHandler:(void (^)(BOOL success))completionHandler {
// Retrieve the bookmark node for the given URL.
const bookmarks::BookmarkNode* bookmark =
self.bookmarkModel->GetMostRecentlyAddedUserNodeForURL(URL);
if (!bookmark) {
return;
}
commerce::SetPriceTrackingStateForBookmark(
self.shoppingService, self.bookmarkModel, bookmark, false,
base::BindOnce(completionHandler));
}
// Stops tracking a product's price by URL or cluster ID.
- (void)stopTrackingForURL:(const GURL&)URL
clusterId:(uint64_t)clusterId
withCompletionHandler:(void (^)(BOOL success))completionHandler {
// Retrieve the bookmark node for the given URL.
const bookmarks::BookmarkNode* bookmark =
self.bookmarkModel->GetMostRecentlyAddedUserNodeForURL(URL);
if (!bookmark) {
// If the URL isn't bookmarked, try to stop tracking for the given cluster
// ID.
commerce::SetPriceTrackingStateForClusterId(
self.shoppingService, self.bookmarkModel, clusterId, false,
base::BindOnce(completionHandler));
return;
}
commerce::SetPriceTrackingStateForBookmark(
self.shoppingService, self.bookmarkModel, bookmark, false,
base::BindOnce(completionHandler));
}
- (void)presentNotificationPermission:
(void (^)(BOOL granted, BOOL promptShown, NSError* error))
completionHandler {
// Requests push notification permission. This will determine whether the user
// receives price tracking notifications to the current device. However, the
// device's permission status will not prevent the shopping service from
// subscribing the user to the product and its price tracking events.
[PushNotificationUtil requestPushNotificationPermission:completionHandler];
}
- (void)trackForURL:(const GURL&)URL
title:(NSString*)title
completionHandler:(void (^)(BOOL success))completionHandler {
// The price tracking infrastructure is built on top of bookmarks, so a new
// bookmark needs to be created before the item can be registered for price
// tracking.
const bookmarks::BookmarkNode* bookmark =
self.bookmarkModel->GetMostRecentlyAddedUserNodeForURL(URL);
bool isNewBookmark = bookmark == nullptr;
if (!bookmark) {
const bookmarks::BookmarkNode* defaultFolder =
self.bookmarkModel->account_mobile_node();
if (!defaultFolder) {
// Cannot track URL: the user is likely signed out.
base::SequencedTaskRunner::GetCurrentDefault()->PostTask(
FROM_HERE, base::BindOnce(completionHandler, false));
return;
}
bookmark = self.bookmarkModel->AddURL(defaultFolder,
defaultFolder->children().size(),
base::SysNSStringToUTF16(title), URL);
}
commerce::SetPriceTrackingStateForBookmark(
self.shoppingService, self.bookmarkModel, bookmark, true,
base::BindOnce(completionHandler), isNewBookmark);
}
// Callback invoked after requesting push notification permission.
- (void)onNotificationPermissionRequestForItem:(PriceInsightsItem*)item
permissionGranted:(BOOL)granted
promptShown:(BOOL)promptShown
error:(NSError*)error {
if (error) {
[self priceInsightsTrackItem:item
notificationsGranted:false
showCompletion:true];
return;
}
if (!promptShown && !granted) {
[self.priceInsightsConsumer presentPushNotificationPermissionAlert];
return;
}
if (promptShown && granted) {
self.pushNotificationService->SetPreference(
self.gaiaID, PushNotificationClientId::kCommerce, true);
}
[self priceInsightsTrackItem:item
notificationsGranted:granted
showCompletion:true];
}
// Callback invoked after requesting to track an item.
- (void)onPriceInsightsTrackItem:(PriceInsightsItem*)item
success:(BOOL)success
permissionGranted:(BOOL)granted
showCompletion:(BOOL)showCompletion {
if (!success) {
[self.priceInsightsConsumer presentStartPriceTrackingErrorAlert];
return;
}
[self.priceInsightsConsumer
didStartPriceTrackingWithNotification:granted
showCompletion:showCompletion];
[self recordProductStatusFromSource:PriceNotificationTrackingSource::
kPriceInsights
status:PriceNotificationProductStatus::kTrack];
}
// Callback invoked after requesting to stop tracking an item.
- (void)onPriceInsightsStopTrackingItem:(PriceInsightsItem*)item
success:(BOOL)success {
if (!success) {
[self.priceInsightsConsumer presentStopPriceTrackingErrorAlert];
return;
}
[self recordProductStatusFromSource:PriceNotificationTrackingSource::
kPriceInsights
status:PriceNotificationProductStatus::kUntrack];
[self.priceInsightsConsumer didStopPriceTracking];
}
@end