blob: d0480e416ff5350590f856b45753ba097657faf1 [file] [log] [blame]
// 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.
#include "chrome/browser/ui/commerce/price_tracking_page_action_controller.h"
#include "base/check_is_test.h"
#include "base/metrics/histogram_functions.h"
#include "base/strings/string_number_conversions.h"
#include "components/commerce/core/price_tracking_utils.h"
#include "components/commerce/core/shopping_service.h"
#include "components/feature_engagement/public/tracker.h"
#include "components/image_fetcher/core/image_fetcher.h"
namespace commerce {
namespace {
constexpr net::NetworkTrafficAnnotationTag kShoppingListTrafficAnnotation =
net::DefineNetworkTrafficAnnotation("shopping_list_ui_image_fetcher",
R"(
semantics {
sender: "Product image fetcher for the shopping list feature."
description:
"Retrieves the image for a product that is displayed on the active "
"web page. This will be shown to the user as part of the "
"bookmarking or price tracking action."
trigger:
"On navigation, if the URL of the page is determined to be a "
"product that can be price tracked, we will attempt to fetch the "
"image for it."
user_data {
type: NONE
}
data: "No user data."
internal {
contacts {
email: "chrome-shopping@google.com"
}
}
destination: GOOGLE_OWNED_SERVICE
last_reviewed: "2024-01-11"
}
policy {
cookies_allowed: NO
setting:
"This fetch is enabled for any user with the 'Shopping List' "
"feature enabled."
chrome_policy {
ShoppingListEnabled {
policy_options {mode: MANDATORY}
ShoppingListEnabled: false
}
}
})");
constexpr char kImageFetcherUmaClient[] = "ShoppingList";
// The minimum price that the price tracking UI always wants to expand at.
constexpr int64_t kAlwaysExpandChipPriceMicros = 100000000L;
} // namespace
PriceTrackingPageActionController::PriceTrackingPageActionController(
base::RepeatingCallback<void()> notify_callback,
ShoppingService* shopping_service,
image_fetcher::ImageFetcher* image_fetcher,
feature_engagement::Tracker* tracker)
: CommercePageActionController(std::move(notify_callback)),
shopping_service_(shopping_service),
image_fetcher_(image_fetcher),
tracker_(tracker) {
if (shopping_service_) {
scoped_observation_.Observe(shopping_service_);
shopping_service_->WaitForReady(base::BindOnce(
[](base::WeakPtr<PriceTrackingPageActionController> controller,
ShoppingService* service) {
if (!controller || !service) {
return;
}
if (service->IsShoppingListEligible()) {
// Fetching the image may have been blocked by the eligibility
// check, retry.
controller->MaybeDoProductImageFetch(
controller->product_info_for_page_);
controller->NotifyHost();
}
},
weak_ptr_factory_.GetWeakPtr()));
} else {
CHECK_IS_TEST();
}
}
PriceTrackingPageActionController::~PriceTrackingPageActionController() {
// Recording this in the destructor corresponds to the tab being closed or the
// browser shutting down.
MaybeRecordPriceTrackingIconMetrics(/*from_icon_use=*/false);
}
std::optional<bool>
PriceTrackingPageActionController::ShouldShowForNavigation() {
// If the user isn't eligible for the feature, don't block.
if (!shopping_service_ || !shopping_service_->IsShoppingListEligible()) {
return false;
}
// If we got a response from the shopping service but the response was empty,
// we don't need to wait for the image or subscription status.
if (got_product_response_for_page_ && !product_info_for_page_.has_value()) {
return false;
}
// If the page is determined to be a product page, we're "undecided" until we
// can show the current subscription state and have an image for the UI.
if (!got_initial_subscription_status_for_page_ ||
!got_image_response_for_page_) {
return std::nullopt;
}
return !last_fetched_image_.IsEmpty();
}
bool PriceTrackingPageActionController::WantsExpandedUi() {
if (!product_info_for_page_.has_value()) {
return false;
}
CommerceSubscription sub(
SubscriptionType::kPriceTrack, IdentifierType::kProductClusterId,
base::NumberToString(product_info_for_page_->product_cluster_id.value()),
ManagementType::kUserManaged);
bool already_subscribed = shopping_service_->IsSubscribedFromCache(sub);
// Don't expand the chip if the user is already subscribed to the product.
if (!already_subscribed) {
if (tracker_ &&
tracker_->ShouldTriggerHelpUI(
feature_engagement::kIPHPriceTrackingPageActionIconLabelFeature)) {
tracker_->Dismissed(
feature_engagement::kIPHPriceTrackingPageActionIconLabelFeature);
expanded_ui_for_page_ = true;
return true;
}
// If none of the above cases expanded a chip, expand the price tracking
// chip if the product price is > $100.
if (product_info_for_page_->amount_micros > kAlwaysExpandChipPriceMicros &&
product_info_for_page_->product_cluster_id.has_value()) {
expanded_ui_for_page_ = true;
return true;
}
}
return false;
}
void PriceTrackingPageActionController::ResetForNewNavigation(const GURL& url) {
if (!shopping_service_->IsShoppingListEligible()) {
return;
}
// The page action icon may not have been used for the last page load. If
// that's the case, make sure we record it.
MaybeRecordPriceTrackingIconMetrics(/*from_icon_use=*/false);
// Cancel any pending callbacks.
weak_ptr_factory_.InvalidateWeakPtrs();
is_cluster_id_tracked_by_user_ = false;
last_fetched_image_ = gfx::Image();
last_fetched_image_url_ = GURL();
current_url_ = url;
got_product_response_for_page_ = false;
got_image_response_for_page_ = false;
got_initial_subscription_status_for_page_ = false;
expanded_ui_for_page_ = false;
icon_use_recorded_for_page_ = false;
// Initiate an update for the icon on navigation since we may not have product
// info.
NotifyHost();
shopping_service_->GetProductInfoForUrl(
url, base::BindOnce(
&PriceTrackingPageActionController::HandleProductInfoResponse,
weak_ptr_factory_.GetWeakPtr()));
}
void PriceTrackingPageActionController::OnIconClicked() {
MaybeRecordPriceTrackingIconMetrics(/*from_icon_use=*/true);
}
void PriceTrackingPageActionController::MaybeRecordPriceTrackingIconMetrics(
bool from_icon_use) {
// Ignore cases where these is no cluster ID or the metric was already
// recorded for the page.
if (!cluster_id_for_page_.has_value() || icon_use_recorded_for_page_) {
return;
}
icon_use_recorded_for_page_ = true;
// Ignore any instances where the product is already tracked. This will not
// stop cases where the icon is being used to newly track a product since
// this logic will run prior to subscriptions updating.
if (is_cluster_id_tracked_by_user_) {
return;
}
std::string histogram_name = "Commerce.PriceTracking.IconInteractionState";
// Clicking the icon for a product that is already tracked does not
// immediately untrack the product. If we made it this far, we can assume the
// interaction was to track a product, otherwise we would have been blocked
// above.
if (from_icon_use) {
if (expanded_ui_for_page_) {
base::UmaHistogramEnumeration(
histogram_name, PageActionIconInteractionState::kClickedExpanded);
} else {
base::UmaHistogramEnumeration(histogram_name,
PageActionIconInteractionState::kClicked);
}
} else {
if (expanded_ui_for_page_) {
base::UmaHistogramEnumeration(
histogram_name, PageActionIconInteractionState::kNotClickedExpanded);
} else {
base::UmaHistogramEnumeration(
histogram_name, PageActionIconInteractionState::kNotClicked);
}
}
}
void PriceTrackingPageActionController::HandleProductInfoResponse(
const GURL& url,
const std::optional<const ProductInfo>& info) {
if (url != current_url_ || !info.has_value()) {
got_product_response_for_page_ = true;
NotifyHost();
return;
}
product_info_for_page_ = info;
got_product_response_for_page_ = true;
if (shopping_service_->IsShoppingListEligible() && CanTrackPrice(info) &&
!info->image_url.is_empty()) {
cluster_id_for_page_.emplace(info->product_cluster_id.value());
UpdatePriceTrackingStateFromSubscriptions();
MaybeDoProductImageFetch(info);
}
}
void PriceTrackingPageActionController::MaybeDoProductImageFetch(
const std::optional<ProductInfo>& info) {
if (!shopping_service_->IsShoppingListEligible() || !CanTrackPrice(info) ||
info->image_url.is_empty() || !this->last_fetched_image_.IsEmpty()) {
return;
}
// TODO(crbug.com/40863328): Delay this fetch by possibly waiting until page
// load has
// finished.
image_fetcher_->FetchImage(
info.value().image_url,
base::BindOnce(
&PriceTrackingPageActionController::HandleImageFetcherResponse,
weak_ptr_factory_.GetWeakPtr(), info.value().image_url),
image_fetcher::ImageFetcherParams(kShoppingListTrafficAnnotation,
kImageFetcherUmaClient));
}
void PriceTrackingPageActionController::
UpdatePriceTrackingStateFromSubscriptions() {
if (!cluster_id_for_page_.has_value()) {
return;
}
shopping_service_->IsSubscribed(
BuildUserSubscriptionForClusterId(cluster_id_for_page_.value()),
base::BindOnce(
[](base::WeakPtr<PriceTrackingPageActionController> controller,
bool is_tracked) {
if (!controller) {
return;
}
controller->is_cluster_id_tracked_by_user_ = is_tracked;
controller->got_initial_subscription_status_for_page_ = true;
controller->NotifyHost();
},
weak_ptr_factory_.GetWeakPtr()));
}
void PriceTrackingPageActionController::OnSubscribe(
const CommerceSubscription& subscription,
bool succeeded) {
HandleSubscriptionChange(subscription);
}
void PriceTrackingPageActionController::OnUnsubscribe(
const CommerceSubscription& subscription,
bool succeeded) {
HandleSubscriptionChange(subscription);
}
void PriceTrackingPageActionController::HandleSubscriptionChange(
const CommerceSubscription& sub) {
if (sub.id_type == IdentifierType::kProductClusterId &&
sub.id == base::NumberToString(
cluster_id_for_page_.value_or(kInvalidSubscriptionId))) {
UpdatePriceTrackingStateFromSubscriptions();
NotifyHost();
}
}
void PriceTrackingPageActionController::HandleImageFetcherResponse(
const GURL image_url,
const gfx::Image& image,
const image_fetcher::RequestMetadata& request_metadata) {
got_image_response_for_page_ = true;
if (!image.IsEmpty()) {
last_fetched_image_url_ = image_url;
last_fetched_image_ = image;
}
NotifyHost();
}
const gfx::Image& PriceTrackingPageActionController::GetLastFetchedImage() {
return last_fetched_image_;
}
const GURL& PriceTrackingPageActionController::GetLastFetchedImageUrl() {
return last_fetched_image_url_;
}
bool PriceTrackingPageActionController::IsPriceTrackingCurrentProduct() {
return is_cluster_id_tracked_by_user_;
}
void PriceTrackingPageActionController::SetImageFetcherForTesting(
image_fetcher::ImageFetcher* image_fetcher) {
image_fetcher_ = image_fetcher;
}
} // namespace commerce