| // 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 <random> |
| #include <vector> |
| |
| #include "base/metrics/histogram_functions.h" |
| #include "base/notreached.h" |
| #include "base/rand_util.h" |
| #include "base/ranges/algorithm.h" |
| #include "base/strings/strcat.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 { |
| |
| // 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; |
| } |
| |
| 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) { |
| 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()); |
| } |
| } |
| |
| const int kBuckets = 10; |
| DCHECK_GE(kBuckets, |
| blink::features::kBrowsingTopicsNumberOfEpochsToExpose.Get()); |
| |
| base::UmaHistogramExactLinear("BrowsingTopics.Result.RealTopicCount", |
| real_count, kBuckets); |
| base::UmaHistogramExactLinear("BrowsingTopics.Result.FakeTopicCount", |
| fake_count, kBuckets); |
| base::UmaHistogramExactLinear("BrowsingTopics.Result.FilteredTopicCount", |
| filtered_count, kBuckets); |
| |
| 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, |
| |
| kMaxValue = kObserveViaIframeAttributeApi, |
| }; |
| |
| 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; |
| } |
| |
| DCHECK_EQ(caller_source, ApiCallerSource::kFetch); |
| |
| if (get_topics) { |
| DCHECK(!observe); |
| |
| base::UmaHistogramEnumeration( |
| kBrowsingTopicsApiActionTypeHistogramId, |
| BrowsingTopicsApiActionType::kGetViaFetchLikeApi); |
| return; |
| } |
| |
| DCHECK(observe); |
| base::UmaHistogramEnumeration( |
| kBrowsingTopicsApiActionTypeHistogramId, |
| BrowsingTopicsApiActionType::kObserveViaFetchLikeApi); |
| } |
| |
| } // 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)) { |
| 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); |
| |
| 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())) { |
| 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); |
| |
| if (observe) { |
| // Track the API usage context after the permissions check. |
| BrowsingTopicsPageLoadDataTracker::GetOrCreateForPage(main_frame->GetPage()) |
| ->OnBrowsingTopicsApiUsed(hashed_context_domain, history_service_); |
| } |
| |
| 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( |
| blink::features::kBrowsingTopicsConfigVersion.Get())}); |
| 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)); |
| } |
| |
| std::sort(topics.begin(), topics.end()); |
| |
| // Remove duplicate entries. |
| topics.erase(std::unique(topics.begin(), topics.end()), topics.end()); |
| |
| return true; |
| } |
| |
| 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::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; |
| } |
| |
| Annotator* BrowsingTopicsServiceImpl::GetAnnotator() { |
| return annotator_.get(); |
| } |
| |
| 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, |
| Annotator* annotator, |
| const base::circular_deque<EpochTopics>& epochs, |
| BrowsingTopicsCalculator::CalculateCompletedCallback callback) { |
| return std::make_unique<BrowsingTopicsCalculator>( |
| privacy_sandbox_settings, history_service, site_data_manager, annotator, |
| 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_, |
| annotator_.get(), 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_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(), |
| canonical_topic.taxonomy_version()); |
| } |
| } |
| |
| 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 |