blob: d6f32b789a7b3f26b865e90b375cdbb813aa715c [file] [log] [blame]
// Copyright 2015 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/remote/remote_suggestions_provider_impl.h"
#include <algorithm>
#include <iterator>
#include <utility>
#include "base/bind.h"
#include "base/command_line.h"
#include "base/location.h"
#include "base/metrics/field_trial_params.h"
#include "base/metrics/histogram_functions.h"
#include "base/metrics/histogram_macros.h"
#include "base/stl_util.h"
#include "base/strings/string_number_conversions.h"
#include "base/threading/thread_task_runner_handle.h"
#include "base/time/default_clock.h"
#include "base/time/time.h"
#include "base/timer/timer.h"
#include "base/values.h"
#include "components/image_fetcher/core/image_fetcher.h"
#include "components/ntp_snippets/category_rankers/category_ranker.h"
#include "components/ntp_snippets/features.h"
#include "components/ntp_snippets/pref_names.h"
#include "components/ntp_snippets/remote/remote_suggestions_database.h"
#include "components/ntp_snippets/remote/remote_suggestions_scheduler.h"
#include "components/ntp_snippets/remote/remote_suggestions_status_service_impl.h"
#include "components/ntp_snippets/switches.h"
#include "components/ntp_snippets/time_serialization.h"
#include "components/prefs/pref_registry_simple.h"
#include "components/prefs/pref_service.h"
#include "components/strings/grit/components_strings.h"
#include "components/variations/variations_associated_data.h"
#include "ui/gfx/image/image.h"
namespace ntp_snippets {
namespace {
// Maximal number of suggestions we expect to receive from the server during a
// normal (not fetch-more) fetch. Consider replacing sparse UMA histograms with
// COUNTS() if this number increases beyond 50.
// TODO(vitaliii): Either support requesting a given number of suggestions on
// the server or delete this constant (this will require moving the UMA
// reporting below to content_suggestions_metrics).
const int kMaxNormalFetchSuggestionCount = 20;
// Number of archived suggestions we keep around in memory.
const int kMaxArchivedSuggestionCount = 200;
// Maximal number of dismissed suggestions to exclude when fetching new
// suggestions from the server. This limit exists to decrease data usage.
const int kMaxExcludedDismissedIds = 100;
// Keys for storing CategoryContent info in prefs.
const char kCategoryContentId[] = "id";
const char kCategoryContentTitle[] = "title";
const char kCategoryContentProvidedByServer[] = "provided_by_server";
const char kCategoryContentAllowFetchingMore[] = "allow_fetching_more";
// Variation parameter for ordering new remote categories based on their
// position in the response relative to "Article for you" category.
const char kOrderNewRemoteCategoriesBasedOnArticlesCategory[] =
"order_new_remote_categories_based_on_articles_category";
// Variation parameter for additional prefetched suggestions quantity. Not more
// than this number of prefetched suggestions will be kept longer.
const char kMaxAdditionalPrefetchedSuggestionsParamName[] =
"max_additional_prefetched_suggestions";
const int kDefaultMaxAdditionalPrefetchedSuggestions = 5;
// Variation parameter for additional prefetched suggestions age. Only
// prefetched suggestions fetched not later than this are considered to be kept
// longer.
const char kMaxAgeForAdditionalPrefetchedSuggestionParamName[] =
"max_age_for_additional_prefetched_suggestion_minutes";
const int kDefaultMaxAgeForAdditionalPrefetchedSuggestionMinutes = 36 * 60;
bool IsOrderingNewRemoteCategoriesBasedOnArticlesCategoryEnabled() {
// TODO(vitaliii): Use GetFieldTrialParamByFeature(As.*)? from
// base/metrics/field_trial_params.h. GetVariationParamByFeature(As.*)? are
// deprecated.
return variations::GetVariationParamByFeatureAsBool(
kArticleSuggestionsFeature,
kOrderNewRemoteCategoriesBasedOnArticlesCategory,
/*default_value=*/false);
}
void AddFetchedCategoriesToRankerBasedOnArticlesCategory(
CategoryRanker* ranker,
const FetchedCategoriesVector& fetched_categories,
Category articles_category) {
DCHECK(IsOrderingNewRemoteCategoriesBasedOnArticlesCategoryEnabled());
// Insert categories which precede "Articles" in the response.
for (const FetchedCategory& fetched_category : fetched_categories) {
if (fetched_category.category == articles_category) {
break;
}
ranker->InsertCategoryBeforeIfNecessary(fetched_category.category,
articles_category);
}
// Insert categories which follow "Articles" in the response. Note that we
// insert them in reversed order, because they are inserted right after
// "Articles", which reverses the order.
for (auto fetched_category_it = fetched_categories.rbegin();
fetched_category_it != fetched_categories.rend();
++fetched_category_it) {
if (fetched_category_it->category == articles_category) {
return;
}
ranker->InsertCategoryAfterIfNecessary(fetched_category_it->category,
articles_category);
}
NOTREACHED() << "Articles category was not found.";
}
bool IsKeepingPrefetchedSuggestionsEnabled() {
return base::FeatureList::IsEnabled(kKeepPrefetchedContentSuggestions);
}
int GetMaxAdditionalPrefetchedSuggestions() {
return base::GetFieldTrialParamByFeatureAsInt(
kKeepPrefetchedContentSuggestions,
kMaxAdditionalPrefetchedSuggestionsParamName,
kDefaultMaxAdditionalPrefetchedSuggestions);
}
base::TimeDelta GetMaxAgeForAdditionalPrefetchedSuggestion() {
return base::TimeDelta::FromMinutes(base::GetFieldTrialParamByFeatureAsInt(
kKeepPrefetchedContentSuggestions,
kMaxAgeForAdditionalPrefetchedSuggestionParamName,
kDefaultMaxAgeForAdditionalPrefetchedSuggestionMinutes));
}
// Whether notifications for fetched suggestions are enabled. Note that this
// param does not overwrite other switches which could disable these
// notifications.
const bool kEnableFetchedSuggestionsNotificationsDefault = true;
const char kEnableFetchedSuggestionsNotificationsParamName[] =
"enable_fetched_suggestions_notifications";
bool IsFetchedSuggestionsNotificationsEnabled() {
return base::GetFieldTrialParamByFeatureAsBool(
kNotificationsFeature, kEnableFetchedSuggestionsNotificationsParamName,
kEnableFetchedSuggestionsNotificationsDefault);
}
// Whether notifications for pushed (prepended) suggestions are enabled. Note
// that this param does not overwrite other switches which could disable these
// notifications.
const bool kEnablePushedSuggestionsNotificationsDefault = false;
const char kEnablePushedSuggestionsNotificationsParamName[] =
"enable_pushed_suggestions_notifications";
bool IsPushedSuggestionsNotificationsEnabled() {
return base::GetFieldTrialParamByFeatureAsBool(
kNotificationsFeature, kEnablePushedSuggestionsNotificationsParamName,
kEnablePushedSuggestionsNotificationsDefault);
}
// Whether notification info is overriden for fetched suggestions. Note that
// this param does not overwrite other switches which could disable these
// notifications.
const bool kForceFetchedSuggestionsNotificationsDefault = false;
const char kForceFetchedSuggestionsNotificationsParamName[] =
"force_fetched_suggestions_notifications";
bool ShouldForceFetchedSuggestionsNotifications() {
return base::GetFieldTrialParamByFeatureAsBool(
kNotificationsFeature, kForceFetchedSuggestionsNotificationsParamName,
kForceFetchedSuggestionsNotificationsDefault);
}
// Variation parameter for number of suggestions to request when fetching more.
const char kFetchMoreSuggestionsCountParamName[] =
"fetch_more_suggestions_count";
const int kFetchMoreSuggestionsCountDefault = 25;
int GetFetchMoreSuggestionsCount() {
return base::GetFieldTrialParamByFeatureAsInt(
kArticleSuggestionsFeature, kFetchMoreSuggestionsCountParamName,
kFetchMoreSuggestionsCountDefault);
}
// Variation parameter for the timeout when fetching suggestions with a loading
// indicator. If the fetch takes too long and the timeout is over, the category
// status is forced back to AVAILABLE. If there are existing (possibly stale)
// suggestions, they get notified.
// This timeout is not used for fetching more as the signature of Fetch()
// provides no way to deliver results later after the timeout.
const char kTimeoutForLoadingIndicatorSecondsParamName[] =
"timeout_for_loading_indicator_seconds";
const int kDefaultTimeoutForLoadingIndicatorSeconds = 5;
base::TimeDelta GetTimeoutForLoadingIndicator() {
return base::TimeDelta::FromSeconds(base::GetFieldTrialParamByFeatureAsInt(
ntp_snippets::kArticleSuggestionsFeature,
kTimeoutForLoadingIndicatorSecondsParamName,
kDefaultTimeoutForLoadingIndicatorSeconds));
}
template <typename SuggestionPtrContainer>
std::unique_ptr<std::vector<std::string>> GetSuggestionIDVector(
const SuggestionPtrContainer& suggestions) {
auto result = std::make_unique<std::vector<std::string>>();
for (const auto& suggestion : suggestions) {
result->push_back(suggestion->id());
}
return result;
}
bool HasIntersection(const std::vector<std::string>& a,
const std::set<std::string>& b) {
for (const std::string& item : a) {
if (b.count(item)) {
return true;
}
}
return false;
}
void EraseByPrimaryID(RemoteSuggestion::PtrVector* suggestions,
const std::vector<std::string>& ids) {
std::set<std::string> ids_lookup(ids.begin(), ids.end());
base::EraseIf(
*suggestions,
[&ids_lookup](const std::unique_ptr<RemoteSuggestion>& suggestion) {
return ids_lookup.count(suggestion->id());
});
}
void EraseMatchingSuggestions(
RemoteSuggestion::PtrVector* suggestions,
const RemoteSuggestion::PtrVector& compare_against) {
std::set<std::string> compare_against_ids;
for (const std::unique_ptr<RemoteSuggestion>& suggestion : compare_against) {
const std::vector<std::string>& suggestion_ids = suggestion->GetAllIDs();
compare_against_ids.insert(suggestion_ids.begin(), suggestion_ids.end());
}
base::EraseIf(
*suggestions, [&compare_against_ids](
const std::unique_ptr<RemoteSuggestion>& suggestion) {
return HasIntersection(suggestion->GetAllIDs(), compare_against_ids);
});
}
void RemoveNullPointers(RemoteSuggestion::PtrVector* suggestions) {
base::EraseIf(*suggestions,
[](const std::unique_ptr<RemoteSuggestion>& suggestion) {
return !suggestion;
});
}
void RemoveIncompleteSuggestions(RemoteSuggestion::PtrVector* suggestions) {
if (base::CommandLine::ForCurrentProcess()->HasSwitch(
switches::kAddIncompleteSnippets)) {
return;
}
int num_suggestions = suggestions->size();
// Remove suggestions that do not have all the info we need to display it to
// the user.
base::EraseIf(*suggestions,
[](const std::unique_ptr<RemoteSuggestion>& suggestion) {
return !suggestion->is_complete();
});
int num_suggestions_removed = num_suggestions - suggestions->size();
UMA_HISTOGRAM_BOOLEAN("NewTabPage.Snippets.IncompleteSnippetsAfterFetch",
num_suggestions_removed > 0);
if (num_suggestions_removed > 0) {
base::UmaHistogramSparse("NewTabPage.Snippets.NumIncompleteSnippets",
num_suggestions_removed);
}
}
std::vector<ContentSuggestion> ConvertToContentSuggestions(
Category category,
const RemoteSuggestion::PtrVector& suggestions) {
std::vector<ContentSuggestion> result;
for (const std::unique_ptr<RemoteSuggestion>& suggestion : suggestions) {
// TODO(sfiera): if a suggestion is not going to be displayed, move it
// directly to content.dismissed on fetch. Otherwise, we might prune
// other suggestions to get down to kMaxSuggestionCount, only to hide one of
// the incomplete ones we kept.
if (!suggestion->is_complete()) {
continue;
}
result.emplace_back(suggestion->ToContentSuggestion(category));
}
return result;
}
void CallWithEmptyResults(FetchDoneCallback callback, const Status& status) {
if (callback.is_null()) {
return;
}
std::move(callback).Run(status, std::vector<ContentSuggestion>());
}
void AddDismissedIdsToRequest(const RemoteSuggestion::PtrVector& dismissed,
RequestParams* request_params) {
// The latest ids are added first, because they are more relevant.
for (auto it = dismissed.rbegin(); it != dismissed.rend(); ++it) {
if (request_params->excluded_ids.size() == kMaxExcludedDismissedIds) {
break;
}
request_params->excluded_ids.insert((*it)->id());
}
}
void AddDismissedArchivedIdsToRequest(
const base::circular_deque<std::unique_ptr<RemoteSuggestion>>& archived,
RequestParams* request_params) {
// We add all archived, dismissed IDs to the request (the archive is limited
// to kMaxArchivedSuggestionCount suggestions). They don't get persisted,
// which means that the user very recently dismissed them and that they are
// usually not many.
for (auto it = archived.begin(); it != archived.end(); ++it) {
const RemoteSuggestion& suggestion = **it;
if (suggestion.is_dismissed()) {
request_params->excluded_ids.insert(suggestion.id());
}
}
}
} // namespace
RemoteSuggestionsProviderImpl::RemoteSuggestionsProviderImpl(
Observer* observer,
PrefService* pref_service,
const std::string& application_language_code,
CategoryRanker* category_ranker,
RemoteSuggestionsScheduler* scheduler,
std::unique_ptr<RemoteSuggestionsFetcher> suggestions_fetcher,
std::unique_ptr<image_fetcher::ImageFetcher> image_fetcher,
std::unique_ptr<RemoteSuggestionsDatabase> database,
std::unique_ptr<RemoteSuggestionsStatusService> status_service,
std::unique_ptr<PrefetchedPagesTracker> prefetched_pages_tracker,
std::unique_ptr<base::OneShotTimer> fetch_timeout_timer)
: RemoteSuggestionsProvider(observer),
state_(State::NOT_INITED),
pref_service_(pref_service),
articles_category_(
Category::FromKnownCategory(KnownCategories::ARTICLES)),
application_language_code_(application_language_code),
category_ranker_(category_ranker),
remote_suggestions_scheduler_(scheduler),
suggestions_fetcher_(std::move(suggestions_fetcher)),
database_(std::move(database)),
image_fetcher_(std::move(image_fetcher), pref_service, database_.get()),
status_service_(std::move(status_service)),
clear_history_dependent_state_when_initialized_(false),
clear_cached_suggestions_when_initialized_(false),
clock_(base::DefaultClock::GetInstance()),
prefetched_pages_tracker_(std::move(prefetched_pages_tracker)),
fetch_timeout_timer_(std::move(fetch_timeout_timer)),
request_status_(FetchRequestStatus::NONE) {
DCHECK(fetch_timeout_timer_);
RestoreCategoriesFromPrefs();
// The articles category always exists. Add it if we didn't get it from prefs.
// TODO(treib): Rethink this.
category_contents_.insert(
std::make_pair(articles_category_,
CategoryContent(BuildArticleCategoryInfo(base::nullopt))));
// Tell the observer about all the categories.
for (const auto& entry : category_contents_) {
observer->OnCategoryStatusChanged(this, entry.first, entry.second.status);
}
if (database_->IsErrorState()) {
EnterState(State::ERROR_OCCURRED);
UpdateAllCategoryStatus(CategoryStatus::LOADING_ERROR);
return;
}
database_->SetErrorCallback(base::Bind(
&RemoteSuggestionsProviderImpl::OnDatabaseError, base::Unretained(this)));
// We transition to other states while finalizing the initialization, when the
// database is done loading.
database_load_start_ = base::TimeTicks::Now();
database_->LoadSnippets(
base::BindOnce(&RemoteSuggestionsProviderImpl::OnDatabaseLoaded,
base::Unretained(this)));
}
RemoteSuggestionsProviderImpl::~RemoteSuggestionsProviderImpl() {
}
// static
void RemoteSuggestionsProviderImpl::RegisterProfilePrefs(
PrefRegistrySimple* registry) {
registry->RegisterListPref(prefs::kRemoteSuggestionCategories);
registry->RegisterInt64Pref(prefs::kLastSuccessfulBackgroundFetchTime, 0);
RemoteSuggestionsStatusServiceImpl::RegisterProfilePrefs(registry);
}
void RemoteSuggestionsProviderImpl::ReloadSuggestions() {
if (!remote_suggestions_scheduler_->AcquireQuotaForInteractiveFetch()) {
return;
}
auto callback = base::BindOnce(
[](RemoteSuggestionsScheduler* scheduler, Status status_code) {
scheduler->OnInteractiveFetchFinished(status_code);
},
base::Unretained(remote_suggestions_scheduler_));
if (AreArticlesEmpty()) {
// No reason to use a timeout to hide the loading indicator before the fetch
// definitely fails as we have nothing else to display.
FetchSuggestionsWithLoadingIndicator(
/*interactive_request=*/true, std::move(callback),
/*enable_loading_indication_timeout=*/false);
return;
}
FetchSuggestions(
/*interactive_request=*/true, std::move(callback));
}
void RemoteSuggestionsProviderImpl::RefetchInTheBackground(
FetchStatusCallback callback) {
if (AreArticlesEmpty()) {
// We want to have a loading indicator even for a background fetch as the
// user might open an NTP before the fetch finishes.
// No reason to use a timeout to hide the loading indicator before the fetch
// definitely fails as we have nothing else to display.
FetchSuggestionsWithLoadingIndicator(
/*interactive_request=*/false, std::move(callback),
/*enable_loading_indication_timeout=*/false);
return;
}
FetchSuggestions(/*interactive_request=*/false, std::move(callback));
}
void RemoteSuggestionsProviderImpl::RefetchWhileDisplaying(
FetchStatusCallback callback) {
// We also have existing suggestions in place, so we want to use a timeout
// after which we fall back to the existing suggestions. This way, we do not
// annoy users by too much of waiting on poor connection.
FetchSuggestionsWithLoadingIndicator(
/*interactive_request=*/true, std::move(callback),
/*enable_loading_indication_timeout=*/true);
}
const RemoteSuggestionsFetcher*
RemoteSuggestionsProviderImpl::suggestions_fetcher_for_debugging() const {
return suggestions_fetcher_.get();
}
GURL RemoteSuggestionsProviderImpl::GetUrlWithFavicon(
const ContentSuggestion::ID& suggestion_id) const {
DCHECK(base::Contains(category_contents_, suggestion_id.category()));
const CategoryContent& content =
category_contents_.at(suggestion_id.category());
const RemoteSuggestion* suggestion =
content.FindSuggestion(suggestion_id.id_within_category());
if (!suggestion) {
return GURL();
}
return ContentSuggestion::GetFaviconDomain(suggestion->url());
}
bool RemoteSuggestionsProviderImpl::IsDisabled() const {
return state_ == State::DISABLED;
}
bool RemoteSuggestionsProviderImpl::ready() const {
return state_ == State::READY;
}
void RemoteSuggestionsProviderImpl::FetchSuggestionsWithLoadingIndicator(
bool interactive_request,
FetchStatusCallback callback,
bool enable_loading_indication_timeout) {
if (!AreArticlesAvailable()) {
// If the article section is not AVAILABLE, we cannot safely flip its status
// to AVAILABLE_LOADING and back. Instead, we fallback to the standard
// background refetch.
FetchSuggestions(interactive_request, std::move(callback));
return;
}
NotifyFetchWithLoadingIndicatorStarted();
if (enable_loading_indication_timeout) {
// |fetch_timeout_timer_| makes sure the UI stops waiting after a certain
// time period (the UI also falls back to previous suggestions, if there are
// any).
fetch_timeout_timer_->Start(
FROM_HERE, GetTimeoutForLoadingIndicator(),
base::BindOnce(&RemoteSuggestionsProviderImpl::
NotifyFetchWithLoadingIndicatorFailedOrTimeouted,
base::Unretained(this)));
}
FetchStatusCallback callback_wrapped =
base::BindOnce(&RemoteSuggestionsProviderImpl::
OnFetchSuggestionsWithLoadingIndicatorFinished,
base::Unretained(this), std::move(callback));
FetchSuggestions(interactive_request, std::move(callback_wrapped));
}
void RemoteSuggestionsProviderImpl::
OnFetchSuggestionsWithLoadingIndicatorFinished(FetchStatusCallback callback,
Status status) {
fetch_timeout_timer_->Stop();
// If the fetch succeeds, it already notified new results.
if (!status.IsSuccess()) {
NotifyFetchWithLoadingIndicatorFailedOrTimeouted();
}
if (callback) {
std::move(callback).Run(status);
}
}
void RemoteSuggestionsProviderImpl::FetchSuggestions(
bool interactive_request,
FetchStatusCallback callback) {
if (!ready()) {
if (callback) {
std::move(callback).Run(
Status(StatusCode::TEMPORARY_ERROR,
"RemoteSuggestionsProvider is not ready!"));
}
return;
}
if (request_status_ == FetchRequestStatus::NONE) {
// We cannot rule out concurrent requests although they are rare as the user
// has to trigger ReloadSuggestions() while the scheduler decides for a
// background fetch. Although preventing concurrent fetches would be
// desireable, it's not worth the effort (also see TODO() in
// OnFetchFinished()).
request_status_ = FetchRequestStatus::IN_PROGRESS;
}
// |count_to_fetch| is actually ignored, because the server does not support
// this functionality.
RequestParams params = BuildFetchParams(/*fetched_category=*/base::nullopt,
/*count_to_fetch=*/10);
params.interactive_request = interactive_request;
suggestions_fetcher_->FetchSnippets(
params, base::BindOnce(&RemoteSuggestionsProviderImpl::OnFetchFinished,
base::Unretained(this), std::move(callback),
interactive_request));
}
void RemoteSuggestionsProviderImpl::Fetch(
const Category& category,
const std::set<std::string>& known_suggestion_ids,
FetchDoneCallback callback) {
if (!ready()) {
CallWithEmptyResults(std::move(callback),
Status(StatusCode::TEMPORARY_ERROR,
"RemoteSuggestionsProvider is not ready!"));
return;
}
if (!remote_suggestions_scheduler_->AcquireQuotaForInteractiveFetch()) {
CallWithEmptyResults(
std::move(callback),
Status(StatusCode::TEMPORARY_ERROR, "Interactive quota exceeded!"));
return;
}
// Make sure after the fetch, the scheduler is informed about the status.
FetchDoneCallback callback_wrapper = base::BindOnce(
[](RemoteSuggestionsScheduler* scheduler, FetchDoneCallback callback,
Status status_code, std::vector<ContentSuggestion> suggestions) {
scheduler->OnInteractiveFetchFinished(status_code);
std::move(callback).Run(status_code, std::move(suggestions));
},
base::Unretained(remote_suggestions_scheduler_), std::move(callback));
RequestParams params = BuildFetchParams(
category, /*count_to_fetch=*/GetFetchMoreSuggestionsCount());
params.excluded_ids.insert(known_suggestion_ids.begin(),
known_suggestion_ids.end());
params.interactive_request = true;
params.exclusive_category = category;
suggestions_fetcher_->FetchSnippets(
params,
base::BindOnce(&RemoteSuggestionsProviderImpl::OnFetchMoreFinished,
base::Unretained(this), std::move(callback_wrapper)));
}
// Builds default fetcher params.
RequestParams RemoteSuggestionsProviderImpl::BuildFetchParams(
base::Optional<Category> fetched_category,
int count_to_fetch) const {
RequestParams result;
result.language_code = application_language_code_;
result.count_to_fetch = count_to_fetch;
// If this is a fetch for a specific category, its dismissed suggestions are
// added first to truncate them less.
if (fetched_category.has_value()) {
DCHECK(category_contents_.count(*fetched_category));
const CategoryContent& content =
category_contents_.find(*fetched_category)->second;
AddDismissedIdsToRequest(content.dismissed, &result);
AddDismissedArchivedIdsToRequest(content.archived, &result);
}
for (const auto& map_entry : category_contents_) {
if (fetched_category.has_value() && map_entry.first == *fetched_category) {
continue;
}
AddDismissedIdsToRequest(map_entry.second.dismissed, &result);
AddDismissedArchivedIdsToRequest(map_entry.second.archived, &result);
}
return result;
}
bool RemoteSuggestionsProviderImpl::AreArticlesEmpty() const {
if (!ready()) {
return false;
}
auto articles_it = category_contents_.find(articles_category_);
DCHECK(articles_it != category_contents_.end());
const CategoryContent& content = articles_it->second;
return content.suggestions.empty();
}
bool RemoteSuggestionsProviderImpl::AreArticlesAvailable() const {
if (!ready()) {
return false;
}
auto articles_it = category_contents_.find(articles_category_);
DCHECK(articles_it != category_contents_.end());
const CategoryContent& content = articles_it->second;
return content.status == CategoryStatus::AVAILABLE;
}
void RemoteSuggestionsProviderImpl::NotifyFetchWithLoadingIndicatorStarted() {
auto articles_it = category_contents_.find(articles_category_);
DCHECK(articles_it != category_contents_.end());
CategoryContent& content = articles_it->second;
DCHECK_EQ(content.status, CategoryStatus::AVAILABLE);
UpdateCategoryStatus(articles_it->first, CategoryStatus::AVAILABLE_LOADING);
}
void RemoteSuggestionsProviderImpl::
NotifyFetchWithLoadingIndicatorFailedOrTimeouted() {
auto articles_it = category_contents_.find(articles_category_);
DCHECK(articles_it != category_contents_.end());
CategoryContent& content = articles_it->second;
DCHECK(content.status == CategoryStatus::AVAILABLE_LOADING ||
content.status == CategoryStatus::CATEGORY_EXPLICITLY_DISABLED);
UpdateCategoryStatus(articles_it->first, CategoryStatus::AVAILABLE);
// TODO(jkrcal): Technically, we have no new suggestions; we should not
// notify. This is a work-around before crbug.com/768410 gets fixed.
NotifyNewSuggestions(articles_category_, content.suggestions);
}
CategoryStatus RemoteSuggestionsProviderImpl::GetCategoryStatus(
Category category) {
auto content_it = category_contents_.find(category);
DCHECK(content_it != category_contents_.end());
return content_it->second.status;
}
CategoryInfo RemoteSuggestionsProviderImpl::GetCategoryInfo(Category category) {
auto content_it = category_contents_.find(category);
DCHECK(content_it != category_contents_.end());
return content_it->second.info;
}
void RemoteSuggestionsProviderImpl::DismissSuggestion(
const ContentSuggestion::ID& suggestion_id) {
if (!ready()) {
return;
}
auto content_it = category_contents_.find(suggestion_id.category());
DCHECK(content_it != category_contents_.end());
CategoryContent* content = &content_it->second;
DismissSuggestionFromCategoryContent(content,
suggestion_id.id_within_category());
}
void RemoteSuggestionsProviderImpl::ClearHistory(
base::Time begin,
base::Time end,
const base::Callback<bool(const GURL& url)>& filter) {
// Both time range and the filter are ignored and all suggestions are removed,
// because it is not known which history entries were used for the suggestions
// personalization.
ClearHistoryDependentState();
if (request_status_ == FetchRequestStatus::IN_PROGRESS ||
request_status_ == FetchRequestStatus::IN_PROGRESS_NEEDS_REFETCH) {
request_status_ = FetchRequestStatus::IN_PROGRESS_CANCELED;
}
}
void RemoteSuggestionsProviderImpl::ClearCachedSuggestions() {
ClearCachedSuggestionsImpl();
if (request_status_ == FetchRequestStatus::IN_PROGRESS) {
// Called by external cache-cleared trigger. As this can be caused by
// language change, we need to refetch a potentiall ongoing fetch.
request_status_ = FetchRequestStatus::IN_PROGRESS_NEEDS_REFETCH;
}
}
void RemoteSuggestionsProviderImpl::OnSignInStateChanged(bool has_signed_in) {
// Make sure the status service is registered and we already initialised its
// start state.
if (!initialized()) {
return;
}
status_service_->OnSignInStateChanged(has_signed_in);
}
void RemoteSuggestionsProviderImpl::GetDismissedSuggestionsForDebugging(
Category category,
DismissedSuggestionsCallback callback) {
auto content_it = category_contents_.find(category);
DCHECK(content_it != category_contents_.end());
std::move(callback).Run(
ConvertToContentSuggestions(category, content_it->second.dismissed));
}
void RemoteSuggestionsProviderImpl::ClearDismissedSuggestionsForDebugging(
Category category) {
auto content_it = category_contents_.find(category);
DCHECK(content_it != category_contents_.end());
CategoryContent* content = &content_it->second;
if (!initialized()) {
return;
}
if (!content->dismissed.empty()) {
database_->DeleteSnippets(GetSuggestionIDVector(content->dismissed));
// The image got already deleted when the suggestion was dismissed.
content->dismissed.clear();
}
// Update the archive.
for (const auto& suggestion : content->archived) {
suggestion->set_dismissed(false);
}
}
// static
int RemoteSuggestionsProviderImpl::
GetMaxNormalFetchSuggestionCountForTesting() {
return kMaxNormalFetchSuggestionCount;
}
////////////////////////////////////////////////////////////////////////////////
// Private methods
GURL RemoteSuggestionsProviderImpl::FindSuggestionImageUrl(
const ContentSuggestion::ID& suggestion_id) const {
DCHECK(base::Contains(category_contents_, suggestion_id.category()));
const CategoryContent& content =
category_contents_.at(suggestion_id.category());
const RemoteSuggestion* suggestion =
content.FindSuggestion(suggestion_id.id_within_category());
if (!suggestion) {
return GURL();
}
return suggestion->salient_image_url();
}
void RemoteSuggestionsProviderImpl::OnDatabaseLoaded(
RemoteSuggestion::PtrVector suggestions) {
if (state_ == State::ERROR_OCCURRED) {
return;
}
DCHECK(state_ == State::NOT_INITED);
DCHECK(base::Contains(category_contents_, articles_category_));
base::TimeDelta database_load_time =
base::TimeTicks::Now() - database_load_start_;
UMA_HISTOGRAM_MEDIUM_TIMES("NewTabPage.Snippets.DatabaseLoadTime",
database_load_time);
RemoteSuggestion::PtrVector to_delete;
for (std::unique_ptr<RemoteSuggestion>& suggestion : suggestions) {
Category suggestion_category =
Category::FromRemoteCategory(suggestion->remote_category_id());
auto content_it = category_contents_.find(suggestion_category);
// We should already know about the category.
if (content_it == category_contents_.end()) {
DLOG(WARNING) << "Loaded a suggestion for unknown category "
<< suggestion_category << " from the DB; deleting";
to_delete.emplace_back(std::move(suggestion));
continue;
}
CategoryContent* content = &content_it->second;
if (suggestion->is_dismissed()) {
content->dismissed.emplace_back(std::move(suggestion));
} else {
content->suggestions.emplace_back(std::move(suggestion));
}
}
if (!to_delete.empty()) {
database_->DeleteSnippets(GetSuggestionIDVector(to_delete));
database_->DeleteImages(GetSuggestionIDVector(to_delete));
}
// Sort the suggestions in each category.
for (auto& entry : category_contents_) {
CategoryContent* content = &entry.second;
std::sort(content->suggestions.begin(), content->suggestions.end(),
[](const std::unique_ptr<RemoteSuggestion>& lhs,
const std::unique_ptr<RemoteSuggestion>& rhs) {
if (lhs->rank() != rhs->rank()) {
return lhs->rank() < rhs->rank();
}
// Suggestion created before the rank was introduced have rank
// equal to INT_MAX by default. Sort them by score.
// TODO(vitaliii): Remove this fallback (and its test) in M64.
return lhs->score() > rhs->score();
});
}
// TODO(tschumann): If I move ClearExpiredDismissedSuggestions() to the
// beginning of the function, it essentially does nothing but tests are still
// green. Fix this!
ClearExpiredDismissedSuggestions();
ClearOrphanedImages();
FinishInitialization();
}
void RemoteSuggestionsProviderImpl::OnDatabaseError() {
EnterState(State::ERROR_OCCURRED);
UpdateAllCategoryStatus(CategoryStatus::LOADING_ERROR);
}
void RemoteSuggestionsProviderImpl::OnFetchMoreFinished(
FetchDoneCallback fetching_callback,
Status status,
RemoteSuggestionsFetcher::OptionalFetchedCategories fetched_categories) {
if (!fetched_categories) {
DCHECK(!status.IsSuccess());
CallWithEmptyResults(std::move(fetching_callback), status);
return;
}
if (fetched_categories->size() != 1u) {
LOG(DFATAL) << "Requested one exclusive category but received "
<< fetched_categories->size() << " categories.";
CallWithEmptyResults(std::move(fetching_callback),
Status(StatusCode::PERMANENT_ERROR,
"RemoteSuggestionsProvider received more "
"categories than requested."));
return;
}
auto& fetched_category = (*fetched_categories)[0];
Category category = fetched_category.category;
CategoryContent* existing_content =
UpdateCategoryInfo(category, fetched_category.info);
SanitizeReceivedSuggestions(existing_content->dismissed,
&fetched_category.suggestions);
std::vector<ContentSuggestion> result =
ConvertToContentSuggestions(category, fetched_category.suggestions);
// Store the additional suggestions into the archive to be able to fetch
// images and favicons for them. Note that ArchiveSuggestions clears
// |fetched_category.suggestions|.
ArchiveSuggestions(existing_content, &fetched_category.suggestions);
std::move(fetching_callback).Run(Status::Success(), std::move(result));
}
void RemoteSuggestionsProviderImpl::OnFetchFinished(
FetchStatusCallback callback,
bool interactive_request,
Status status,
RemoteSuggestionsFetcher::OptionalFetchedCategories fetched_categories) {
FetchRequestStatus request_status = request_status_;
// TODO(jkrcal): This is potentially incorrect if there is another concurrent
// request in progress; when it finishes we will treat it as a standard
// request even though it may need to be refetched/disregarded. Even though
// the scheduler never triggers two concurrent requests, the user can trigger
// the second request via the UI. If cache/history gets cleared before neither
// of the two finishes, we can get outdated results afterwards. Low chance &
// low risk, feels safe to ignore.
request_status_ = FetchRequestStatus::NONE;
if (!ready()) {
// TODO(tschumann): What happens if this was a user-triggered, interactive
// request? Is the UI waiting indefinitely now?
return;
}
if (request_status == FetchRequestStatus::IN_PROGRESS_NEEDS_REFETCH) {
// Disregard the results and start a fetch again.
FetchSuggestions(interactive_request, std::move(callback));
return;
} else if (request_status == FetchRequestStatus::IN_PROGRESS_CANCELED) {
// Disregard the results.
return;
}
if (!status.IsSuccess()) {
if (callback) {
std::move(callback).Run(status);
}
return;
}
if (IsKeepingPrefetchedSuggestionsEnabled() && prefetched_pages_tracker_ &&
!prefetched_pages_tracker_->IsInitialized()) {
// Wait until the tracker is initialized.
prefetched_pages_tracker_->Initialize(base::BindOnce(
&RemoteSuggestionsProviderImpl::OnFetchFinished, base::Unretained(this),
std::move(callback), interactive_request, status,
std::move(fetched_categories)));
return;
}
if (fetched_categories) {
for (FetchedCategory& fetched_category : *fetched_categories) {
for (std::unique_ptr<RemoteSuggestion>& suggestion :
fetched_category.suggestions) {
if (ShouldForceFetchedSuggestionsNotifications() &&
IsFetchedSuggestionsNotificationsEnabled()) {
suggestion->set_should_notify(true);
suggestion->set_notification_deadline(clock_->Now() +
base::TimeDelta::FromDays(7));
}
if (!IsFetchedSuggestionsNotificationsEnabled()) {
suggestion->set_should_notify(false);
}
}
}
}
// Record the fetch time of a successfull background fetch.
if (!interactive_request) {
pref_service_->SetInt64(prefs::kLastSuccessfulBackgroundFetchTime,
SerializeTime(clock_->Now()));
}
// Mark all categories as not provided by the server in the latest fetch. The
// ones we got will be marked again below.
for (auto& item : category_contents_) {
CategoryContent* content = &item.second;
content->included_in_last_server_response = false;
}
// Clear up expired dismissed suggestions before we use them to filter new
// ones.
ClearExpiredDismissedSuggestions();
// If suggestions were fetched successfully, update our |category_contents_|
// from each category provided by the server.
if (fetched_categories) {
// TODO(treib): Reorder |category_contents_| to match the order we received
// from the server. crbug.com/653816
bool response_includes_article_category = false;
for (FetchedCategory& fetched_category : *fetched_categories) {
if (fetched_category.category == articles_category_) {
base::UmaHistogramSparse(
"NewTabPage.Snippets.NumArticlesFetched",
std::min(fetched_category.suggestions.size(),
static_cast<size_t>(kMaxNormalFetchSuggestionCount)));
response_includes_article_category = true;
}
CategoryContent* content =
UpdateCategoryInfo(fetched_category.category, fetched_category.info);
content->included_in_last_server_response = true;
SanitizeReceivedSuggestions(content->dismissed,
&fetched_category.suggestions);
IntegrateSuggestions(fetched_category.category, content,
std::move(fetched_category.suggestions));
}
// Add new remote categories to the ranker.
if (IsOrderingNewRemoteCategoriesBasedOnArticlesCategoryEnabled() &&
response_includes_article_category) {
AddFetchedCategoriesToRankerBasedOnArticlesCategory(
category_ranker_, *fetched_categories, articles_category_);
} else {
for (const FetchedCategory& fetched_category : *fetched_categories) {
category_ranker_->AppendCategoryIfNecessary(fetched_category.category);
}
}
// Delete categories not present in this fetch.
std::vector<Category> categories_to_delete;
for (auto& item : category_contents_) {
Category category = item.first;
CategoryContent* content = &item.second;
if (!content->included_in_last_server_response &&
category != articles_category_) {
categories_to_delete.push_back(category);
}
}
DeleteCategories(categories_to_delete);
}
// We might have gotten new categories (or updated the titles of existing
// ones), so update the pref.
StoreCategoriesToPrefs();
for (auto& item : category_contents_) {
Category category = item.first;
UpdateCategoryStatus(category, CategoryStatus::AVAILABLE);
// TODO(sfiera): notify only when a category changed above.
NotifyNewSuggestions(category, item.second.suggestions);
// The suggestions may be reused (e.g. when prepending an article), avoid
// trigering notifications for the second time.
for (std::unique_ptr<RemoteSuggestion>& suggestion :
item.second.suggestions) {
suggestion->set_should_notify(false);
}
}
// TODO(sfiera): equivalent metrics for non-articles.
auto content_it = category_contents_.find(articles_category_);
DCHECK(content_it != category_contents_.end());
const CategoryContent& content = content_it->second;
base::UmaHistogramSparse("NewTabPage.Snippets.NumArticles",
content.suggestions.size());
if (content.suggestions.empty() && !content.dismissed.empty()) {
UMA_HISTOGRAM_COUNTS_1M("NewTabPage.Snippets.NumArticlesZeroDueToDiscarded",
content.dismissed.size());
}
if (callback) {
std::move(callback).Run(status);
}
}
void RemoteSuggestionsProviderImpl::ArchiveSuggestions(
CategoryContent* content,
RemoteSuggestion::PtrVector* to_archive) {
// Archive previous suggestions - move them at the beginning of the list.
content->archived.insert(content->archived.begin(),
std::make_move_iterator(to_archive->begin()),
std::make_move_iterator(to_archive->end()));
to_archive->clear();
// If there are more archived suggestions than we want to keep, delete the
// oldest ones by their fetch time (which are always in the back).
if (content->archived.size() > kMaxArchivedSuggestionCount) {
RemoteSuggestion::PtrVector to_delete(
std::make_move_iterator(content->archived.begin() +
kMaxArchivedSuggestionCount),
std::make_move_iterator(content->archived.end()));
content->archived.resize(kMaxArchivedSuggestionCount);
database_->DeleteImages(GetSuggestionIDVector(to_delete));
}
}
void RemoteSuggestionsProviderImpl::SanitizeReceivedSuggestions(
const RemoteSuggestion::PtrVector& dismissed,
RemoteSuggestion::PtrVector* suggestions) {
DCHECK(ready());
EraseMatchingSuggestions(suggestions, dismissed);
RemoveIncompleteSuggestions(suggestions);
}
void RemoteSuggestionsProviderImpl::IntegrateSuggestions(
Category category,
CategoryContent* content,
RemoteSuggestion::PtrVector new_suggestions) {
DCHECK(ready());
// It's entirely possible that the newly fetched suggestions contain articles
// that have been present before.
// We need to make sure to only delete and archive suggestions that don't
// appear with the same ID in the new suggestions (it's fine for additional
// IDs though).
EraseByPrimaryID(&content->suggestions,
*GetSuggestionIDVector(new_suggestions));
// If enabled, keep some older prefetched article suggestions, otherwise the
// user has little time to see them.
if (IsKeepingPrefetchedSuggestionsEnabled() &&
category == articles_category_ && prefetched_pages_tracker_) {
DCHECK(prefetched_pages_tracker_->IsInitialized());
// Select suggestions to keep.
std::sort(content->suggestions.begin(), content->suggestions.end(),
[](const std::unique_ptr<RemoteSuggestion>& first,
const std::unique_ptr<RemoteSuggestion>& second) {
return first->fetch_date() > second->fetch_date();
});
std::vector<std::unique_ptr<RemoteSuggestion>>
additional_prefetched_suggestions, other_suggestions;
for (auto& remote_suggestion : content->suggestions) {
const GURL& url = remote_suggestion->amp_url().is_empty()
? remote_suggestion->url()
: remote_suggestion->amp_url();
if (prefetched_pages_tracker_->PrefetchedOfflinePageExists(url) &&
clock_->Now() - remote_suggestion->fetch_date() <
GetMaxAgeForAdditionalPrefetchedSuggestion() &&
static_cast<int>(additional_prefetched_suggestions.size()) <
GetMaxAdditionalPrefetchedSuggestions()) {
additional_prefetched_suggestions.push_back(
std::move(remote_suggestion));
} else {
other_suggestions.push_back(std::move(remote_suggestion));
}
}
// Mix them into the new set according to their score.
for (auto& remote_suggestion : additional_prefetched_suggestions) {
new_suggestions.push_back(std::move(remote_suggestion));
}
std::sort(new_suggestions.begin(), new_suggestions.end(),
[](const std::unique_ptr<RemoteSuggestion>& first,
const std::unique_ptr<RemoteSuggestion>& second) {
return first->score() > second->score();
});
// Treat remaining suggestions as usual.
content->suggestions = std::move(other_suggestions);
}
// Do not delete the thumbnail images as they are still handy on open NTPs.
database_->DeleteSnippets(GetSuggestionIDVector(content->suggestions));
// Note, that ArchiveSuggestions will clear |content->suggestions|.
ArchiveSuggestions(content, &content->suggestions);
// TODO(vitaliii): Move rank logic into the database. It will set ranks and
// return already sorted suggestions.
for (size_t i = 0; i < new_suggestions.size(); ++i) {
new_suggestions[i]->set_rank(i);
}
database_->SaveSnippets(new_suggestions);
content->suggestions = std::move(new_suggestions);
}
void RemoteSuggestionsProviderImpl::PrependArticleSuggestion(
std::unique_ptr<RemoteSuggestion> remote_suggestion) {
if (!ready()) {
return;
}
if (!IsPushedSuggestionsNotificationsEnabled()) {
remote_suggestion->set_should_notify(false);
}
ClearExpiredDismissedSuggestions();
DCHECK_EQ(articles_category_, Category::FromRemoteCategory(
remote_suggestion->remote_category_id()));
auto content_it = category_contents_.find(articles_category_);
if (content_it == category_contents_.end()) {
return;
}
CategoryContent* content = &content_it->second;
std::vector<std::unique_ptr<RemoteSuggestion>> suggestions;
suggestions.push_back(std::move(remote_suggestion));
// Ignore the pushed suggestion if:
//
// - Incomplete.
// - Has been dismissed.
// - Present in the current suggestions.
// - Possibly shown on older surfaces (i.e. archived).
//
// We do not check the database, because it just persists current suggestions.
RemoveIncompleteSuggestions(&suggestions);
EraseMatchingSuggestions(&suggestions, content->dismissed);
EraseMatchingSuggestions(&suggestions, content->suggestions);
// Check archived suggestions.
base::EraseIf(
suggestions,
[&content](const std::unique_ptr<RemoteSuggestion>& suggestion) {
const std::vector<std::string>& ids = suggestion->GetAllIDs();
for (const auto& archived_suggestion : content->archived) {
if (base::Contains(ids, archived_suggestion->id())) {
return true;
}
}
return false;
});
if (!suggestions.empty()) {
content->suggestions.insert(content->suggestions.begin(),
std::move(suggestions[0]));
for (size_t i = 0; i < content->suggestions.size(); ++i) {
content->suggestions[i]->set_rank(i);
}
if (IsCategoryStatusAvailable(content->status)) {
NotifyNewSuggestions(articles_category_, content->suggestions);
}
// Avoid triggering the pushed suggestion notification for the second time
// (e.g. when another suggestions is pushed).
content->suggestions[0]->set_should_notify(false);
database_->SaveSnippets(content->suggestions);
}
}
void RemoteSuggestionsProviderImpl::
RefreshSuggestionsUponPushToRefreshRequest() {
RefetchInTheBackground({});
}
void RemoteSuggestionsProviderImpl::DismissSuggestionFromCategoryContent(
CategoryContent* content,
const std::string& id_within_category) {
auto id_predicate = [&id_within_category](
const std::unique_ptr<RemoteSuggestion>& suggestion) {
return suggestion->id() == id_within_category;
};
auto it = std::find_if(content->suggestions.begin(),
content->suggestions.end(), id_predicate);
if (it != content->suggestions.end()) {
(*it)->set_dismissed(true);
database_->SaveSnippet(**it);
content->dismissed.push_back(std::move(*it));
content->suggestions.erase(it);
} else {
// Check the archive.
auto archive_it = std::find_if(content->archived.begin(),
content->archived.end(), id_predicate);
if (archive_it != content->archived.end()) {
(*archive_it)->set_dismissed(true);
}
}
}
void RemoteSuggestionsProviderImpl::DeleteCategories(
const std::vector<Category>& categories) {
for (Category category : categories) {
auto it = category_contents_.find(category);
if (it == category_contents_.end()) {
continue;
}
const CategoryContent& content = it->second;
UpdateCategoryStatus(category, CategoryStatus::NOT_PROVIDED);
if (!content.suggestions.empty()) {
database_->DeleteImages(GetSuggestionIDVector(content.suggestions));
database_->DeleteSnippets(GetSuggestionIDVector(content.suggestions));
}
if (!content.dismissed.empty()) {
database_->DeleteImages(GetSuggestionIDVector(content.dismissed));
database_->DeleteSnippets(GetSuggestionIDVector(content.dismissed));
}
category_contents_.erase(it);
}
}
void RemoteSuggestionsProviderImpl::ClearExpiredDismissedSuggestions() {
std::vector<Category> categories_to_delete;
const base::Time now = base::Time::Now();
for (auto& item : category_contents_) {
Category category = item.first;
CategoryContent* content = &item.second;
RemoteSuggestion::PtrVector to_delete;
// Move expired dismissed suggestions over into |to_delete|.
for (std::unique_ptr<RemoteSuggestion>& suggestion : content->dismissed) {
if (suggestion->expiry_date() <= now) {
to_delete.emplace_back(std::move(suggestion));
}
}
RemoveNullPointers(&content->dismissed);
if (!to_delete.empty()) {
// Delete the images.
database_->DeleteImages(GetSuggestionIDVector(to_delete));
// Delete the removed article suggestions from the DB.
database_->DeleteSnippets(GetSuggestionIDVector(to_delete));
}
if (content->suggestions.empty() && content->dismissed.empty() &&
category != articles_category_ &&
!content->included_in_last_server_response) {
categories_to_delete.push_back(category);
}
}
DeleteCategories(categories_to_delete);
StoreCategoriesToPrefs();
}
void RemoteSuggestionsProviderImpl::ClearOrphanedImages() {
auto alive_suggestions = std::make_unique<std::set<std::string>>();
for (const auto& entry : category_contents_) {
const CategoryContent& content = entry.second;
for (const auto& suggestion_ptr : content.suggestions) {
alive_suggestions->insert(suggestion_ptr->id());
}
for (const auto& suggestion_ptr : content.dismissed) {
alive_suggestions->insert(suggestion_ptr->id());
}
}
database_->GarbageCollectImages(std::move(alive_suggestions));
}
void RemoteSuggestionsProviderImpl::ClearHistoryDependentState() {
if (!initialized()) {
clear_history_dependent_state_when_initialized_ = true;
return;
}
NukeAllSuggestions();
remote_suggestions_scheduler_->OnHistoryCleared();
}
void RemoteSuggestionsProviderImpl::ClearCachedSuggestionsImpl() {
if (!initialized()) {
clear_cached_suggestions_when_initialized_ = true;
return;
}
NukeAllSuggestions();
remote_suggestions_scheduler_->OnSuggestionsCleared();
}
void RemoteSuggestionsProviderImpl::NukeAllSuggestions() {
DCHECK(initialized());
// TODO(tschumann): Should Nuke also cancel outstanding requests? Or should we
// only block the results of such outstanding requests?
for (auto& item : category_contents_) {
Category category = item.first;
CategoryContent* content = &item.second;
// TODO(tschumann): We do the unnecessary checks for .empty() in many places
// before calling database methods. Change the RemoteSuggestionsDatabase to
// return early for those and remove the many if statements in this file.
if (!content->suggestions.empty()) {
database_->DeleteSnippets(GetSuggestionIDVector(content->suggestions));
database_->DeleteImages(GetSuggestionIDVector(content->suggestions));
content->suggestions.clear();
}
if (!content->archived.empty()) {
database_->DeleteSnippets(GetSuggestionIDVector(content->archived));
database_->DeleteImages(GetSuggestionIDVector(content->archived));
content->archived.clear();
}
// Update listeners about the new (empty) state.
if (IsCategoryStatusAvailable(content->status)) {
NotifyNewSuggestions(category, content->suggestions);
}
// TODO(tschumann): We should not call debug code from production code.
ClearDismissedSuggestionsForDebugging(category);
}
StoreCategoriesToPrefs();
}
GURL RemoteSuggestionsProviderImpl::GetImageURLToFetch(
const ContentSuggestion::ID& suggestion_id) const {
if (!base::Contains(category_contents_, suggestion_id.category())) {
return GURL();
}
return FindSuggestionImageUrl(suggestion_id);
}
void RemoteSuggestionsProviderImpl::FetchSuggestionImage(
const ContentSuggestion::ID& suggestion_id,
ImageFetchedCallback callback) {
GURL image_url = GetImageURLToFetch(suggestion_id);
if (image_url.is_empty()) {
// As we don't know the corresponding suggestion anymore, we don't expect to
// find it in the database (and also can't fetch it remotely). Cut the
// lookup short and return directly.
base::ThreadTaskRunnerHandle::Get()->PostTask(
FROM_HERE, base::BindOnce(std::move(callback), gfx::Image()));
return;
}
image_fetcher_.FetchSuggestionImage(suggestion_id, image_url,
ImageDataFetchedCallback(),
std::move(callback));
}
void RemoteSuggestionsProviderImpl::FetchSuggestionImageData(
const ContentSuggestion::ID& suggestion_id,
ImageDataFetchedCallback callback) {
GURL image_url = GetImageURLToFetch(suggestion_id);
if (image_url.is_empty()) {
// As we don't know the corresponding suggestion anymore, we don't expect to
// find it in the database (and also can't fetch it remotely). Cut the
// lookup short and return directly.
base::ThreadTaskRunnerHandle::Get()->PostTask(
FROM_HERE, base::BindOnce(std::move(callback), std::string()));
return;
}
image_fetcher_.FetchSuggestionImage(suggestion_id, image_url,
std::move(callback),
ntp_snippets::ImageFetchedCallback());
}
void RemoteSuggestionsProviderImpl::FinishInitialization() {
// Note: Initializing the status service will run the callback right away with
// the current state.
status_service_->Init(base::Bind(
&RemoteSuggestionsProviderImpl::OnStatusChanged, base::Unretained(this)));
// Always notify here even if we got nothing from the database, because we
// don't know how long the fetch will take or if it will even complete.
for (const auto& item : category_contents_) {
Category category = item.first;
const CategoryContent& content = item.second;
// Note: We might be in a non-available status here, e.g. DISABLED due to
// enterprise policy.
if (IsCategoryStatusAvailable(content.status)) {
NotifyNewSuggestions(category, content.suggestions);
}
}
}
void RemoteSuggestionsProviderImpl::OnStatusChanged(
RemoteSuggestionsStatus old_status,
RemoteSuggestionsStatus new_status) {
switch (new_status) {
case RemoteSuggestionsStatus::ENABLED_AND_SIGNED_IN:
if (old_status == RemoteSuggestionsStatus::ENABLED_AND_SIGNED_OUT) {
DCHECK(state_ == State::READY);
// Clear nonpersonalized suggestions (and notify the scheduler there are
// no suggestions).
ClearCachedSuggestionsImpl();
if (request_status_ == FetchRequestStatus::IN_PROGRESS) {
request_status_ = FetchRequestStatus::IN_PROGRESS_NEEDS_REFETCH;
}
} else {
EnterState(State::READY);
}
break;
case RemoteSuggestionsStatus::ENABLED_AND_SIGNED_OUT:
if (old_status == RemoteSuggestionsStatus::ENABLED_AND_SIGNED_IN) {
DCHECK(state_ == State::READY);
// Clear personalized suggestions (and notify the scheduler there are
// no suggestions).
ClearCachedSuggestionsImpl();
if (request_status_ == FetchRequestStatus::IN_PROGRESS) {
request_status_ = FetchRequestStatus::IN_PROGRESS_NEEDS_REFETCH;
}
} else {
EnterState(State::READY);
}
break;
case RemoteSuggestionsStatus::EXPLICITLY_DISABLED:
EnterState(State::DISABLED);
break;
}
}
void RemoteSuggestionsProviderImpl::EnterState(State state) {
if (state == state_) {
return;
}
UMA_HISTOGRAM_ENUMERATION("NewTabPage.Snippets.EnteredState",
static_cast<int>(state),
static_cast<int>(State::COUNT));
switch (state) {
case State::NOT_INITED:
// Initial state, it should not be possible to get back there.
NOTREACHED();
break;
case State::READY:
DCHECK(state_ == State::NOT_INITED || state_ == State::DISABLED);
DVLOG(1) << "Entering state: READY";
state_ = State::READY;
DCHECK(category_contents_.find(articles_category_) !=
category_contents_.end());
UpdateAllCategoryStatus(CategoryStatus::AVAILABLE);
if (clear_cached_suggestions_when_initialized_) {
ClearCachedSuggestionsImpl();
clear_cached_suggestions_when_initialized_ = false;
}
if (clear_history_dependent_state_when_initialized_) {
clear_history_dependent_state_when_initialized_ = false;
ClearHistoryDependentState();
}
// This notification may cause the scheduler to ask the provider to do a
// refetch. We want to do it as the last step, when the state change here
// in the provider is completed.
NotifyStateChanged();
break;
case State::DISABLED:
DCHECK(state_ == State::NOT_INITED || state_ == State::READY);
DVLOG(1) << "Entering state: DISABLED";
state_ = State::DISABLED;
// Notify the state change to disable the scheduler. Clearing history /
// suggestions below tells the scheduler to fetch them again if the
// scheduler is not disabled. It is disabled; thus the calls are ignored.
NotifyStateChanged();
if (clear_history_dependent_state_when_initialized_) {
clear_history_dependent_state_when_initialized_ = false;
ClearHistoryDependentState();
}
ClearCachedSuggestionsImpl();
clear_cached_suggestions_when_initialized_ = false;
UpdateAllCategoryStatus(CategoryStatus::CATEGORY_EXPLICITLY_DISABLED);
break;
case State::ERROR_OCCURRED:
DVLOG(1) << "Entering state: ERROR_OCCURRED";
state_ = State::ERROR_OCCURRED;
NotifyStateChanged();
status_service_.reset();
break;
case State::COUNT:
NOTREACHED();
break;
}
}
void RemoteSuggestionsProviderImpl::NotifyStateChanged() {
switch (state_) {
case State::NOT_INITED:
// Initial state, not sure yet whether active or not.
break;
case State::READY:
remote_suggestions_scheduler_->OnProviderActivated();
break;
case State::DISABLED:
remote_suggestions_scheduler_->OnProviderDeactivated();
break;
case State::ERROR_OCCURRED:
remote_suggestions_scheduler_->OnProviderDeactivated();
break;
case State::COUNT:
NOTREACHED();
break;
}
}
void RemoteSuggestionsProviderImpl::NotifyNewSuggestions(
Category category,
const RemoteSuggestion::PtrVector& suggestions) {
DCHECK(category_contents_.find(category) != category_contents_.end());
DCHECK(IsCategoryStatusAvailable(
category_contents_.find(category)->second.status));
std::vector<ContentSuggestion> result =
ConvertToContentSuggestions(category, suggestions);
DVLOG(1) << "NotifyNewSuggestions(): " << result.size()
<< " items in category " << category;
observer()->OnNewSuggestions(this, category, std::move(result));
}
void RemoteSuggestionsProviderImpl::UpdateCategoryStatus(
Category category,
CategoryStatus status) {
auto content_it = category_contents_.find(category);
DCHECK(content_it != category_contents_.end());
CategoryContent& content = content_it->second;
if (status == content.status) {
return;
}
DVLOG(1) << "UpdateCategoryStatus(): " << category.id() << ": "
<< static_cast<int>(content.status) << " -> "
<< static_cast<int>(status);
content.status = status;
observer()->OnCategoryStatusChanged(this, category, content.status);
}
void RemoteSuggestionsProviderImpl::UpdateAllCategoryStatus(
CategoryStatus status) {
for (const auto& category : category_contents_) {
UpdateCategoryStatus(category.first, status);
}
}
namespace {
template <typename T>
typename T::const_iterator FindSuggestionInContainer(
const T& container,
const std::string& id_within_category) {
return std::find_if(container.begin(), container.end(),
[&id_within_category](
const std::unique_ptr<RemoteSuggestion>& suggestion) {
return suggestion->id() == id_within_category;
});
}
} // namespace
const RemoteSuggestion*
RemoteSuggestionsProviderImpl::CategoryContent::FindSuggestion(
const std::string& id_within_category) const {
// Search for the suggestion in current and archived suggestions.
auto it = FindSuggestionInContainer(suggestions, id_within_category);
if (it != suggestions.end()) {
return it->get();
}
auto archived_it = FindSuggestionInContainer(archived, id_within_category);
if (archived_it != archived.end()) {
return archived_it->get();
}
auto dismissed_it = FindSuggestionInContainer(dismissed, id_within_category);
if (dismissed_it != dismissed.end()) {
return dismissed_it->get();
}
return nullptr;
}
RemoteSuggestionsProviderImpl::CategoryContent*
RemoteSuggestionsProviderImpl::UpdateCategoryInfo(Category category,
const CategoryInfo& info) {
auto content_it = category_contents_.find(category);
if (content_it == category_contents_.end()) {
content_it = category_contents_
.insert(std::make_pair(category, CategoryContent(info)))
.first;
} else {
content_it->second.info = info;
}
return &content_it->second;
}
void RemoteSuggestionsProviderImpl::RestoreCategoriesFromPrefs() {
// This must only be called at startup, before there are any categories.
DCHECK(category_contents_.empty());
const base::ListValue* list =
pref_service_->GetList(prefs::kRemoteSuggestionCategories);
for (const base::Value& entry : *list) {
const base::DictionaryValue* dict = nullptr;
if (!entry.GetAsDictionary(&dict)) {
DLOG(WARNING) << "Invalid category pref value: " << entry;
continue;
}
int id = 0;
if (!dict->GetInteger(kCategoryContentId, &id)) {
DLOG(WARNING) << "Invalid category pref value, missing '"
<< kCategoryContentId << "': " << entry;
continue;
}
base::string16 title;
if (!dict->GetString(kCategoryContentTitle, &title)) {
DLOG(WARNING) << "Invalid category pref value, missing '"
<< kCategoryContentTitle << "': " << entry;
continue;
}
bool included_in_last_server_response = false;
if (!dict->GetBoolean(kCategoryContentProvidedByServer,
&included_in_last_server_response)) {
DLOG(WARNING) << "Invalid category pref value, missing '"
<< kCategoryContentProvidedByServer << "': " << entry;
continue;
}
bool allow_fetching_more_results = false;
// This wasn't always around, so it's okay if it's missing.
dict->GetBoolean(kCategoryContentAllowFetchingMore,
&allow_fetching_more_results);
Category category = Category::FromIDValue(id);
// The ranker may not persist the order of remote categories.
category_ranker_->AppendCategoryIfNecessary(category);
// TODO(tschumann): The following has a bad smell that category
// serialization / deserialization should not be done inside this
// class. We should move that into a central place that also knows how to
// parse data we received from remote backends.
// We don't want to use the restored title for BuildArticleCategoryInfo to
// avoid using a title that was calculated for a stale locale.
CategoryInfo info =
category == articles_category_
? BuildArticleCategoryInfo(base::nullopt)
: BuildRemoteCategoryInfo(title, allow_fetching_more_results);
CategoryContent* content = UpdateCategoryInfo(category, info);
content->included_in_last_server_response =
included_in_last_server_response;
}
}
void RemoteSuggestionsProviderImpl::StoreCategoriesToPrefs() {
// Collect all the CategoryContents.
std::vector<std::pair<Category, const CategoryContent*>> to_store;
for (const auto& entry : category_contents_) {
to_store.emplace_back(entry.first, &entry.second);
}
// The ranker may not persist the order, thus, it is stored by the provider.
std::sort(to_store.begin(), to_store.end(),
[this](const std::pair<Category, const CategoryContent*>& left,
const std::pair<Category, const CategoryContent*>& right) {
return category_ranker_->Compare(left.first, right.first);
});
// Convert the relevant info into a base::ListValue for storage.
base::ListValue list;
for (const auto& entry : to_store) {
const Category& category = entry.first;
const CategoryContent& content = *entry.second;
auto dict = std::make_unique<base::DictionaryValue>();
dict->SetInteger(kCategoryContentId, category.id());
// TODO(tschumann): Persist other properties of the CategoryInfo.
dict->SetString(kCategoryContentTitle, content.info.title());
dict->SetBoolean(kCategoryContentProvidedByServer,
content.included_in_last_server_response);
bool has_fetch_action = content.info.additional_action() ==
ContentSuggestionsAdditionalAction::FETCH;
dict->SetBoolean(kCategoryContentAllowFetchingMore, has_fetch_action);
list.Append(std::move(dict));
}
// Finally, store the result in the pref service.
pref_service_->Set(prefs::kRemoteSuggestionCategories, list);
}
RemoteSuggestionsProviderImpl::CategoryContent::CategoryContent(
const CategoryInfo& info)
: info(info) {}
RemoteSuggestionsProviderImpl::CategoryContent::CategoryContent(
CategoryContent&&) = default;
RemoteSuggestionsProviderImpl::CategoryContent::~CategoryContent() = default;
RemoteSuggestionsProviderImpl::CategoryContent&
RemoteSuggestionsProviderImpl::CategoryContent::operator=(CategoryContent&&) =
default;
} // namespace ntp_snippets