blob: d9eac97705d6ad170cc857614e07812eb59f8d73 [file] [log] [blame]
// Copyright 2022 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
#include "components/browsing_topics/browsing_topics_service_impl.h"
#include <random>
#include "base/rand_util.h"
#include "base/ranges/algorithm.h"
#include "base/time/time.h"
#include "components/browsing_topics/browsing_topics_calculator.h"
#include "components/browsing_topics/browsing_topics_page_load_data_tracker.h"
#include "components/browsing_topics/mojom/browsing_topics_internals.mojom.h"
#include "components/browsing_topics/util.h"
#include "components/optimization_guide/content/browser/page_content_annotations_service.h"
#include "content/public/browser/browsing_topics_site_data_manager.h"
#include "net/base/registry_controlled_domains/registry_controlled_domain.h"
#include "services/metrics/public/cpp/ukm_builders.h"
#include "services/metrics/public/cpp/ukm_recorder.h"
#include "third_party/blink/public/common/features.h"
#include "third_party/blink/public/mojom/browsing_topics/browsing_topics.mojom.h"
namespace browsing_topics {
namespace {
// Returns whether the topics should all be cleared given
// `browsing_topics_data_accessible_since` and `is_topic_allowed_by_settings`.
// Returns true if `browsing_topics_data_accessible_since` is greater than the
// last calculation time, or if any top topic is disallowed from the settings.
// The latter could happen if the topic became disallowed when
// `browsing_topics_state` was still loading (and we didn't get a chance to
// clear it). This is an unlikely edge case, so it's fine to over-delete.
bool ShouldClearTopicsOnStartup(
const BrowsingTopicsState& browsing_topics_state,
base::Time browsing_topics_data_accessible_since,
base::RepeatingCallback<bool(const privacy_sandbox::CanonicalTopic&)>
is_topic_allowed_by_settings) {
DCHECK(!is_topic_allowed_by_settings.is_null());
if (browsing_topics_state.epochs().empty())
return false;
// Here we rely on the fact that `browsing_topics_data_accessible_since` can
// only be updated to base::Time::Now() due to data deletion. So we'll either
// need to clear all topics data, or no-op. If this assumption no longer
// holds, we'd need to iterate over all epochs, check their calculation time,
// and selectively delete the epochs.
if (browsing_topics_data_accessible_since >
browsing_topics_state.epochs().back().calculation_time()) {
return true;
}
for (const EpochTopics& epoch : browsing_topics_state.epochs()) {
for (const TopicAndDomains& topic_and_domains :
epoch.top_topics_and_observing_domains()) {
if (!topic_and_domains.IsValid())
continue;
if (!is_topic_allowed_by_settings.Run(privacy_sandbox::CanonicalTopic(
topic_and_domains.topic(), epoch.taxonomy_version()))) {
return true;
}
}
}
return false;
}
struct StartupCalculateDecision {
bool clear_topics_data = true;
base::TimeDelta next_calculation_delay;
};
StartupCalculateDecision GetStartupCalculationDecision(
const BrowsingTopicsState& browsing_topics_state,
base::Time browsing_topics_data_accessible_since,
base::RepeatingCallback<bool(const privacy_sandbox::CanonicalTopic&)>
is_topic_allowed_by_settings) {
// The topics have never been calculated. This could happen with a fresh
// profile or the if the config has updated. In case of a config update, the
// topics should have already been cleared when initializing the
// `BrowsingTopicsState`.
if (browsing_topics_state.next_scheduled_calculation_time().is_null()) {
return StartupCalculateDecision{
.clear_topics_data = false,
.next_calculation_delay = base::TimeDelta()};
}
// This could happen when clear-on-exit is turned on and has caused the
// cookies to be deleted on startup, of if a topic became disallowed when
// `browsing_topics_state` was still loading.
bool should_clear_topics_data = ShouldClearTopicsOnStartup(
browsing_topics_state, browsing_topics_data_accessible_since,
is_topic_allowed_by_settings);
base::TimeDelta presumed_next_calculation_delay =
browsing_topics_state.next_scheduled_calculation_time() -
base::Time::Now();
// The scheduled calculation time was reached before the startup.
if (presumed_next_calculation_delay <= base::TimeDelta()) {
return StartupCalculateDecision{
.clear_topics_data = should_clear_topics_data,
.next_calculation_delay = base::TimeDelta()};
}
// This could happen if the machine time has changed since the last
// calculation. Recalculate immediately to align with the expected schedule
// rather than potentially stop computing for a very long time.
if (presumed_next_calculation_delay >=
2 * blink::features::kBrowsingTopicsTimePeriodPerEpoch.Get()) {
return StartupCalculateDecision{
.clear_topics_data = should_clear_topics_data,
.next_calculation_delay = base::TimeDelta()};
}
return StartupCalculateDecision{
.clear_topics_data = should_clear_topics_data,
.next_calculation_delay = presumed_next_calculation_delay};
}
// Represents the different reasons why the topics API returns an empty result.
// These values are persisted to logs. Entries should not be renumbered and
// numeric values should never be reused.
enum class EmptyApiResultReason {
// The topics state hasn't finished loading.
kStateNotReady = 0,
// Access is disallowed by user settings.
kAccessDisallowedBySettings = 1,
// There are no candidate topics, e.g. no candidate epochs; epoch calculation
// failed; individual topics were cleared or blocked.
kNoCandicateTopics = 2,
// The candidate topics were filtered for the requesting context.
kCandicateTopicsFiltered = 3,
kMaxValue = kCandicateTopicsFiltered,
};
void RecordBrowsingTopicsApiResultUkmMetrics(
EmptyApiResultReason empty_reason,
content::RenderFrameHost* main_frame) {
ukm::UkmRecorder* ukm_recorder = ukm::UkmRecorder::Get();
ukm::builders::BrowsingTopics_DocumentBrowsingTopicsApiResult builder(
main_frame->GetPageUkmSourceId());
builder.SetEmptyReason(static_cast<int64_t>(empty_reason));
builder.Record(ukm_recorder->Get());
}
void RecordBrowsingTopicsApiResultUkmMetrics(
const std::vector<std::pair<blink::mojom::EpochTopicPtr, bool>>&
topics_with_status,
content::RenderFrameHost* main_frame) {
DCHECK(!topics_with_status.empty());
ukm::UkmRecorder* ukm_recorder = ukm::UkmRecorder::Get();
ukm::builders::BrowsingTopics_DocumentBrowsingTopicsApiResult builder(
main_frame->GetPageUkmSourceId());
for (size_t i = 0; i < 3u && topics_with_status.size() > i; ++i) {
const blink::mojom::EpochTopicPtr& topic = topics_with_status[i].first;
bool is_true_topic = topics_with_status[i].second;
int taxonomy_version = 0;
base::StringToInt(topic->taxonomy_version, &taxonomy_version);
DCHECK(taxonomy_version);
int64_t model_version = 0;
base::StringToInt64(topic->model_version, &model_version);
DCHECK(model_version);
if (i == 0) {
builder.SetReturnedTopic0(topic->topic)
.SetReturnedTopic0IsTrueTopTopic(is_true_topic)
.SetReturnedTopic0TaxonomyVersion(taxonomy_version)
.SetReturnedTopic0ModelVersion(model_version);
} else if (i == 1) {
builder.SetReturnedTopic1(topic->topic)
.SetReturnedTopic1IsTrueTopTopic(is_true_topic)
.SetReturnedTopic1TaxonomyVersion(taxonomy_version)
.SetReturnedTopic1ModelVersion(model_version);
} else {
DCHECK_EQ(i, 2u);
builder.SetReturnedTopic2(topic->topic)
.SetReturnedTopic2IsTrueTopTopic(is_true_topic)
.SetReturnedTopic2TaxonomyVersion(taxonomy_version)
.SetReturnedTopic2ModelVersion(model_version);
}
}
builder.Record(ukm_recorder->Get());
}
} // namespace
BrowsingTopicsServiceImpl::~BrowsingTopicsServiceImpl() = default;
BrowsingTopicsServiceImpl::BrowsingTopicsServiceImpl(
const base::FilePath& profile_path,
privacy_sandbox::PrivacySandboxSettings* privacy_sandbox_settings,
history::HistoryService* history_service,
content::BrowsingTopicsSiteDataManager* site_data_manager,
optimization_guide::PageContentAnnotationsService* annotations_service)
: privacy_sandbox_settings_(privacy_sandbox_settings),
history_service_(history_service),
site_data_manager_(site_data_manager),
annotations_service_(annotations_service),
browsing_topics_state_(
profile_path,
base::BindOnce(
&BrowsingTopicsServiceImpl::OnBrowsingTopicsStateLoaded,
base::Unretained(this))) {
privacy_sandbox_settings_observation_.Observe(privacy_sandbox_settings);
history_service_observation_.Observe(history_service);
// Greedily request the model to be available to reduce the latency in later
// topics calculation.
annotations_service_->RequestAndNotifyWhenModelAvailable(
optimization_guide::AnnotationType::kPageTopics, base::DoNothing());
}
std::vector<blink::mojom::EpochTopicPtr>
BrowsingTopicsServiceImpl::GetBrowsingTopicsForJsApi(
const url::Origin& context_origin,
content::RenderFrameHost* main_frame) {
if (!browsing_topics_state_loaded_) {
RecordBrowsingTopicsApiResultUkmMetrics(
EmptyApiResultReason::kStateNotReady, main_frame);
return {};
}
if (!privacy_sandbox_settings_->IsTopicsAllowed()) {
RecordBrowsingTopicsApiResultUkmMetrics(
EmptyApiResultReason::kAccessDisallowedBySettings, main_frame);
return {};
}
if (!privacy_sandbox_settings_->IsTopicsAllowedForContext(
context_origin.GetURL(), main_frame->GetLastCommittedOrigin())) {
RecordBrowsingTopicsApiResultUkmMetrics(
EmptyApiResultReason::kAccessDisallowedBySettings, main_frame);
return {};
}
std::string context_domain =
net::registry_controlled_domains::GetDomainAndRegistry(
context_origin.GetURL(),
net::registry_controlled_domains::INCLUDE_PRIVATE_REGISTRIES);
HashedDomain hashed_context_domain = HashContextDomainForStorage(
browsing_topics_state_.hmac_key(), context_domain);
// Track the API usage context after the permissions check.
BrowsingTopicsPageLoadDataTracker::GetOrCreateForPage(main_frame->GetPage())
->OnBrowsingTopicsApiUsed(hashed_context_domain, history_service_);
std::string top_domain =
net::registry_controlled_domains::GetDomainAndRegistry(
main_frame->GetLastCommittedOrigin().GetURL(),
net::registry_controlled_domains::INCLUDE_PRIVATE_REGISTRIES);
bool has_filtered_topics = false;
// The result topics along with flags denoting whether they are true topics.
std::vector<std::pair<blink::mojom::EpochTopicPtr, bool>> topics_with_status;
for (const EpochTopics* epoch :
browsing_topics_state_.EpochsForSite(top_domain)) {
bool output_is_true_topic = false;
bool candidate_topic_filtered = false;
absl::optional<Topic> topic = epoch->TopicForSite(
top_domain, hashed_context_domain, browsing_topics_state_.hmac_key(),
output_is_true_topic, candidate_topic_filtered);
if (candidate_topic_filtered)
has_filtered_topics = true;
// Only add a non-empty topic to the result.
if (!topic)
continue;
// Although a top topic can never be in the disallowed state, the returned
// `topic` may be the random one. Thus we still need this check.
if (!privacy_sandbox_settings_->IsTopicAllowed(
privacy_sandbox::CanonicalTopic(*topic,
epoch->taxonomy_version()))) {
continue;
}
auto result_topic = blink::mojom::EpochTopic::New();
result_topic->topic = topic.value().value();
result_topic->config_version = base::StrCat(
{"chrome.", base::NumberToString(
blink::features::kBrowsingTopicsConfigVersion.Get())});
result_topic->model_version = base::NumberToString(epoch->model_version());
result_topic->taxonomy_version =
base::NumberToString(epoch->taxonomy_version());
result_topic->version = base::StrCat({result_topic->config_version, ":",
result_topic->taxonomy_version, ":",
result_topic->model_version});
topics_with_status.emplace_back(std::move(result_topic),
output_is_true_topic);
}
// Sort `topics_with_status` based on `EpochTopicPtr` first, and if the
// `EpochTopicPtr` parts are equal, then a true topic will be ordered before a
// random topic. This ensures that when we later deduplicate based on the
// `EpochTopicPtr` field only, the associated is-true-topic status will be
// true as long as there is one true topic for that topic in
// `topics_with_status`.
std::sort(topics_with_status.begin(), topics_with_status.end(),
[](const auto& left, const auto& right) {
if (left.first < right.first)
return true;
if (left.first > right.first)
return false;
return right.second < left.second;
});
// Remove duplicate `EpochTopicPtr` entries.
topics_with_status.erase(
std::unique(topics_with_status.begin(), topics_with_status.end(),
[](const auto& left, const auto& right) {
return left.first == right.first;
}),
topics_with_status.end());
// Shuffle the entries.
base::RandomShuffle(topics_with_status.begin(), topics_with_status.end());
if (topics_with_status.empty()) {
if (has_filtered_topics) {
RecordBrowsingTopicsApiResultUkmMetrics(
EmptyApiResultReason::kCandicateTopicsFiltered, main_frame);
} else {
RecordBrowsingTopicsApiResultUkmMetrics(
EmptyApiResultReason::kNoCandicateTopics, main_frame);
}
return {};
}
RecordBrowsingTopicsApiResultUkmMetrics(topics_with_status, main_frame);
std::vector<blink::mojom::EpochTopicPtr> result_topics;
result_topics.reserve(topics_with_status.size());
std::transform(topics_with_status.begin(), topics_with_status.end(),
std::back_inserter(result_topics),
[](auto& topic_with_status) {
return std::move(topic_with_status.first);
});
return result_topics;
}
void BrowsingTopicsServiceImpl::GetBrowsingTopicsStateForWebUi(
bool calculate_now,
mojom::PageHandler::GetBrowsingTopicsStateCallback callback) {
if (!browsing_topics_state_loaded_) {
std::move(callback).Run(
mojom::WebUIGetBrowsingTopicsStateResult::NewOverrideStatusMessage(
"State loading hasn't finished. Please retry shortly."));
return;
}
// If a calculation is already in progress, get the webui topics state after
// the calculation is done. Do this regardless of whether `calculate_now` is
// true, i.e. if `calculate_now` is true, this request is effectively merged
// with the in progress calculation.
if (topics_calculator_) {
get_state_for_webui_callbacks_.push_back(std::move(callback));
return;
}
DCHECK(schedule_calculate_timer_.IsRunning());
if (calculate_now) {
get_state_for_webui_callbacks_.push_back(std::move(callback));
schedule_calculate_timer_.AbandonAndStop();
CalculateBrowsingTopics();
return;
}
std::move(callback).Run(GetBrowsingTopicsStateForWebUiHelper());
}
std::vector<privacy_sandbox::CanonicalTopic>
BrowsingTopicsServiceImpl::GetTopicsForSiteForDisplay(
const url::Origin& top_origin) const {
if (!browsing_topics_state_loaded_)
return {};
std::string top_domain =
net::registry_controlled_domains::GetDomainAndRegistry(
top_origin.GetURL(),
net::registry_controlled_domains::INCLUDE_PRIVATE_REGISTRIES);
std::vector<privacy_sandbox::CanonicalTopic> result;
for (const EpochTopics* epoch :
browsing_topics_state_.EpochsForSite(top_domain)) {
absl::optional<Topic> topic = epoch->TopicForSiteForDisplay(
top_domain, browsing_topics_state_.hmac_key());
if (!topic)
continue;
// `epoch->TopicForSiteForDisplay()` shall only return a top topic, and a
// top topic can never be in the disallowed state (i.e. it will be cleared
// when it becomes diallowed).
DCHECK(privacy_sandbox_settings_->IsTopicAllowed(
privacy_sandbox::CanonicalTopic(*topic, epoch->taxonomy_version())));
result.emplace_back(*topic, epoch->taxonomy_version());
}
return result;
}
std::vector<privacy_sandbox::CanonicalTopic>
BrowsingTopicsServiceImpl::GetTopTopicsForDisplay() const {
if (!browsing_topics_state_loaded_)
return {};
std::vector<privacy_sandbox::CanonicalTopic> result;
for (const EpochTopics& epoch : browsing_topics_state_.epochs()) {
DCHECK_LE(epoch.padded_top_topics_start_index(),
epoch.top_topics_and_observing_domains().size());
for (size_t i = 0; i < epoch.padded_top_topics_start_index(); ++i) {
const TopicAndDomains& topic_and_domains =
epoch.top_topics_and_observing_domains()[i];
if (!topic_and_domains.IsValid())
continue;
// A top topic can never be in the disallowed state (i.e. it will be
// cleared when it becomes diallowed).
DCHECK(privacy_sandbox_settings_->IsTopicAllowed(
privacy_sandbox::CanonicalTopic(topic_and_domains.topic(),
epoch.taxonomy_version())));
result.emplace_back(topic_and_domains.topic(), epoch.taxonomy_version());
}
}
return result;
}
void BrowsingTopicsServiceImpl::ClearTopic(
const privacy_sandbox::CanonicalTopic& canonical_topic) {
if (!browsing_topics_state_loaded_)
return;
browsing_topics_state_.ClearTopic(canonical_topic.topic_id(),
canonical_topic.taxonomy_version());
}
void BrowsingTopicsServiceImpl::ClearTopicsDataForOrigin(
const url::Origin& origin) {
if (!browsing_topics_state_loaded_)
return;
std::string context_domain =
net::registry_controlled_domains::GetDomainAndRegistry(
origin.GetURL(),
net::registry_controlled_domains::INCLUDE_PRIVATE_REGISTRIES);
HashedDomain hashed_context_domain = HashContextDomainForStorage(
browsing_topics_state_.hmac_key(), context_domain);
browsing_topics_state_.ClearContextDomain(hashed_context_domain);
site_data_manager_->ClearContextDomain(hashed_context_domain);
}
void BrowsingTopicsServiceImpl::ClearAllTopicsData() {
if (!browsing_topics_state_loaded_)
return;
browsing_topics_state_.ClearAllTopics();
site_data_manager_->ExpireDataBefore(base::Time::Now());
}
std::unique_ptr<BrowsingTopicsCalculator>
BrowsingTopicsServiceImpl::CreateCalculator(
privacy_sandbox::PrivacySandboxSettings* privacy_sandbox_settings,
history::HistoryService* history_service,
content::BrowsingTopicsSiteDataManager* site_data_manager,
optimization_guide::PageContentAnnotationsService* annotations_service,
const base::circular_deque<EpochTopics>& epochs,
BrowsingTopicsCalculator::CalculateCompletedCallback callback) {
return std::make_unique<BrowsingTopicsCalculator>(
privacy_sandbox_settings, history_service, site_data_manager,
annotations_service, epochs, std::move(callback));
}
const BrowsingTopicsState& BrowsingTopicsServiceImpl::browsing_topics_state() {
return browsing_topics_state_;
}
void BrowsingTopicsServiceImpl::ScheduleBrowsingTopicsCalculation(
base::TimeDelta delay) {
DCHECK(browsing_topics_state_loaded_);
// `this` owns the timer, which is automatically cancelled on destruction, so
// base::Unretained(this) is safe.
schedule_calculate_timer_.Start(
FROM_HERE, delay,
base::BindOnce(&BrowsingTopicsServiceImpl::CalculateBrowsingTopics,
base::Unretained(this)));
}
void BrowsingTopicsServiceImpl::CalculateBrowsingTopics() {
DCHECK(browsing_topics_state_loaded_);
DCHECK(!topics_calculator_);
// `this` owns `topics_calculator_` so `topics_calculator_` should not invoke
// the callback once it's destroyed.
topics_calculator_ = CreateCalculator(
privacy_sandbox_settings_, history_service_, site_data_manager_,
annotations_service_, browsing_topics_state_.epochs(),
base::BindOnce(
&BrowsingTopicsServiceImpl::OnCalculateBrowsingTopicsCompleted,
base::Unretained(this)));
}
void BrowsingTopicsServiceImpl::OnCalculateBrowsingTopicsCompleted(
EpochTopics epoch_topics) {
DCHECK(browsing_topics_state_loaded_);
DCHECK(topics_calculator_);
topics_calculator_.reset();
browsing_topics_state_.AddEpoch(std::move(epoch_topics));
browsing_topics_state_.UpdateNextScheduledCalculationTime();
ScheduleBrowsingTopicsCalculation(
blink::features::kBrowsingTopicsTimePeriodPerEpoch.Get());
if (!get_state_for_webui_callbacks_.empty()) {
mojom::WebUIGetBrowsingTopicsStateResultPtr webui_state =
GetBrowsingTopicsStateForWebUiHelper();
for (auto& callback : get_state_for_webui_callbacks_) {
std::move(callback).Run(webui_state->Clone());
}
get_state_for_webui_callbacks_.clear();
}
}
void BrowsingTopicsServiceImpl::OnBrowsingTopicsStateLoaded() {
DCHECK(!browsing_topics_state_loaded_);
browsing_topics_state_loaded_ = true;
base::Time browsing_topics_data_sccessible_since =
privacy_sandbox_settings_->TopicsDataAccessibleSince();
StartupCalculateDecision decision = GetStartupCalculationDecision(
browsing_topics_state_, browsing_topics_data_sccessible_since,
base::BindRepeating(
&privacy_sandbox::PrivacySandboxSettings::IsTopicAllowed,
base::Unretained(privacy_sandbox_settings_)));
if (decision.clear_topics_data)
browsing_topics_state_.ClearAllTopics();
site_data_manager_->ExpireDataBefore(browsing_topics_data_sccessible_since);
ScheduleBrowsingTopicsCalculation(decision.next_calculation_delay);
}
void BrowsingTopicsServiceImpl::Shutdown() {
privacy_sandbox_settings_observation_.Reset();
history_service_observation_.Reset();
}
void BrowsingTopicsServiceImpl::OnTopicsDataAccessibleSinceUpdated() {
if (!browsing_topics_state_loaded_)
return;
// Here we rely on the fact that `browsing_topics_data_accessible_since` can
// only be updated to base::Time::Now() due to data deletion. In this case, we
// should just clear all topics.
browsing_topics_state_.ClearAllTopics();
site_data_manager_->ExpireDataBefore(
privacy_sandbox_settings_->TopicsDataAccessibleSince());
// Abort the outstanding topics calculation and restart immediately.
if (topics_calculator_) {
DCHECK(!schedule_calculate_timer_.IsRunning());
topics_calculator_.reset();
CalculateBrowsingTopics();
}
}
void BrowsingTopicsServiceImpl::OnURLsDeleted(
history::HistoryService* history_service,
const history::DeletionInfo& deletion_info) {
if (!browsing_topics_state_loaded_)
return;
// Ignore invalid time_range.
if (!deletion_info.IsAllHistory() && !deletion_info.time_range().IsValid())
return;
for (size_t i = 0; i < browsing_topics_state_.epochs().size(); ++i) {
const EpochTopics& epoch_topics = browsing_topics_state_.epochs()[i];
if (epoch_topics.empty())
continue;
// The typical case is assumed here. We cannot always derive the original
// history start time, as the necessary data (e.g. its previous epoch's
// calculation time) may have been gone.
base::Time history_data_start_time =
epoch_topics.calculation_time() -
blink::features::kBrowsingTopicsTimePeriodPerEpoch.Get();
bool time_range_overlap =
epoch_topics.calculation_time() >= deletion_info.time_range().begin() &&
history_data_start_time <= deletion_info.time_range().end();
if (time_range_overlap)
browsing_topics_state_.ClearOneEpoch(i);
}
// If there's an outstanding topics calculation, abort and restart it.
if (topics_calculator_) {
DCHECK(!schedule_calculate_timer_.IsRunning());
topics_calculator_.reset();
CalculateBrowsingTopics();
}
}
mojom::WebUIGetBrowsingTopicsStateResultPtr
BrowsingTopicsServiceImpl::GetBrowsingTopicsStateForWebUiHelper() {
DCHECK(browsing_topics_state_loaded_);
DCHECK(!topics_calculator_);
auto webui_state = mojom::WebUIBrowsingTopicsState::New();
webui_state->next_scheduled_calculation_time =
browsing_topics_state_.next_scheduled_calculation_time();
for (const EpochTopics& epoch : browsing_topics_state_.epochs()) {
DCHECK_LE(epoch.padded_top_topics_start_index(),
epoch.top_topics_and_observing_domains().size());
// Note: for a failed epoch calculation, the default zero-initialized values
// will be displayed in the Web UI.
auto webui_epoch = mojom::WebUIEpoch::New();
webui_epoch->calculation_time = epoch.calculation_time();
webui_epoch->model_version = base::NumberToString(epoch.model_version());
webui_epoch->taxonomy_version =
base::NumberToString(epoch.taxonomy_version());
for (size_t i = 0; i < epoch.top_topics_and_observing_domains().size();
++i) {
const TopicAndDomains& topic_and_domains =
epoch.top_topics_and_observing_domains()[i];
privacy_sandbox::CanonicalTopic canonical_topic =
privacy_sandbox::CanonicalTopic(topic_and_domains.topic(),
epoch.taxonomy_version());
std::vector<std::string> webui_observed_by_domains;
webui_observed_by_domains.reserve(
topic_and_domains.hashed_domains().size());
for (const auto& domain : topic_and_domains.hashed_domains()) {
webui_observed_by_domains.push_back(
base::NumberToString(domain.value()));
}
// Note: if the topic is invalid (i.e. cleared), the output `topic_id`
// will be 0; if the topic is invalid, or if the taxonomy version isn't
// recognized by this Chrome binary, the output `topic_name` will be
// "Unknown".
auto webui_topic = mojom::WebUITopic::New();
webui_topic->topic_id = topic_and_domains.topic().value();
webui_topic->topic_name = canonical_topic.GetLocalizedRepresentation();
webui_topic->is_real_topic = (i < epoch.padded_top_topics_start_index());
webui_topic->observed_by_domains = std::move(webui_observed_by_domains);
webui_epoch->topics.push_back(std::move(webui_topic));
}
webui_state->epochs.push_back(std::move(webui_epoch));
}
// Reorder the epochs from latest to oldest.
base::ranges::reverse(webui_state->epochs);
return mojom::WebUIGetBrowsingTopicsStateResult::NewBrowsingTopicsState(
std::move(webui_state));
}
} // namespace browsing_topics