blob: 0e320f778adf3c44cb431043c233f1b079c6365d [file] [log] [blame]
// Copyright 2016 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/ntp_snippets/content_suggestions_service.h"
#include <algorithm>
#include <iterator>
#include <set>
#include <utility>
#include "base/bind.h"
#include "base/location.h"
#include "base/metrics/histogram_macros.h"
#include "base/stl_util.h"
#include "base/strings/string_number_conversions.h"
#include "base/strings/stringprintf.h"
#include "base/threading/thread_task_runner_handle.h"
#include "base/time/default_clock.h"
#include "base/values.h"
#include "components/favicon/core/favicon_server_fetcher_params.h"
#include "components/favicon/core/large_icon_service.h"
#include "components/favicon_base/fallback_icon_style.h"
#include "components/favicon_base/favicon_types.h"
#include "components/ntp_snippets/content_suggestions_metrics.h"
#include "components/ntp_snippets/pref_names.h"
#include "components/ntp_snippets/remote/remote_suggestions_provider.h"
#include "components/prefs/pref_registry_simple.h"
#include "components/prefs/pref_service.h"
#include "net/traffic_annotation/network_traffic_annotation.h"
#include "ui/gfx/image/image.h"
namespace ntp_snippets {
namespace {
// Enumeration listing all possible outcomes for fetch attempts of favicons for
// content suggestions. Used for UMA histograms, so do not change existing
// values. Insert new values at the end, and update the histogram definition.
// GENERATED_JAVA_ENUM_PACKAGE: org.chromium.chrome.browser.ntp.snippets
enum class FaviconFetchResult {
SUCCESS_CACHED = 0,
SUCCESS_FETCHED = 1,
FAILURE = 2,
COUNT = 3
};
void RecordFaviconFetchResult(FaviconFetchResult result) {
UMA_HISTOGRAM_ENUMERATION(
"NewTabPage.ContentSuggestions.ArticleFaviconFetchResult", result,
FaviconFetchResult::COUNT);
}
} // namespace
ContentSuggestionsService::ContentSuggestionsService(
State state,
identity::IdentityManager* identity_manager,
history::HistoryService* history_service,
favicon::LargeIconService* large_icon_service,
PrefService* pref_service,
std::unique_ptr<CategoryRanker> category_ranker,
std::unique_ptr<UserClassifier> user_classifier,
std::unique_ptr<RemoteSuggestionsScheduler> remote_suggestions_scheduler,
std::unique_ptr<Logger> debug_logger)
: state_(state),
identity_manager_observer_(this),
history_service_observer_(this),
remote_suggestions_provider_(nullptr),
large_icon_service_(large_icon_service),
pref_service_(pref_service),
remote_suggestions_scheduler_(std::move(remote_suggestions_scheduler)),
user_classifier_(std::move(user_classifier)),
category_ranker_(std::move(category_ranker)),
debug_logger_(std::move(debug_logger)) {
// Can be null in tests.
if (identity_manager) {
identity_manager_observer_.Add(identity_manager);
}
if (history_service) {
history_service_observer_.Add(history_service);
}
debug_logger_->Log(FROM_HERE, /*message=*/std::string());
RestoreDismissedCategoriesFromPrefs();
}
ContentSuggestionsService::~ContentSuggestionsService() = default;
void ContentSuggestionsService::Shutdown() {
remote_suggestions_provider_ = nullptr;
remote_suggestions_scheduler_ = nullptr;
suggestions_by_category_.clear();
providers_by_category_.clear();
categories_.clear();
providers_.clear();
state_ = State::DISABLED;
for (Observer& observer : observers_) {
observer.ContentSuggestionsServiceShutdown();
}
}
// static
void ContentSuggestionsService::RegisterProfilePrefs(
PrefRegistrySimple* registry) {
registry->RegisterListPref(prefs::kDismissedCategories);
}
std::vector<Category> ContentSuggestionsService::GetCategories() const {
std::vector<Category> sorted_categories = categories_;
std::sort(sorted_categories.begin(), sorted_categories.end(),
[this](const Category& left, const Category& right) {
return category_ranker_->Compare(left, right);
});
return sorted_categories;
}
CategoryStatus ContentSuggestionsService::GetCategoryStatus(
Category category) const {
if (state_ == State::DISABLED) {
return CategoryStatus::ALL_SUGGESTIONS_EXPLICITLY_DISABLED;
}
auto iterator = providers_by_category_.find(category);
if (iterator == providers_by_category_.end()) {
return CategoryStatus::NOT_PROVIDED;
}
return iterator->second->GetCategoryStatus(category);
}
base::Optional<CategoryInfo> ContentSuggestionsService::GetCategoryInfo(
Category category) const {
auto iterator = providers_by_category_.find(category);
if (iterator == providers_by_category_.end()) {
return base::Optional<CategoryInfo>();
}
return iterator->second->GetCategoryInfo(category);
}
const std::vector<ContentSuggestion>&
ContentSuggestionsService::GetSuggestionsForCategory(Category category) const {
auto iterator = suggestions_by_category_.find(category);
if (iterator == suggestions_by_category_.end()) {
return no_suggestions_;
}
return iterator->second;
}
void ContentSuggestionsService::FetchSuggestionImage(
const ContentSuggestion::ID& suggestion_id,
ImageFetchedCallback callback) {
if (!providers_by_category_.count(suggestion_id.category())) {
LOG(WARNING) << "Requested image for suggestion " << suggestion_id
<< " for unavailable category " << suggestion_id.category();
base::ThreadTaskRunnerHandle::Get()->PostTask(
FROM_HERE, base::BindOnce(std::move(callback), gfx::Image()));
return;
}
providers_by_category_[suggestion_id.category()]->FetchSuggestionImage(
suggestion_id, std::move(callback));
}
void ContentSuggestionsService::FetchSuggestionImageData(
const ContentSuggestion::ID& suggestion_id,
ImageDataFetchedCallback callback) {
if (!providers_by_category_.count(suggestion_id.category())) {
LOG(WARNING) << "Requested image for suggestion " << suggestion_id
<< " for unavailable category " << suggestion_id.category();
base::ThreadTaskRunnerHandle::Get()->PostTask(
FROM_HERE, base::BindOnce(std::move(callback), std::string()));
return;
}
providers_by_category_[suggestion_id.category()]->FetchSuggestionImageData(
suggestion_id, std::move(callback));
}
// TODO(jkrcal): Split the favicon fetching into a separate class.
void ContentSuggestionsService::FetchSuggestionFavicon(
const ContentSuggestion::ID& suggestion_id,
int minimum_size_in_pixel,
int desired_size_in_pixel,
ImageFetchedCallback callback) {
const GURL& domain_with_favicon = GetFaviconDomain(suggestion_id);
if (!domain_with_favicon.is_valid() || !large_icon_service_) {
base::ThreadTaskRunnerHandle::Get()->PostTask(
FROM_HERE, base::BindOnce(std::move(callback), gfx::Image()));
RecordFaviconFetchResult(FaviconFetchResult::FAILURE);
return;
}
GetFaviconFromCache(domain_with_favicon, minimum_size_in_pixel,
desired_size_in_pixel, std::move(callback),
/*continue_to_google_server=*/true);
}
GURL ContentSuggestionsService::GetFaviconDomain(
const ContentSuggestion::ID& suggestion_id) {
const std::vector<ContentSuggestion>& suggestions =
suggestions_by_category_[suggestion_id.category()];
auto position =
std::find_if(suggestions.begin(), suggestions.end(),
[&suggestion_id](const ContentSuggestion& suggestion) {
return suggestion_id == suggestion.id();
});
if (position != suggestions.end()) {
return position->url_with_favicon();
}
// Look up the URL in the archive of |remote_suggestions_provider_|.
// TODO(jkrcal): Fix how Fetch more works or find other ways to remove this
// hack. crbug.com/714031
if (providers_by_category_[suggestion_id.category()] ==
remote_suggestions_provider_) {
return remote_suggestions_provider_->GetUrlWithFavicon(suggestion_id);
}
return GURL();
}
void ContentSuggestionsService::GetFaviconFromCache(
const GURL& publisher_url,
int minimum_size_in_pixel,
int desired_size_in_pixel,
ImageFetchedCallback callback,
bool continue_to_google_server) {
// TODO(jkrcal): Create a general wrapper function in LargeIconService that
// does handle the get-from-cache-and-fallback-to-google-server functionality
// in one shot (for all clients that do not need to react in between).
// Use desired_size = 0 for getting the icon from the cache (so that the icon
// is not poorly rescaled by LargeIconService).
large_icon_service_->GetLargeIconImageOrFallbackStyle(
publisher_url, minimum_size_in_pixel, /*desired_size_in_pixel=*/0,
base::Bind(&ContentSuggestionsService::OnGetFaviconFromCacheFinished,
base::Unretained(this), publisher_url, minimum_size_in_pixel,
desired_size_in_pixel, base::Passed(std::move(callback)),
continue_to_google_server),
&favicons_task_tracker_);
}
void ContentSuggestionsService::OnGetFaviconFromCacheFinished(
const GURL& publisher_url,
int minimum_size_in_pixel,
int desired_size_in_pixel,
ImageFetchedCallback callback,
bool continue_to_google_server,
const favicon_base::LargeIconImageResult& result) {
if (!result.image.IsEmpty()) {
std::move(callback).Run(result.image);
// The icon is from cache if we haven't gone to Google server yet. The icon
// is freshly fetched, otherwise.
RecordFaviconFetchResult(continue_to_google_server
? FaviconFetchResult::SUCCESS_CACHED
: FaviconFetchResult::SUCCESS_FETCHED);
// Update the time when the icon was last requested - postpone thus the
// automatic eviction of the favicon from the favicon database.
large_icon_service_->TouchIconFromGoogleServer(result.icon_url);
return;
}
if (!continue_to_google_server ||
(result.fallback_icon_style &&
!result.fallback_icon_style->is_default_background_color)) {
// We cannot download from the server if there is some small icon in the
// cache (resulting in non-default background color) or if we already did
// so.
std::move(callback).Run(gfx::Image());
RecordFaviconFetchResult(FaviconFetchResult::FAILURE);
return;
}
// Try to fetch the favicon from a Google favicon server.
// TODO(jkrcal): Currently used only for Articles for you which have public
// URLs. Let the provider decide whether |publisher_url| may be private or
// not.
net::NetworkTrafficAnnotationTag traffic_annotation =
net::DefineNetworkTrafficAnnotation("content_suggestion_get_favicon", R"(
semantics {
sender: "Content Suggestion"
description:
"Sends a request to a Google server to retrieve the favicon bitmap "
"for an article suggestion on the new tab page (URLs are public "
"and provided by Google)."
trigger:
"A request can be sent if Chrome does not have a favicon for a "
"particular page."
data: "Page URL and desired icon size."
destination: GOOGLE_OWNED_SERVICE
}
policy {
cookies_allowed: NO
setting: "This feature cannot be disabled by settings."
policy_exception_justification: "Not implemented."
})");
large_icon_service_
->GetLargeIconOrFallbackStyleFromGoogleServerSkippingLocalCache(
favicon::FaviconServerFetcherParams::CreateForMobile(
publisher_url, minimum_size_in_pixel, desired_size_in_pixel),
/*may_page_url_be_private=*/false, traffic_annotation,
base::Bind(
&ContentSuggestionsService::OnGetFaviconFromGoogleServerFinished,
base::Unretained(this), publisher_url, minimum_size_in_pixel,
desired_size_in_pixel, base::Passed(std::move(callback))));
}
void ContentSuggestionsService::OnGetFaviconFromGoogleServerFinished(
const GURL& publisher_url,
int minimum_size_in_pixel,
int desired_size_in_pixel,
ImageFetchedCallback callback,
favicon_base::GoogleFaviconServerRequestStatus status) {
if (status != favicon_base::GoogleFaviconServerRequestStatus::SUCCESS) {
std::move(callback).Run(gfx::Image());
RecordFaviconFetchResult(FaviconFetchResult::FAILURE);
return;
}
GetFaviconFromCache(publisher_url, minimum_size_in_pixel,
desired_size_in_pixel, std::move(callback),
/*continue_to_google_server=*/false);
}
void ContentSuggestionsService::ClearHistory(
base::Time begin,
base::Time end,
const base::Callback<bool(const GURL& url)>& filter) {
for (const auto& provider : providers_) {
provider->ClearHistory(begin, end, filter);
}
category_ranker_->ClearHistory(begin, end);
// This potentially removed personalized data which we shouldn't display
// anymore.
for (Observer& observer : observers_) {
observer.OnFullRefreshRequired();
}
}
void ContentSuggestionsService::ClearAllCachedSuggestions() {
suggestions_by_category_.clear();
for (const auto& provider : providers_) {
provider->ClearCachedSuggestions();
}
for (Observer& observer : observers_) {
observer.OnFullRefreshRequired();
}
}
void ContentSuggestionsService::GetDismissedSuggestionsForDebugging(
Category category,
DismissedSuggestionsCallback callback) {
auto iterator = providers_by_category_.find(category);
if (iterator != providers_by_category_.end()) {
iterator->second->GetDismissedSuggestionsForDebugging(category,
std::move(callback));
} else {
std::move(callback).Run(std::vector<ContentSuggestion>());
}
}
void ContentSuggestionsService::ClearDismissedSuggestionsForDebugging(
Category category) {
auto iterator = providers_by_category_.find(category);
if (iterator != providers_by_category_.end()) {
iterator->second->ClearDismissedSuggestionsForDebugging(category);
}
}
void ContentSuggestionsService::DismissSuggestion(
const ContentSuggestion::ID& suggestion_id) {
if (!providers_by_category_.count(suggestion_id.category())) {
LOG(WARNING) << "Dismissed suggestion " << suggestion_id
<< " for unavailable category " << suggestion_id.category();
return;
}
metrics::RecordContentSuggestionDismissed();
providers_by_category_[suggestion_id.category()]->DismissSuggestion(
suggestion_id);
// Remove the suggestion locally if it is present. A suggestion may be missing
// localy e.g. if it was sent to UI through |Fetch| or it has been dismissed
// from a different NTP.
RemoveSuggestionByID(suggestion_id);
}
void ContentSuggestionsService::DismissCategory(Category category) {
auto providers_it = providers_by_category_.find(category);
if (providers_it == providers_by_category_.end()) {
return;
}
metrics::RecordCategoryDismissed();
ContentSuggestionsProvider* provider = providers_it->second;
UnregisterCategory(category, provider);
dismissed_providers_by_category_[category] = provider;
StoreDismissedCategoriesToPrefs();
category_ranker_->OnCategoryDismissed(category);
}
void ContentSuggestionsService::RestoreDismissedCategories() {
// Make a copy as the original will be modified during iteration.
auto dismissed_providers_by_category_copy = dismissed_providers_by_category_;
for (const auto& category_provider_pair :
dismissed_providers_by_category_copy) {
RestoreDismissedCategory(category_provider_pair.first);
}
StoreDismissedCategoriesToPrefs();
DCHECK(dismissed_providers_by_category_.empty());
}
void ContentSuggestionsService::AddObserver(Observer* observer) {
observers_.AddObserver(observer);
}
void ContentSuggestionsService::RemoveObserver(Observer* observer) {
observers_.RemoveObserver(observer);
}
void ContentSuggestionsService::RegisterProvider(
std::unique_ptr<ContentSuggestionsProvider> provider) {
DCHECK(state_ == State::ENABLED);
providers_.push_back(std::move(provider));
}
void ContentSuggestionsService::Fetch(
const Category& category,
const std::set<std::string>& known_suggestion_ids,
FetchDoneCallback callback) {
auto providers_it = providers_by_category_.find(category);
if (providers_it == providers_by_category_.end()) {
return;
}
metrics::RecordFetchAction();
providers_it->second->Fetch(category, known_suggestion_ids,
std::move(callback));
}
void ContentSuggestionsService::ReloadSuggestions() {
for (const auto& provider : providers_) {
provider->ReloadSuggestions();
}
}
bool ContentSuggestionsService::AreRemoteSuggestionsEnabled() const {
return remote_suggestions_provider_ &&
!remote_suggestions_provider_->IsDisabled();
}
////////////////////////////////////////////////////////////////////////////////
// Private methods
void ContentSuggestionsService::OnNewSuggestions(
ContentSuggestionsProvider* provider,
Category category,
std::vector<ContentSuggestion> suggestions) {
// Providers shouldn't call this when they're in a non-available state.
DCHECK(
IsCategoryStatusInitOrAvailable(provider->GetCategoryStatus(category)));
if (TryRegisterProviderForCategory(provider, category)) {
NotifyCategoryStatusChanged(category);
} else if (IsCategoryDismissed(category)) {
// The category has been registered as a dismissed one. We need to
// check if the dismissal can be cleared now that we received new data.
if (suggestions.empty()) {
return;
}
RestoreDismissedCategory(category);
StoreDismissedCategoriesToPrefs();
NotifyCategoryStatusChanged(category);
}
if (!IsCategoryStatusAvailable(provider->GetCategoryStatus(category))) {
// A provider shouldn't send us suggestions while it's not available.
DCHECK(suggestions.empty());
return;
}
suggestions_by_category_[category] = std::move(suggestions);
for (Observer& observer : observers_) {
observer.OnNewSuggestions(category);
}
}
void ContentSuggestionsService::OnCategoryStatusChanged(
ContentSuggestionsProvider* provider,
Category category,
CategoryStatus new_status) {
if (new_status == CategoryStatus::NOT_PROVIDED) {
UnregisterCategory(category, provider);
} else {
if (!IsCategoryStatusAvailable(new_status)) {
suggestions_by_category_.erase(category);
}
TryRegisterProviderForCategory(provider, category);
DCHECK_EQ(new_status, provider->GetCategoryStatus(category));
}
if (!IsCategoryDismissed(category)) {
NotifyCategoryStatusChanged(category);
}
}
void ContentSuggestionsService::OnSuggestionInvalidated(
ContentSuggestionsProvider* provider,
const ContentSuggestion::ID& suggestion_id) {
RemoveSuggestionByID(suggestion_id);
for (Observer& observer : observers_) {
observer.OnSuggestionInvalidated(suggestion_id);
}
}
// identity::IdentityManager::Observer implementation
void ContentSuggestionsService::OnPrimaryAccountSet(
const AccountInfo& account_info) {
OnSignInStateChanged(/*has_signed_in=*/true);
}
void ContentSuggestionsService::OnPrimaryAccountCleared(
const AccountInfo& account_info) {
OnSignInStateChanged(/*has_signed_in=*/false);
}
// history::HistoryServiceObserver implementation.
void ContentSuggestionsService::OnURLsDeleted(
history::HistoryService* history_service,
const history::DeletionInfo& deletion_info) {
// We don't care about expired entries.
if (deletion_info.is_from_expiration()) {
return;
}
if (deletion_info.IsAllHistory()) {
base::Callback<bool(const GURL& url)> filter =
base::Bind([](const GURL& url) { return true; });
ClearHistory(base::Time(), base::Time::Max(), filter);
} else {
// If a user deletes a single URL, we don't consider this a clear user
// intend to clear our data.
// TODO(tschumann): Single URL deletions should be handled on a case-by-case
// basis. However this depends on the provider's details and thus cannot be
// done here. Introduce a OnURLsDeleted() method on the providers to move
// this decision further down.
if (deletion_info.deleted_rows().size() < 2) {
return;
}
std::set<GURL> deleted_urls;
for (const history::URLRow& row : deletion_info.deleted_rows()) {
deleted_urls.insert(row.url());
}
base::Callback<bool(const GURL& url)> filter =
base::Bind([](const std::set<GURL>& set,
const GURL& url) { return set.count(url) != 0; },
deleted_urls);
// We usually don't have any time-related information (the URLRow objects
// usually don't provide a |last_visit()| timestamp. Hence we simply clear
// the whole history for the selected URLs.
ClearHistory(base::Time(), base::Time::Max(), filter);
}
}
void ContentSuggestionsService::HistoryServiceBeingDeleted(
history::HistoryService* history_service) {
history_service_observer_.RemoveAll();
}
bool ContentSuggestionsService::TryRegisterProviderForCategory(
ContentSuggestionsProvider* provider,
Category category) {
auto it = providers_by_category_.find(category);
if (it != providers_by_category_.end()) {
DCHECK_EQ(it->second, provider);
return false;
}
auto dismissed_it = dismissed_providers_by_category_.find(category);
if (dismissed_it != dismissed_providers_by_category_.end()) {
// The initialisation of dismissed categories registers them with |nullptr|
// for providers, we need to check for that to see if the provider is
// already registered or not.
if (!dismissed_it->second) {
dismissed_it->second = provider;
} else {
DCHECK_EQ(dismissed_it->second, provider);
}
return false;
}
RegisterCategory(category, provider);
return true;
}
void ContentSuggestionsService::RegisterCategory(
Category category,
ContentSuggestionsProvider* provider) {
DCHECK(!base::ContainsKey(providers_by_category_, category));
DCHECK(!IsCategoryDismissed(category));
providers_by_category_[category] = provider;
categories_.push_back(category);
if (IsCategoryStatusAvailable(provider->GetCategoryStatus(category))) {
suggestions_by_category_.insert(
std::make_pair(category, std::vector<ContentSuggestion>()));
}
}
void ContentSuggestionsService::UnregisterCategory(
Category category,
ContentSuggestionsProvider* provider) {
auto providers_it = providers_by_category_.find(category);
if (providers_it == providers_by_category_.end()) {
DCHECK(IsCategoryDismissed(category));
return;
}
DCHECK_EQ(provider, providers_it->second);
providers_by_category_.erase(providers_it);
categories_.erase(
std::find(categories_.begin(), categories_.end(), category));
suggestions_by_category_.erase(category);
}
bool ContentSuggestionsService::RemoveSuggestionByID(
const ContentSuggestion::ID& suggestion_id) {
std::vector<ContentSuggestion>* suggestions =
&suggestions_by_category_[suggestion_id.category()];
auto position =
std::find_if(suggestions->begin(), suggestions->end(),
[&suggestion_id](const ContentSuggestion& suggestion) {
return suggestion_id == suggestion.id();
});
if (position == suggestions->end()) {
return false;
}
suggestions->erase(position);
return true;
}
void ContentSuggestionsService::NotifyCategoryStatusChanged(Category category) {
for (Observer& observer : observers_) {
observer.OnCategoryStatusChanged(category, GetCategoryStatus(category));
}
}
void ContentSuggestionsService::OnSignInStateChanged(bool has_signed_in) {
// First notify the providers, so they can make the required changes.
for (const auto& provider : providers_) {
provider->OnSignInStateChanged(has_signed_in);
}
// Finally notify the observers so they refresh only after the backend is
// ready.
for (Observer& observer : observers_) {
observer.OnFullRefreshRequired();
}
}
bool ContentSuggestionsService::IsCategoryDismissed(Category category) const {
return base::ContainsKey(dismissed_providers_by_category_, category);
}
void ContentSuggestionsService::RestoreDismissedCategory(Category category) {
auto dismissed_it = dismissed_providers_by_category_.find(category);
DCHECK(base::ContainsKey(dismissed_providers_by_category_, category));
// Keep the reference to the provider and remove it from the dismissed ones,
// because the category registration enforces that it's not dismissed.
ContentSuggestionsProvider* provider = dismissed_it->second;
dismissed_providers_by_category_.erase(dismissed_it);
if (provider) {
RegisterCategory(category, provider);
}
}
void ContentSuggestionsService::RestoreDismissedCategoriesFromPrefs() {
// This must only be called at startup.
DCHECK(dismissed_providers_by_category_.empty());
DCHECK(providers_by_category_.empty());
const base::ListValue* list =
pref_service_->GetList(prefs::kDismissedCategories);
for (const base::Value& entry : *list) {
int id = 0;
if (!entry.GetAsInteger(&id)) {
DLOG(WARNING) << "Invalid category pref value: " << entry;
continue;
}
// When the provider is registered, it will be stored in this map.
dismissed_providers_by_category_[Category::FromIDValue(id)] = nullptr;
}
}
void ContentSuggestionsService::StoreDismissedCategoriesToPrefs() {
base::ListValue list;
for (const auto& category_provider_pair : dismissed_providers_by_category_) {
list.AppendInteger(category_provider_pair.first.id());
}
pref_service_->Set(prefs::kDismissedCategories, list);
}
void ContentSuggestionsService::DestroyCategoryAndItsProvider(
Category category) {
// Destroying articles category is more complex and not implemented.
DCHECK_NE(category, Category::FromKnownCategory(KnownCategories::ARTICLES));
if (providers_by_category_.count(category) != 1) {
return;
}
{ // Destroy the provider and delete its mentions.
ContentSuggestionsProvider* raw_provider = providers_by_category_[category];
base::EraseIf(
providers_,
[&raw_provider](
const std::unique_ptr<ContentSuggestionsProvider>& provider) {
return provider.get() == raw_provider;
});
providers_by_category_.erase(category);
if (dismissed_providers_by_category_.count(category) == 1) {
dismissed_providers_by_category_[category] = nullptr;
}
}
suggestions_by_category_.erase(category);
auto it = std::find(categories_.begin(), categories_.end(), category);
categories_.erase(it);
// Notify observers that the category is gone.
NotifyCategoryStatusChanged(category);
}
} // namespace ntp_snippets