|  | // 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/browsing_topics/browsing_topics_service_impl.h" | 
|  |  | 
|  | #include <algorithm> | 
|  | #include <random> | 
|  | #include <vector> | 
|  |  | 
|  | #include "base/functional/bind.h" | 
|  | #include "base/metrics/histogram_functions.h" | 
|  | #include "base/notreached.h" | 
|  | #include "base/rand_util.h" | 
|  | #include "base/strings/strcat.h" | 
|  | #include "base/strings/string_number_conversions.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/common/common_types.h" | 
|  | #include "components/browsing_topics/mojom/browsing_topics_internals.mojom.h" | 
|  | #include "components/browsing_topics/util.h" | 
|  | #include "components/privacy_sandbox/canonical_topic.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 { | 
|  |  | 
|  | enum class NumberOfTopics { | 
|  | kZero = 0, | 
|  | kOne = 1, | 
|  | kTwo = 2, | 
|  | kThree = 3, | 
|  |  | 
|  | kMaxValue = kThree, | 
|  | }; | 
|  |  | 
|  | // 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. | 
|  | bool ShouldClearTopicsOnStartup( | 
|  | const BrowsingTopicsState& browsing_topics_state, | 
|  | base::Time browsing_topics_data_accessible_since) { | 
|  | 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; | 
|  | } | 
|  |  | 
|  | return false; | 
|  | } | 
|  |  | 
|  | // Returns a vector of top topics which are disallowed and thus should be | 
|  | // cleared. This could happen if the topic became disallowed when | 
|  | // `browsing_topics_state` was still loading (and we didn't get a chance to | 
|  | // clear it). | 
|  | std::vector<privacy_sandbox::CanonicalTopic> TopTopicsToClearOnStartup( | 
|  | const BrowsingTopicsState& browsing_topics_state, | 
|  | base::RepeatingCallback<bool(const privacy_sandbox::CanonicalTopic&)> | 
|  | is_topic_allowed_by_settings) { | 
|  | DCHECK(!is_topic_allowed_by_settings.is_null()); | 
|  | std::vector<privacy_sandbox::CanonicalTopic> top_topics_to_clear; | 
|  | 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; | 
|  | } | 
|  | privacy_sandbox::CanonicalTopic canonical_topic = | 
|  | privacy_sandbox::CanonicalTopic(topic_and_domains.topic(), | 
|  | epoch.taxonomy_version()); | 
|  | if (!is_topic_allowed_by_settings.Run(canonical_topic)) { | 
|  | top_topics_to_clear.emplace_back(canonical_topic); | 
|  | } | 
|  | } | 
|  | } | 
|  | return top_topics_to_clear; | 
|  | } | 
|  |  | 
|  | struct StartupCalculateDecision { | 
|  | bool clear_all_topics_data = true; | 
|  | base::TimeDelta next_calculation_delay; | 
|  | std::vector<privacy_sandbox::CanonicalTopic> topics_to_clear; | 
|  | }; | 
|  |  | 
|  | 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_all_topics_data = false, | 
|  | .next_calculation_delay = base::TimeDelta(), | 
|  | .topics_to_clear = {}}; | 
|  | } | 
|  |  | 
|  | // This could happen when clear-on-exit is turned on and has caused the | 
|  | // cookies to be deleted on startup | 
|  | bool should_clear_all_topics_data = ShouldClearTopicsOnStartup( | 
|  | browsing_topics_state, browsing_topics_data_accessible_since); | 
|  |  | 
|  | std::vector<privacy_sandbox::CanonicalTopic> topics_to_clear; | 
|  | if (!should_clear_all_topics_data) { | 
|  | topics_to_clear = TopTopicsToClearOnStartup(browsing_topics_state, | 
|  | 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_all_topics_data = should_clear_all_topics_data, | 
|  | .next_calculation_delay = base::TimeDelta(), | 
|  | .topics_to_clear = topics_to_clear}; | 
|  | } | 
|  |  | 
|  | // 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_all_topics_data = should_clear_all_topics_data, | 
|  | .next_calculation_delay = base::TimeDelta(), | 
|  | .topics_to_clear = topics_to_clear}; | 
|  | } | 
|  |  | 
|  | return StartupCalculateDecision{ | 
|  | .clear_all_topics_data = should_clear_all_topics_data, | 
|  | .next_calculation_delay = presumed_next_calculation_delay, | 
|  | .topics_to_clear = topics_to_clear}; | 
|  | } | 
|  |  | 
|  | void RecordBrowsingTopicsApiResultMetrics(ApiAccessResult result, | 
|  | content::RenderFrameHost* main_frame, | 
|  | bool is_get_topics_request) { | 
|  | // The `BrowsingTopics_DocumentBrowsingTopicsApiResult2` event is only | 
|  | // recorded for request that gets the topics. | 
|  | if (!is_get_topics_request) { | 
|  | return; | 
|  | } | 
|  |  | 
|  | base::UmaHistogramEnumeration("BrowsingTopics.Result.Status", result); | 
|  |  | 
|  | if (result == browsing_topics::ApiAccessResult::kSuccess) { | 
|  | return; | 
|  | } | 
|  |  | 
|  | CHECK(!main_frame->IsInLifecycleState( | 
|  | content::RenderFrameHost::LifecycleState::kPrerendering)); | 
|  | ukm::UkmRecorder* ukm_recorder = ukm::UkmRecorder::Get(); | 
|  | ukm::builders::BrowsingTopics_DocumentBrowsingTopicsApiResult2 builder( | 
|  | main_frame->GetPageUkmSourceId()); | 
|  | builder.SetFailureReason(static_cast<int64_t>(result)); | 
|  |  | 
|  | builder.Record(ukm_recorder->Get()); | 
|  | } | 
|  |  | 
|  | void RecordBrowsingTopicsApiResultMetrics( | 
|  | const std::vector<CandidateTopic>& valid_candidate_topics, | 
|  | content::RenderFrameHost* main_frame) { | 
|  | CHECK(!main_frame->IsInLifecycleState( | 
|  | content::RenderFrameHost::LifecycleState::kPrerendering)); | 
|  | ukm::UkmRecorder* ukm_recorder = ukm::UkmRecorder::Get(); | 
|  | ukm::builders::BrowsingTopics_DocumentBrowsingTopicsApiResult2 builder( | 
|  | main_frame->GetPageUkmSourceId()); | 
|  |  | 
|  | int real_count = 0; | 
|  | int fake_count = 0; | 
|  | int filtered_count = 0; | 
|  |  | 
|  | for (size_t i = 0; i < 3u && valid_candidate_topics.size() > i; ++i) { | 
|  | const CandidateTopic& candidate_topic = valid_candidate_topics[i]; | 
|  |  | 
|  | DCHECK(candidate_topic.IsValid()); | 
|  |  | 
|  | if (candidate_topic.should_be_filtered()) { | 
|  | filtered_count += 1; | 
|  | } else { | 
|  | candidate_topic.is_true_topic() ? real_count += 1 : fake_count += 1; | 
|  | } | 
|  |  | 
|  | if (i == 0) { | 
|  | builder.SetCandidateTopic0(candidate_topic.topic().value()) | 
|  | .SetCandidateTopic0IsTrueTopTopic(candidate_topic.is_true_topic()) | 
|  | .SetCandidateTopic0ShouldBeFiltered( | 
|  | candidate_topic.should_be_filtered()) | 
|  | .SetCandidateTopic0TaxonomyVersion(candidate_topic.taxonomy_version()) | 
|  | .SetCandidateTopic0ModelVersion(candidate_topic.model_version()); | 
|  | } else if (i == 1) { | 
|  | builder.SetCandidateTopic1(candidate_topic.topic().value()) | 
|  | .SetCandidateTopic1IsTrueTopTopic(candidate_topic.is_true_topic()) | 
|  | .SetCandidateTopic1ShouldBeFiltered( | 
|  | candidate_topic.should_be_filtered()) | 
|  | .SetCandidateTopic1TaxonomyVersion(candidate_topic.taxonomy_version()) | 
|  | .SetCandidateTopic1ModelVersion(candidate_topic.model_version()); | 
|  | } else { | 
|  | DCHECK_EQ(i, 2u); | 
|  | builder.SetCandidateTopic2(candidate_topic.topic().value()) | 
|  | .SetCandidateTopic2IsTrueTopTopic(candidate_topic.is_true_topic()) | 
|  | .SetCandidateTopic2ShouldBeFiltered( | 
|  | candidate_topic.should_be_filtered()) | 
|  | .SetCandidateTopic2TaxonomyVersion(candidate_topic.taxonomy_version()) | 
|  | .SetCandidateTopic2ModelVersion(candidate_topic.model_version()); | 
|  | } | 
|  | } | 
|  |  | 
|  | CHECK_GE(real_count, 0); | 
|  | CHECK_GE(fake_count, 0); | 
|  | CHECK_GE(filtered_count, 0); | 
|  | CHECK_LE(real_count, static_cast<int>(NumberOfTopics::kMaxValue)); | 
|  | CHECK_LE(fake_count, static_cast<int>(NumberOfTopics::kMaxValue)); | 
|  | CHECK_LE(filtered_count, static_cast<int>(NumberOfTopics::kMaxValue)); | 
|  |  | 
|  | base::UmaHistogramEnumeration("BrowsingTopics.Result.RealTopicCount", | 
|  | static_cast<NumberOfTopics>(real_count)); | 
|  | base::UmaHistogramEnumeration("BrowsingTopics.Result.FakeTopicCount", | 
|  | static_cast<NumberOfTopics>(fake_count)); | 
|  | base::UmaHistogramEnumeration("BrowsingTopics.Result.FilteredTopicCount", | 
|  | static_cast<NumberOfTopics>(filtered_count)); | 
|  |  | 
|  | builder.Record(ukm_recorder->Get()); | 
|  | } | 
|  |  | 
|  | // Represents the action type of the request. | 
|  | // | 
|  | // These values are persisted to logs. Entries should not be renumbered and | 
|  | // numeric values should never be reused. | 
|  | enum class BrowsingTopicsApiActionType { | 
|  | // Get topics via document.browsingTopics({skipObservation: true}). | 
|  | kGetViaDocumentApi = 0, | 
|  |  | 
|  | // Get and observe topics via the document.browsingTopics(). | 
|  | kGetAndObserveViaDocumentApi = 1, | 
|  |  | 
|  | // Get topics via fetch(<url>, {browsingTopics: true}) or via the analogous | 
|  | // XHR request. | 
|  | kGetViaFetchLikeApi = 2, | 
|  |  | 
|  | // Observe topics via the "Sec-Browsing-Topics: ?1" response header for the | 
|  | // fetch(<url>, {browsingTopics: true}) request, or for the analogous XHR | 
|  | // request. | 
|  | kObserveViaFetchLikeApi = 3, | 
|  |  | 
|  | // Get topics via <iframe src=[url] browsingtopics>. | 
|  | kGetViaIframeAttributeApi = 4, | 
|  |  | 
|  | // Observe topics via the "Sec-Browsing-Topics: ?1" response header for the | 
|  | // <iframe src=[url] browsingtopics> request. | 
|  | kObserveViaIframeAttributeApi = 5, | 
|  |  | 
|  | // Get topics via <img src=[url] browsingtopics>. | 
|  | kGetViaImgAttributeApi = 6, | 
|  |  | 
|  | // Observe topics via the "Sec-Browsing-Topics: ?1" response header for the | 
|  | // <img src=[url] browsingtopics> request. | 
|  | kObserveViaImgAttributeApi = 7, | 
|  |  | 
|  | kMaxValue = kObserveViaImgAttributeApi, | 
|  | }; | 
|  |  | 
|  | void RecordBrowsingTopicsApiActionTypeMetrics(ApiCallerSource caller_source, | 
|  | bool get_topics, | 
|  | bool observe) { | 
|  | static constexpr char kBrowsingTopicsApiActionTypeHistogramId[] = | 
|  | "BrowsingTopics.ApiActionType"; | 
|  |  | 
|  | if (caller_source == ApiCallerSource::kJavaScript) { | 
|  | DCHECK(get_topics); | 
|  |  | 
|  | if (!observe) { | 
|  | base::UmaHistogramEnumeration( | 
|  | kBrowsingTopicsApiActionTypeHistogramId, | 
|  | BrowsingTopicsApiActionType::kGetViaDocumentApi); | 
|  | return; | 
|  | } | 
|  |  | 
|  | base::UmaHistogramEnumeration( | 
|  | kBrowsingTopicsApiActionTypeHistogramId, | 
|  | BrowsingTopicsApiActionType::kGetAndObserveViaDocumentApi); | 
|  |  | 
|  | return; | 
|  | } | 
|  |  | 
|  | if (caller_source == ApiCallerSource::kIframeAttribute) { | 
|  | if (get_topics) { | 
|  | DCHECK(!observe); | 
|  |  | 
|  | base::UmaHistogramEnumeration( | 
|  | kBrowsingTopicsApiActionTypeHistogramId, | 
|  | BrowsingTopicsApiActionType::kGetViaIframeAttributeApi); | 
|  | return; | 
|  | } | 
|  |  | 
|  | DCHECK(observe); | 
|  | base::UmaHistogramEnumeration( | 
|  | kBrowsingTopicsApiActionTypeHistogramId, | 
|  | BrowsingTopicsApiActionType::kObserveViaIframeAttributeApi); | 
|  |  | 
|  | return; | 
|  | } | 
|  |  | 
|  | if (caller_source == ApiCallerSource::kImgAttribute) { | 
|  | if (get_topics) { | 
|  | DCHECK(!observe); | 
|  |  | 
|  | base::UmaHistogramEnumeration( | 
|  | kBrowsingTopicsApiActionTypeHistogramId, | 
|  | BrowsingTopicsApiActionType::kGetViaImgAttributeApi); | 
|  | return; | 
|  | } | 
|  |  | 
|  | DCHECK(observe); | 
|  | base::UmaHistogramEnumeration( | 
|  | kBrowsingTopicsApiActionTypeHistogramId, | 
|  | BrowsingTopicsApiActionType::kObserveViaImgAttributeApi); | 
|  |  | 
|  | return; | 
|  | } | 
|  |  | 
|  | DCHECK_EQ(caller_source, ApiCallerSource::kFetch); | 
|  |  | 
|  | if (get_topics) { | 
|  | DCHECK(!observe); | 
|  |  | 
|  | base::UmaHistogramEnumeration( | 
|  | kBrowsingTopicsApiActionTypeHistogramId, | 
|  | BrowsingTopicsApiActionType::kGetViaFetchLikeApi); | 
|  | return; | 
|  | } | 
|  |  | 
|  | DCHECK(observe); | 
|  | base::UmaHistogramEnumeration( | 
|  | kBrowsingTopicsApiActionTypeHistogramId, | 
|  | BrowsingTopicsApiActionType::kObserveViaFetchLikeApi); | 
|  | } | 
|  |  | 
|  | std::set<HashedDomain> GetAllObservingDomains( | 
|  | const BrowsingTopicsState& browsing_topics_state) { | 
|  | std::set<HashedDomain> observing_domains; | 
|  | for (const EpochTopics& epoch : browsing_topics_state.epochs()) { | 
|  | for (const auto& topic_and_domains : | 
|  | epoch.top_topics_and_observing_domains()) { | 
|  | observing_domains.insert(topic_and_domains.hashed_domains().begin(), | 
|  | topic_and_domains.hashed_domains().end()); | 
|  | } | 
|  | } | 
|  | return observing_domains; | 
|  | } | 
|  |  | 
|  | }  // 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, | 
|  | std::unique_ptr<Annotator> annotator, | 
|  | TopicAccessedCallback topic_accessed_callback) | 
|  | : privacy_sandbox_settings_(privacy_sandbox_settings), | 
|  | history_service_(history_service), | 
|  | site_data_manager_(site_data_manager), | 
|  | browsing_topics_state_( | 
|  | profile_path, | 
|  | base::BindOnce( | 
|  | &BrowsingTopicsServiceImpl::OnBrowsingTopicsStateLoaded, | 
|  | base::Unretained(this))), | 
|  | annotator_(std::move(annotator)), | 
|  | topic_accessed_callback_(std::move(topic_accessed_callback)), | 
|  | session_start_time_(base::Time::Now()) { | 
|  | DCHECK(topic_accessed_callback_); | 
|  | privacy_sandbox_settings_observation_.Observe(privacy_sandbox_settings); | 
|  | history_service_observation_.Observe(history_service); | 
|  | } | 
|  |  | 
|  | bool BrowsingTopicsServiceImpl::HandleTopicsWebApi( | 
|  | const url::Origin& context_origin, | 
|  | content::RenderFrameHost* main_frame, | 
|  | ApiCallerSource caller_source, | 
|  | bool get_topics, | 
|  | bool observe, | 
|  | std::vector<blink::mojom::EpochTopicPtr>& topics) { | 
|  | DCHECK(topics.empty()); | 
|  | DCHECK(get_topics || observe); | 
|  |  | 
|  | if (is_shutting_down_) { | 
|  | return false; | 
|  | } | 
|  |  | 
|  | RecordBrowsingTopicsApiActionTypeMetrics(caller_source, get_topics, observe); | 
|  |  | 
|  | if (!browsing_topics_state_loaded_) { | 
|  | RecordBrowsingTopicsApiResultMetrics(ApiAccessResult::kStateNotReady, | 
|  | main_frame, get_topics); | 
|  | return false; | 
|  | } | 
|  |  | 
|  | if (!privacy_sandbox_settings_->IsTopicsAllowed()) { | 
|  | RecordBrowsingTopicsApiResultMetrics( | 
|  | ApiAccessResult::kAccessDisallowedBySettings, main_frame, get_topics); | 
|  | return false; | 
|  | } | 
|  |  | 
|  | if (!privacy_sandbox_settings_->IsTopicsAllowedForContext( | 
|  | /*top_frame_origin=*/main_frame->GetLastCommittedOrigin(), | 
|  | context_origin.GetURL(), main_frame)) { | 
|  | RecordBrowsingTopicsApiResultMetrics( | 
|  | ApiAccessResult::kAccessDisallowedBySettings, main_frame, get_topics); | 
|  | return false; | 
|  | } | 
|  |  | 
|  | RecordBrowsingTopicsApiResultMetrics(ApiAccessResult::kSuccess, main_frame, | 
|  | get_topics); | 
|  |  | 
|  | 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, context_domain, | 
|  | history_service_, observe); | 
|  |  | 
|  | if (!get_topics) { | 
|  | return true; | 
|  | } | 
|  |  | 
|  | std::string top_domain = | 
|  | net::registry_controlled_domains::GetDomainAndRegistry( | 
|  | main_frame->GetLastCommittedOrigin().GetURL(), | 
|  | net::registry_controlled_domains::INCLUDE_PRIVATE_REGISTRIES); | 
|  |  | 
|  | std::vector<CandidateTopic> valid_candidate_topics; | 
|  |  | 
|  | for (const EpochTopics* epoch : | 
|  | browsing_topics_state_.EpochsForSite(top_domain)) { | 
|  | CandidateTopic candidate_topic = epoch->CandidateTopicForSite( | 
|  | top_domain, hashed_context_domain, browsing_topics_state_.hmac_key()); | 
|  |  | 
|  | if (!candidate_topic.IsValid()) { | 
|  | continue; | 
|  | } | 
|  |  | 
|  | // Although a top topic can never be in the disallowed state, the returned | 
|  | // `candidate_topic` may be the random one. Thus we still need this check. | 
|  | if (!privacy_sandbox_settings_->IsTopicAllowed( | 
|  | privacy_sandbox::CanonicalTopic( | 
|  | candidate_topic.topic(), candidate_topic.taxonomy_version()))) { | 
|  | DCHECK(!candidate_topic.is_true_topic()); | 
|  | continue; | 
|  | } | 
|  |  | 
|  | valid_candidate_topics.push_back(std::move(candidate_topic)); | 
|  | } | 
|  |  | 
|  | RecordBrowsingTopicsApiResultMetrics(valid_candidate_topics, main_frame); | 
|  |  | 
|  | for (const CandidateTopic& candidate_topic : valid_candidate_topics) { | 
|  | if (candidate_topic.should_be_filtered()) { | 
|  | continue; | 
|  | } | 
|  |  | 
|  | // `PageSpecificContentSettings` should only observe true top topics | 
|  | // accessed on the page. It's okay to notify the same topic multiple | 
|  | // times even though duplicate topics will be removed in the end. | 
|  | if (candidate_topic.is_true_topic()) { | 
|  | privacy_sandbox::CanonicalTopic canonical_topic( | 
|  | candidate_topic.topic(), candidate_topic.taxonomy_version()); | 
|  | topic_accessed_callback_.Run(main_frame, context_origin, | 
|  | /*blocked_by_policy=*/false, | 
|  | canonical_topic); | 
|  | } | 
|  |  | 
|  | auto result_topic = blink::mojom::EpochTopic::New(); | 
|  | result_topic->topic = candidate_topic.topic().value(); | 
|  | result_topic->config_version = base::StrCat( | 
|  | {"chrome.", base::NumberToString(candidate_topic.config_version())}); | 
|  | result_topic->model_version = | 
|  | base::NumberToString(candidate_topic.model_version()); | 
|  | result_topic->taxonomy_version = | 
|  | base::NumberToString(candidate_topic.taxonomy_version()); | 
|  | result_topic->version = base::StrCat({result_topic->config_version, ":", | 
|  | result_topic->taxonomy_version, ":", | 
|  | result_topic->model_version}); | 
|  | topics.emplace_back(std::move(result_topic)); | 
|  | } | 
|  |  | 
|  | // Sort result based on the version first, and then based on the topic ID. | 
|  | // This groups the topics with the same version together, so that when | 
|  | // transforming into the header format, all duplicate versions can be omitted. | 
|  | std::sort(topics.begin(), topics.end(), | 
|  | [](const blink::mojom::EpochTopicPtr& left, | 
|  | const blink::mojom::EpochTopicPtr& right) { | 
|  | if (left->version != right->version) { | 
|  | return left->version < right->version; | 
|  | } | 
|  |  | 
|  | return left->topic < right->topic; | 
|  | }); | 
|  |  | 
|  | // Remove duplicate entries. | 
|  | topics.erase(std::unique(topics.begin(), topics.end()), topics.end()); | 
|  |  | 
|  | return true; | 
|  | } | 
|  |  | 
|  | int BrowsingTopicsServiceImpl::NumVersionsInEpochs( | 
|  | const url::Origin& main_frame_origin) const { | 
|  | CHECK(browsing_topics_state_loaded_); | 
|  | CHECK(!is_shutting_down_); | 
|  | CHECK(privacy_sandbox_settings_->IsTopicsAllowed()); | 
|  |  | 
|  | std::string main_frame_domain = | 
|  | net::registry_controlled_domains::GetDomainAndRegistry( | 
|  | main_frame_origin.GetURL(), | 
|  | net::registry_controlled_domains::INCLUDE_PRIVATE_REGISTRIES); | 
|  |  | 
|  | std::set<std::pair<int, int64_t>> distinct_versions; | 
|  | for (const EpochTopics* epoch : | 
|  | browsing_topics_state_.EpochsForSite(main_frame_domain)) { | 
|  | if (epoch->HasValidVersions()) { | 
|  | distinct_versions.emplace(epoch->taxonomy_version(), | 
|  | epoch->model_version()); | 
|  | } | 
|  | } | 
|  |  | 
|  | return distinct_versions.size(); | 
|  | } | 
|  |  | 
|  | 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 (is_shutting_down_) { | 
|  | std::move(callback).Run( | 
|  | mojom::WebUIGetBrowsingTopicsStateResult::NewOverrideStatusMessage( | 
|  | "BrowsingTopicsService is shutting down.")); | 
|  | 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_.Stop(); | 
|  | CalculateBrowsingTopics(/*is_manually_triggered=*/true, | 
|  | /*previous_timeout_count=*/0); | 
|  | return; | 
|  | } | 
|  |  | 
|  | site_data_manager_->GetContextDomainsFromHashedContextDomains( | 
|  | GetAllObservingDomains(browsing_topics_state_), | 
|  | base::BindOnce( | 
|  | &BrowsingTopicsServiceImpl::GetBrowsingTopicsStateForWebUiHelper, | 
|  | weak_ptr_factory_.GetWeakPtr(), std::move(callback))); | 
|  | } | 
|  |  | 
|  | std::vector<privacy_sandbox::CanonicalTopic> | 
|  | BrowsingTopicsServiceImpl::GetTopTopicsForDisplay() const { | 
|  | if (!browsing_topics_state_loaded_ || is_shutting_down_) { | 
|  | 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::ValidateCalculationSchedule() { | 
|  | if (!browsing_topics_state_loaded_ || topics_calculator_ || | 
|  | is_shutting_down_ || recorded_calculation_did_not_occur_metrics_) { | 
|  | return; | 
|  | } | 
|  |  | 
|  | // Verify the alignment of the calculation schedule with the topics state's | 
|  | // scheduled time, allowing for a one-minute flex window to accommodate the | 
|  | // timer's imprecision. In the event of a discrepancy, log metrics to aid in | 
|  | // troubleshooting. | 
|  | base::TimeDelta elapsed_since_scheduled_time = | 
|  | base::Time::Now() - | 
|  | browsing_topics_state_.next_scheduled_calculation_time(); | 
|  |  | 
|  | if (elapsed_since_scheduled_time > base::Minutes(1)) { | 
|  | base::UmaHistogramExactLinear( | 
|  | "BrowsingTopics.EpochTopicsCalculation.DidNotOccurAtScheduledTime." | 
|  | "DaysSinceSessionStart", | 
|  | (base::Time::Now() - session_start_time_).InDays(), | 
|  | /*exclusive_max=*/30); | 
|  | base::UmaHistogramExactLinear( | 
|  | "BrowsingTopics.EpochTopicsCalculation.DidNotOccurAtScheduledTime." | 
|  | "HoursSinceScheduledTime", | 
|  | elapsed_since_scheduled_time.InHours(), | 
|  | /*exclusive_max=*/30); | 
|  |  | 
|  | recorded_calculation_did_not_occur_metrics_ = true; | 
|  | } | 
|  | } | 
|  |  | 
|  | Annotator* BrowsingTopicsServiceImpl::GetAnnotator() { | 
|  | return annotator_.get(); | 
|  | } | 
|  |  | 
|  | void BrowsingTopicsServiceImpl::ClearTopic( | 
|  | const privacy_sandbox::CanonicalTopic& canonical_topic) { | 
|  | if (!browsing_topics_state_loaded_ || is_shutting_down_) { | 
|  | return; | 
|  | } | 
|  |  | 
|  | browsing_topics_state_.ClearTopic(canonical_topic.topic_id()); | 
|  | } | 
|  |  | 
|  | void BrowsingTopicsServiceImpl::ClearTopicsDataForOrigin( | 
|  | const url::Origin& origin) { | 
|  | if (!browsing_topics_state_loaded_ || is_shutting_down_) { | 
|  | 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_ || is_shutting_down_) { | 
|  | 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, | 
|  | Annotator* annotator, | 
|  | const base::circular_deque<EpochTopics>& epochs, | 
|  | bool is_manually_triggered, | 
|  | int previous_timeout_count, | 
|  | base::Time session_start_time, | 
|  | BrowsingTopicsCalculator::CalculateCompletedCallback callback) { | 
|  | CHECK(!is_shutting_down_); | 
|  | return std::make_unique<BrowsingTopicsCalculator>( | 
|  | privacy_sandbox_settings, history_service, site_data_manager, annotator, | 
|  | epochs, is_manually_triggered, previous_timeout_count, session_start_time, | 
|  | std::move(callback)); | 
|  | } | 
|  |  | 
|  | const BrowsingTopicsState& BrowsingTopicsServiceImpl::browsing_topics_state() { | 
|  | return browsing_topics_state_; | 
|  | } | 
|  |  | 
|  | void BrowsingTopicsServiceImpl::ScheduleBrowsingTopicsCalculation( | 
|  | bool is_manually_triggered, | 
|  | int previous_timeout_count, | 
|  | base::TimeDelta delay) { | 
|  | DCHECK(browsing_topics_state_loaded_); | 
|  |  | 
|  | browsing_topics_state_.UpdateNextScheduledCalculationTime(delay); | 
|  |  | 
|  | // `this` owns the timer, which is automatically cancelled on destruction, so | 
|  | // base::Unretained(this) is safe. | 
|  | schedule_calculate_timer_.Start( | 
|  | FROM_HERE, base::Time::Now() + delay, | 
|  | base::BindOnce(&BrowsingTopicsServiceImpl::CalculateBrowsingTopics, | 
|  | base::Unretained(this), is_manually_triggered, | 
|  | previous_timeout_count)); | 
|  | } | 
|  |  | 
|  | void BrowsingTopicsServiceImpl::CalculateBrowsingTopics( | 
|  | bool is_manually_triggered, | 
|  | int previous_timeout_count) { | 
|  | DCHECK(browsing_topics_state_loaded_); | 
|  |  | 
|  | DCHECK(!topics_calculator_); | 
|  |  | 
|  | if (is_shutting_down_) { | 
|  | return; | 
|  | } | 
|  |  | 
|  | // `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_, | 
|  | annotator_.get(), browsing_topics_state_.epochs(), is_manually_triggered, | 
|  | previous_timeout_count, session_start_time_, | 
|  | base::BindOnce( | 
|  | &BrowsingTopicsServiceImpl::OnCalculateBrowsingTopicsCompleted, | 
|  | base::Unretained(this))); | 
|  | } | 
|  |  | 
|  | void BrowsingTopicsServiceImpl::OnCalculateBrowsingTopicsCompleted( | 
|  | EpochTopics epoch_topics) { | 
|  | CHECK(browsing_topics_state_loaded_); | 
|  | CHECK(topics_calculator_); | 
|  | CHECK(!schedule_calculate_timer_.IsRunning()); | 
|  | CHECK(!is_shutting_down_); | 
|  |  | 
|  | const std::optional<CalculatorResultStatus>& status = | 
|  | epoch_topics.calculator_result_status(); | 
|  | CHECK(status); | 
|  | CHECK_NE(*status, CalculatorResultStatus::kTerminated); | 
|  |  | 
|  | bool is_manually_triggered = topics_calculator_->is_manually_triggered(); | 
|  | int previous_timeout_count = topics_calculator_->previous_timeout_count(); | 
|  | topics_calculator_.reset(); | 
|  |  | 
|  | // If a calculation fails due to hanging, retry it. | 
|  | if (DoesCalculationFailDueToHanging(*status)) { | 
|  | CHECK_LE(blink::features::kBrowsingTopicsFirstTimeoutRetryDelay.Get(), | 
|  | blink::features::kBrowsingTopicsTimePeriodPerEpoch.Get()); | 
|  |  | 
|  | // Retry with exponential backoff for up to 5 times. The delay shouldn't be | 
|  | // greater than an epoch. After 5 retries with exponential backoff, resume | 
|  | // to the epoch cadence. | 
|  | base::TimeDelta delay = | 
|  | blink::features::kBrowsingTopicsTimePeriodPerEpoch.Get(); | 
|  |  | 
|  | if (previous_timeout_count < 5) { | 
|  | base::TimeDelta exponential_backoff_delay = | 
|  | blink::features::kBrowsingTopicsFirstTimeoutRetryDelay.Get() * | 
|  | (1LL << previous_timeout_count); | 
|  |  | 
|  | delay = std::min(delay, exponential_backoff_delay); | 
|  | } | 
|  |  | 
|  | ScheduleBrowsingTopicsCalculation(is_manually_triggered, | 
|  | previous_timeout_count + 1, delay); | 
|  | return; | 
|  | } | 
|  |  | 
|  | if (!browsing_topics_state_.epochs().empty()) { | 
|  | // Use 24 days as the max value, because 24 days is the maximum number of | 
|  | // days that works with UmaHistogramCustomTimes due to its conversion of | 
|  | // times into milliseconds. We expect most values to be around | 
|  | // `kBrowsingTopicsTimePeriodPerEpoch`. | 
|  | base::UmaHistogramCustomTimes( | 
|  | "BrowsingTopics.EpochTopicsCalculation.TimeBetweenCalculations", | 
|  | epoch_topics.calculation_time() - | 
|  | browsing_topics_state_.epochs().back().calculation_time(), | 
|  | /*min=*/base::Seconds(1), /*max=*/base::Days(24), /*buckets=*/100); | 
|  | } | 
|  |  | 
|  | std::optional<EpochTopics> maybe_removed_epoch = | 
|  | browsing_topics_state_.AddEpoch(std::move(epoch_topics)); | 
|  | if (maybe_removed_epoch.has_value()) { | 
|  | site_data_manager_->ExpireDataBefore( | 
|  | maybe_removed_epoch->calculation_time() - | 
|  | blink::features:: | 
|  | kBrowsingTopicsNumberOfEpochsOfObservationDataToUseForFiltering | 
|  | .Get() * | 
|  | blink::features::kBrowsingTopicsTimePeriodPerEpoch.Get()); | 
|  | } | 
|  |  | 
|  | ScheduleBrowsingTopicsCalculation( | 
|  | /*is_manually_triggered=*/false, | 
|  | /*previous_timeout_count=*/0, | 
|  | blink::features::kBrowsingTopicsTimePeriodPerEpoch.Get()); | 
|  |  | 
|  | for (auto& callback : get_state_for_webui_callbacks_) { | 
|  | site_data_manager_->GetContextDomainsFromHashedContextDomains( | 
|  | GetAllObservingDomains(browsing_topics_state_), | 
|  | base::BindOnce( | 
|  | &BrowsingTopicsServiceImpl::GetBrowsingTopicsStateForWebUiHelper, | 
|  | weak_ptr_factory_.GetWeakPtr(), std::move(callback))); | 
|  | } | 
|  | get_state_for_webui_callbacks_.clear(); | 
|  | } | 
|  |  | 
|  | void BrowsingTopicsServiceImpl::OnBrowsingTopicsStateLoaded() { | 
|  | DCHECK(!browsing_topics_state_loaded_); | 
|  | if (is_shutting_down_) { | 
|  | return; | 
|  | } | 
|  | 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_all_topics_data) { | 
|  | browsing_topics_state_.ClearAllTopics(); | 
|  | } else if (!decision.topics_to_clear.empty()) { | 
|  | for (const privacy_sandbox::CanonicalTopic& canonical_topic : | 
|  | decision.topics_to_clear) { | 
|  | browsing_topics_state_.ClearTopic(canonical_topic.topic_id()); | 
|  | } | 
|  | } | 
|  |  | 
|  | site_data_manager_->ExpireDataBefore(browsing_topics_data_sccessible_since); | 
|  |  | 
|  | browsing_topics_state_.ScheduleEpochsExpiration(); | 
|  |  | 
|  | ScheduleBrowsingTopicsCalculation( | 
|  | /*is_manually_triggered=*/false, | 
|  | /*previous_timeout_count=*/0, decision.next_calculation_delay); | 
|  | } | 
|  |  | 
|  | void BrowsingTopicsServiceImpl::Shutdown() { | 
|  | is_shutting_down_ = true; | 
|  | // Reset `topics_calculator_` if it's set because it holds a raw_ptr to | 
|  | // `privacy_sandbox_settings_` and `history_service_`. | 
|  | if (topics_calculator_) { | 
|  | topics_calculator_.reset(); | 
|  | } | 
|  | // Reset `annotator_` because it holds a raw_ptr to the | 
|  | // the per-profile `OptimizationGuideKeyedService`. | 
|  | annotator_.reset(); | 
|  | privacy_sandbox_settings_observation_.Reset(); | 
|  | history_service_observation_.Reset(); | 
|  | privacy_sandbox_settings_ = nullptr; | 
|  | history_service_ = nullptr; | 
|  | } | 
|  |  | 
|  | void BrowsingTopicsServiceImpl::OnTopicsDataAccessibleSinceUpdated() { | 
|  | CHECK(!is_shutting_down_); | 
|  | 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()); | 
|  |  | 
|  | bool is_manually_triggered = topics_calculator_->is_manually_triggered(); | 
|  | int previous_timeout_count = topics_calculator_->previous_timeout_count(); | 
|  | topics_calculator_.reset(); | 
|  | CalculateBrowsingTopics(is_manually_triggered, previous_timeout_count); | 
|  | } | 
|  | } | 
|  |  | 
|  | void BrowsingTopicsServiceImpl::OnHistoryDeletions( | 
|  | history::HistoryService* history_service, | 
|  | const history::DeletionInfo& deletion_info) { | 
|  | CHECK(!is_shutting_down_); | 
|  | 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()); | 
|  |  | 
|  | bool is_manually_triggered = topics_calculator_->is_manually_triggered(); | 
|  | int previous_timeout_count = topics_calculator_->previous_timeout_count(); | 
|  | topics_calculator_.reset(); | 
|  | CalculateBrowsingTopics(is_manually_triggered, previous_timeout_count); | 
|  | } | 
|  | } | 
|  |  | 
|  | void BrowsingTopicsServiceImpl::GetBrowsingTopicsStateForWebUiHelper( | 
|  | mojom::PageHandler::GetBrowsingTopicsStateCallback callback, | 
|  | std::map<HashedDomain, std::string> hashed_to_unhashed_context_domains) { | 
|  | DCHECK(browsing_topics_state_loaded_); | 
|  | DCHECK(!topics_calculator_); | 
|  |  | 
|  | if (is_shutting_down_) { | 
|  | std::move(callback).Run( | 
|  | mojom::WebUIGetBrowsingTopicsStateResult::NewOverrideStatusMessage( | 
|  | "BrowsingTopicsService is shutting down.")); | 
|  | return; | 
|  | } | 
|  |  | 
|  | 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 HashedDomain& hashed_domain : | 
|  | topic_and_domains.hashed_domains()) { | 
|  | auto it = hashed_to_unhashed_context_domains.find(hashed_domain); | 
|  | if (it != hashed_to_unhashed_context_domains.end()) { | 
|  | webui_observed_by_domains.push_back(it->second); | 
|  | } else { | 
|  | // Default to the hashed value if we don't have the original. | 
|  | webui_observed_by_domains.push_back( | 
|  | base::NumberToString(hashed_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. | 
|  | std::ranges::reverse(webui_state->epochs); | 
|  |  | 
|  | std::move(callback).Run( | 
|  | mojom::WebUIGetBrowsingTopicsStateResult::NewBrowsingTopicsState( | 
|  | std::move(webui_state))); | 
|  | } | 
|  |  | 
|  | }  // namespace browsing_topics |