blob: 2415c87fa0e7574d50251165cf05e5688dfc9882 [file] [log] [blame]
// 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.
#include "components/commerce/core/price_tracking_utils.h"
#include <memory>
#include <unordered_set>
#include "base/feature_list.h"
#include "base/memory/weak_ptr.h"
#include "base/strings/string_number_conversions.h"
#include "base/task/sequenced_task_runner.h"
#include "base/time/time.h"
#include "base/uuid.h"
#include "components/bookmarks/browser/bookmark_model.h"
#include "components/bookmarks/browser/bookmark_node.h"
#include "components/bookmarks/browser/bookmark_uuids.h"
#include "components/bookmarks/common/bookmark_metrics.h"
#include "components/commerce/core/commerce_feature_list.h"
#include "components/commerce/core/pref_names.h"
#include "components/commerce/core/shopping_service.h"
#include "components/commerce/core/subscriptions/commerce_subscription.h"
#include "components/power_bookmarks/core/power_bookmark_utils.h"
#include "components/power_bookmarks/core/proto/shopping_specifics.pb.h"
#include "components/prefs/pref_service.h"
#include "components/strings/grit/components_strings.h"
#include "ui/base/l10n/l10n_util.h"
namespace commerce {
namespace {
// Update the bookmarks affected by the subscribe or unsubscribe event if it was
// successful.
void UpdateBookmarksForSubscriptionsResult(
base::WeakPtr<bookmarks::BookmarkModel> model,
base::OnceCallback<void(bool)> callback,
bool enabled,
uint64_t cluster_id,
bool success) {
if (success) {
power_bookmarks::PowerBookmarkQueryFields query;
query.type = power_bookmarks::PowerBookmarkType::SHOPPING;
std::vector<const bookmarks::BookmarkNode*> results =
power_bookmarks::GetBookmarksMatchingProperties(model.get(), query, -1);
for (const bookmarks::BookmarkNode* node : results) {
CHECK(node);
if (model->IsLocalOnlyNode(*node)) {
continue;
}
std::unique_ptr<power_bookmarks::PowerBookmarkMeta> meta =
power_bookmarks::GetNodePowerBookmarkMeta(model.get(), node);
if (!meta)
continue;
power_bookmarks::ShoppingSpecifics* specifics =
meta->mutable_shopping_specifics();
if (!specifics || specifics->product_cluster_id() != cluster_id)
continue;
// TODO(b:273526228): Once crrev.com/c/4278641 reaches stable, remove this
// call -- shopping specifics no longer tracks
// subscription state.
specifics->set_is_price_tracked(enabled);
// Always use the Windows epoch to keep consistency. This also align with
// how we set the time fields in the bookmark_specifics.proto and in the
// subscriptions_manager.cc.
specifics->set_last_subscription_change_time(
base::Time::Now().ToDeltaSinceWindowsEpoch().InMicroseconds());
power_bookmarks::SetNodePowerBookmarkMeta(model.get(), node,
std::move(meta));
}
}
std::move(callback).Run(success);
}
} // namespace
void IsBookmarkPriceTracked(ShoppingService* service,
bookmarks::BookmarkModel* model,
const bookmarks::BookmarkNode* node,
base::OnceCallback<void(bool)> callback) {
std::unique_ptr<power_bookmarks::PowerBookmarkMeta> meta =
power_bookmarks::GetNodePowerBookmarkMeta(model, node);
if (!meta || !meta->has_shopping_specifics() ||
!meta->shopping_specifics().has_product_cluster_id()) {
base::SequencedTaskRunner::GetCurrentDefault()->PostTask(
FROM_HERE, base::BindOnce(std::move(callback), false));
return;
}
CommerceSubscription sub(
SubscriptionType::kPriceTrack, IdentifierType::kProductClusterId,
base::NumberToString(meta->shopping_specifics().product_cluster_id()),
ManagementType::kUserManaged);
service->IsSubscribed(sub, std::move(callback));
}
bool IsProductBookmark(bookmarks::BookmarkModel* model,
const bookmarks::BookmarkNode* node) {
std::unique_ptr<power_bookmarks::PowerBookmarkMeta> meta =
power_bookmarks::GetNodePowerBookmarkMeta(model, node);
return meta && meta->has_shopping_specifics();
}
std::optional<int64_t> GetBookmarkLastSubscriptionChangeTime(
bookmarks::BookmarkModel* model,
const bookmarks::BookmarkNode* node) {
std::unique_ptr<power_bookmarks::PowerBookmarkMeta> meta =
power_bookmarks::GetNodePowerBookmarkMeta(model, node);
if (!meta || !meta->has_shopping_specifics() ||
!meta->shopping_specifics().has_last_subscription_change_time()) {
return std::nullopt;
}
return std::make_optional<int64_t>(
meta->shopping_specifics().last_subscription_change_time());
}
void SetPriceTrackingStateForClusterId(
ShoppingService* service,
bookmarks::BookmarkModel* model,
const uint64_t cluster_id,
bool enabled,
base::OnceCallback<void(bool)> callback) {
if (!service || !model) {
base::SequencedTaskRunner::GetCurrentDefault()->PostTask(
FROM_HERE, base::BindOnce(std::move(callback), false));
return;
}
std::vector<const bookmarks::BookmarkNode*> product_bookmarks =
GetBookmarksWithClusterId(model, cluster_id, 1);
if (product_bookmarks.empty()) {
base::SequencedTaskRunner::GetCurrentDefault()->PostTask(
FROM_HERE, base::BindOnce(std::move(callback), false));
return;
}
SetPriceTrackingStateForBookmark(service, model, product_bookmarks[0],
enabled, std::move(callback));
}
void SetPriceTrackingStateForBookmark(
ShoppingService* service,
bookmarks::BookmarkModel* model,
const bookmarks::BookmarkNode* node,
bool enabled,
base::OnceCallback<void(bool)> callback,
bool was_bookmark_created_by_price_tracking,
std::optional<ProductInfo> product_info) {
if (!service || !model || !node || model->IsLocalOnlyNode(*node)) {
base::SequencedTaskRunner::GetCurrentDefault()->PostTask(
FROM_HERE, base::BindOnce(std::move(callback), false));
return;
}
std::unique_ptr<power_bookmarks::PowerBookmarkMeta> meta =
power_bookmarks::GetNodePowerBookmarkMeta(model, node);
// If there's no existing meta, check the shopping service. Bookmarks added
// prior to making shopping meta available should still be trackable upon
// revisiting the page. This logic is here since it's the result of a direct
// user action, we don't yet want to passively update "normal" bookmarks.
if (!meta || !meta->has_shopping_specifics()) {
std::optional<ProductInfo> info =
service->GetAvailableProductInfoForUrl(node->url());
// ProductInfo can be passed in optionally and used in the event
// that ShoppingService isn't aware of the ProductInfo.
// Ideally use GetProductInfoForUrls() (where a fallback is
// automatically provided) instead of
// GetAvailableProductInfoForUrl() above when synced Tabs are
// supported in Shopping Service TODO(crbug.com/410811501).
if (!info.has_value() && product_info.has_value()) {
info = product_info;
}
// If still no information, do nothing.
if (!info.has_value()) {
base::SequencedTaskRunner::GetCurrentDefault()->PostTask(
FROM_HERE, base::BindOnce(std::move(callback), false));
return;
}
std::unique_ptr<power_bookmarks::PowerBookmarkMeta> newMeta =
std::make_unique<power_bookmarks::PowerBookmarkMeta>();
bool changed =
PopulateOrUpdateBookmarkMetaIfNeeded(newMeta.get(), info.value());
CHECK(changed);
// Make sure the data is attached to the bookmark and get a copy to use in
// the rest of this function.
power_bookmarks::SetNodePowerBookmarkMeta(model, node, std::move(newMeta));
meta = power_bookmarks::GetNodePowerBookmarkMeta(model, node);
}
power_bookmarks::ShoppingSpecifics* specifics =
meta->mutable_shopping_specifics();
if (!specifics || !specifics->has_product_cluster_id()) {
base::SequencedTaskRunner::GetCurrentDefault()->PostTask(
FROM_HERE, base::BindOnce(std::move(callback), false));
return;
}
std::unique_ptr<std::vector<CommerceSubscription>> subs =
std::make_unique<std::vector<CommerceSubscription>>();
std::optional<UserSeenOffer> user_seen_offer = std::nullopt;
if (enabled) {
user_seen_offer.emplace(base::NumberToString(specifics->offer_id()),
specifics->current_price().amount_micros(),
specifics->country_code(), specifics->locale());
}
CommerceSubscription sub(
SubscriptionType::kPriceTrack, IdentifierType::kProductClusterId,
base::NumberToString(specifics->product_cluster_id()),
ManagementType::kUserManaged, kUnknownSubscriptionTimestamp,
std::move(user_seen_offer));
subs->push_back(std::move(sub));
base::OnceCallback<void(bool)> update_bookmarks_callback = base::BindOnce(
&UpdateBookmarksForSubscriptionsResult, model->AsWeakPtr(),
std::move(callback), enabled, specifics->product_cluster_id());
if (enabled) {
// If the bookmark was created through the price tracking flow, make sure
// that is recorded. If untracked, this bookmark will be deleted.
if (was_bookmark_created_by_price_tracking) {
specifics->set_bookmark_created_by_price_tracking(true);
power_bookmarks::SetNodePowerBookmarkMeta(model, node, std::move(meta));
}
service->Subscribe(std::move(subs), std::move(update_bookmarks_callback));
} else {
service->Unsubscribe(std::move(subs), std::move(update_bookmarks_callback));
}
}
std::vector<const bookmarks::BookmarkNode*> GetBookmarksWithClusterId(
bookmarks::BookmarkModel* model,
uint64_t cluster_id,
size_t max_count) {
std::vector<const bookmarks::BookmarkNode*> results =
GetAllShoppingBookmarks(model);
std::vector<const bookmarks::BookmarkNode*> bookmarks_with_cluster;
for (const auto* node : results) {
std::unique_ptr<power_bookmarks::PowerBookmarkMeta> meta =
power_bookmarks::GetNodePowerBookmarkMeta(model, node);
if (!meta)
continue;
power_bookmarks::ShoppingSpecifics* specifics =
meta->mutable_shopping_specifics();
if (!specifics || specifics->product_cluster_id() != cluster_id)
continue;
bookmarks_with_cluster.push_back(node);
// If we're only looking for a fixed number of bookmarks, stop if we reach
// that number.
if (max_count > 0 && bookmarks_with_cluster.size() >= max_count)
break;
}
return bookmarks_with_cluster;
}
void GetAllPriceTrackedBookmarks(
ShoppingService* shopping_service,
bookmarks::BookmarkModel* bookmark_model,
base::OnceCallback<void(std::vector<const bookmarks::BookmarkNode*>)>
callback) {
if (!shopping_service || !bookmark_model) {
base::SequencedTaskRunner::GetCurrentDefault()->PostTask(
FROM_HERE,
base::BindOnce(std::move(callback),
std::vector<const bookmarks::BookmarkNode*>()));
return;
}
shopping_service->GetAllSubscriptions(
SubscriptionType::kPriceTrack,
base::BindOnce(
[](base::WeakPtr<ShoppingService> service,
base::WeakPtr<bookmarks::BookmarkModel> model,
base::OnceCallback<void(
std::vector<const bookmarks::BookmarkNode*>)> callback,
std::vector<CommerceSubscription> subscriptions) {
std::vector<const bookmarks::BookmarkNode*> shopping_bookmarks =
GetAllShoppingBookmarks(model.get());
// Get all cluster IDs in a map for easier lookup.
std::unordered_set<uint64_t> cluster_set;
for (auto sub : subscriptions) {
if (sub.management_type == ManagementType::kUserManaged) {
uint64_t cluster_id;
if (base::StringToUint64(sub.id, &cluster_id)) {
cluster_set.insert(cluster_id);
}
}
}
std::vector<const bookmarks::BookmarkNode*> tracked_bookmarks;
for (const bookmarks::BookmarkNode* node : shopping_bookmarks) {
std::unique_ptr<power_bookmarks::PowerBookmarkMeta> meta =
power_bookmarks::GetNodePowerBookmarkMeta(model.get(), node);
if (!meta || !meta->has_shopping_specifics()) {
continue;
}
const power_bookmarks::ShoppingSpecifics specifics =
meta->shopping_specifics();
if (!cluster_set.contains(specifics.product_cluster_id())) {
continue;
}
tracked_bookmarks.push_back(node);
}
std::move(callback).Run(std::move(tracked_bookmarks));
},
shopping_service->AsWeakPtr(), bookmark_model->AsWeakPtr(),
std::move(callback)));
}
std::vector<const bookmarks::BookmarkNode*> GetAllShoppingBookmarks(
bookmarks::BookmarkModel* model) {
CHECK(model);
power_bookmarks::PowerBookmarkQueryFields query;
query.type = power_bookmarks::PowerBookmarkType::SHOPPING;
std::vector<const bookmarks::BookmarkNode*> nodes =
power_bookmarks::GetBookmarksMatchingProperties(model, query, -1);
std::erase_if(nodes, [model](const bookmarks::BookmarkNode* node) {
return model->IsLocalOnlyNode(*node);
});
return nodes;
}
bool PopulateOrUpdateBookmarkMetaIfNeeded(
power_bookmarks::PowerBookmarkMeta* out_meta,
const ProductInfo& info) {
bool changed = false;
if (out_meta->lead_image().url() != info.image_url.spec()) {
out_meta->mutable_lead_image()->set_url(info.image_url.spec());
changed = true;
}
power_bookmarks::ShoppingSpecifics* specifics =
out_meta->mutable_shopping_specifics();
if (!info.title.empty() && specifics->title() != info.title) {
specifics->set_title(info.title);
changed = true;
}
if (specifics->country_code() != info.country_code) {
specifics->set_country_code(info.country_code);
changed = true;
}
if (specifics->current_price().currency_code() != info.currency_code ||
specifics->current_price().amount_micros() != info.amount_micros) {
specifics->mutable_current_price()->set_currency_code(info.currency_code);
specifics->mutable_current_price()->set_amount_micros(info.amount_micros);
changed = true;
}
if (info.previous_amount_micros.has_value() &&
(specifics->previous_price().currency_code() != info.currency_code ||
specifics->previous_price().amount_micros() !=
info.previous_amount_micros.value())) {
// Intentionally use the same currency code for both current and previous
// price. Consistency between the values is guaranteed by the shopping
// service.
specifics->mutable_previous_price()->set_currency_code(info.currency_code);
specifics->mutable_previous_price()->set_amount_micros(
info.previous_amount_micros.value());
changed = true;
} else if (!info.previous_amount_micros.has_value() &&
specifics->has_previous_price()) {
specifics->clear_previous_price();
changed = true;
}
if (info.offer_id.has_value() &&
specifics->offer_id() != info.offer_id.value()) {
specifics->set_offer_id(info.offer_id.value());
changed = true;
}
// Only update the cluster ID if it was previously empty. Having this value
// change would cause serious problems elsewhere.
if (info.product_cluster_id.has_value() &&
!specifics->has_product_cluster_id()) {
specifics->set_product_cluster_id(info.product_cluster_id.value());
changed = true;
}
// Consider adding a DCHECK for old and new cluster ID equality in the else
// clause for the above.
return changed;
}
void MaybeEnableEmailNotifications(PrefService* pref_service) {
if (pref_service) {
const PrefService::Preference* email_pref =
pref_service->FindPreference(kPriceEmailNotificationsEnabled);
if (email_pref && email_pref->IsDefaultValue()) {
pref_service->SetBoolean(kPriceEmailNotificationsEnabled, true);
}
}
}
bool GetEmailNotificationPrefValue(PrefService* pref_service) {
return pref_service &&
pref_service->GetBoolean(kPriceEmailNotificationsEnabled);
}
bool IsEmailNotificationPrefSetByUser(PrefService* pref_service) {
return pref_service &&
pref_service->HasPrefPath(kPriceEmailNotificationsEnabled);
}
CommerceSubscription BuildUserSubscriptionForClusterId(uint64_t cluster_id) {
return CommerceSubscription(
SubscriptionType::kPriceTrack, IdentifierType::kProductClusterId,
base::NumberToString(cluster_id), ManagementType::kUserManaged);
}
bool CanTrackPrice(const ProductInfo& info) {
return info.product_cluster_id.has_value();
}
bool CanTrackPrice(const std::optional<ProductInfo>& info) {
return info.has_value() && CanTrackPrice(info.value());
}
bool CanTrackPrice(const power_bookmarks::ShoppingSpecifics& specifics) {
return specifics.has_product_cluster_id();
}
std::optional<std::u16string> GetBookmarkParentName(
bookmarks::BookmarkModel* model,
const GURL& url) {
const bookmarks::BookmarkNode* node =
model->GetMostRecentlyAddedUserNodeForURL(url);
if (!node || model->IsLocalOnlyNode(*node)) {
return std::nullopt;
}
return std::optional<std::u16string>(node->parent()->GetTitle());
}
const bookmarks::BookmarkNode* GetShoppingCollectionBookmarkFolder(
bookmarks::BookmarkModel* model,
bool create_if_needed) {
if (!model) {
return nullptr;
}
const base::Uuid collection_uuid =
base::Uuid::ParseLowercase(bookmarks::kShoppingCollectionUuid);
const bookmarks::BookmarkNode* collection_node = model->GetNodeByUuid(
collection_uuid,
bookmarks::BookmarkModel::NodeTypeForUuidLookup::kLocalOrSyncableNodes);
CHECK(!collection_node || collection_node->is_folder());
if (!collection_node && !create_if_needed) {
return nullptr;
}
if (!collection_node) {
collection_node = model->AddFolder(
model->other_node(), model->other_node()->children().size(),
l10n_util::GetStringUTF16(IDS_SHOPPING_COLLECTION_FOLDER_NAME), nullptr,
std::nullopt, collection_uuid);
CHECK_EQ(
model->GetNodeByUuid(collection_uuid,
bookmarks::BookmarkModel::NodeTypeForUuidLookup::
kLocalOrSyncableNodes),
collection_node);
}
return collection_node;
}
bool IsShoppingCollectionBookmarkFolder(const bookmarks::BookmarkNode* node) {
return node && node->is_folder() &&
node->uuid() ==
base::Uuid::ParseLowercase(bookmarks::kShoppingCollectionUuid);
}
std::optional<uint64_t> GetProductClusterIdFromBookmark(
const GURL& url,
bookmarks::BookmarkModel* model) {
const bookmarks::BookmarkNode* node =
model->GetMostRecentlyAddedUserNodeForURL(url);
if (!node || model->IsLocalOnlyNode(*node)) {
return std::nullopt;
}
std::unique_ptr<power_bookmarks::PowerBookmarkMeta> meta =
power_bookmarks::GetNodePowerBookmarkMeta(model, node);
if (!meta) {
return std::nullopt;
}
const power_bookmarks::ShoppingSpecifics specifics =
meta->shopping_specifics();
return specifics.has_product_cluster_id()
? std::optional<uint64_t>(specifics.product_cluster_id())
: std::nullopt;
}
void RemoveDanglingSubscriptions(
ShoppingService* shopping_service,
bookmarks::BookmarkModel* bookmark_model,
base::OnceCallback<void(size_t)> completed_callback) {
if (!shopping_service || !bookmark_model) {
return;
}
shopping_service->GetAllSubscriptions(
commerce::SubscriptionType::kPriceTrack,
base::BindOnce(
[](base::WeakPtr<ShoppingService> service,
bookmarks::BookmarkModel* model,
base::OnceCallback<void(size_t)> callback,
std::vector<CommerceSubscription> subscriptions) {
if (!service) {
std::move(callback).Run(0);
return;
}
std::unique_ptr<std::vector<CommerceSubscription>> dangling_subs =
std::make_unique<std::vector<CommerceSubscription>>();
for (CommerceSubscription sub : subscriptions) {
if (sub.management_type != ManagementType::kUserManaged) {
continue;
}
uint64_t cluster_id;
if (!base::StringToUint64(sub.id, &cluster_id)) {
continue;
}
// If there is at least one bookmark with the corresponding
// subscription, no need to clean up.
if (GetBookmarksWithClusterId(model, cluster_id, 1).size() > 0) {
continue;
}
dangling_subs->push_back(sub);
}
size_t sub_count = dangling_subs->size();
if (sub_count > 0) {
service->Unsubscribe(
std::move(dangling_subs),
base::BindOnce(
[](base::OnceCallback<void(size_t)> callback,
size_t count,
bool success) { std::move(callback).Run(count); },
std::move(callback), sub_count));
} else {
std::move(callback).Run(0);
}
},
shopping_service->AsWeakPtr(), bookmark_model,
std::move(completed_callback)));
}
} // namespace commerce