| // 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/ntp_snippets_service.h" |
| |
| #include <algorithm> |
| #include <iterator> |
| #include <utility> |
| |
| #include "base/command_line.h" |
| #include "base/files/file_path.h" |
| #include "base/files/file_util.h" |
| #include "base/location.h" |
| #include "base/metrics/histogram_macros.h" |
| #include "base/metrics/sparse_histogram.h" |
| #include "base/path_service.h" |
| #include "base/stl_util.h" |
| #include "base/strings/string_number_conversions.h" |
| #include "base/strings/utf_string_conversions.h" |
| #include "base/task_runner_util.h" |
| #include "base/time/time.h" |
| #include "base/values.h" |
| #include "components/data_use_measurement/core/data_use_user_data.h" |
| #include "components/history/core/browser/history_service.h" |
| #include "components/image_fetcher/image_decoder.h" |
| #include "components/image_fetcher/image_fetcher.h" |
| #include "components/ntp_snippets/ntp_snippets_constants.h" |
| #include "components/ntp_snippets/pref_names.h" |
| #include "components/ntp_snippets/remote/ntp_snippets_database.h" |
| #include "components/ntp_snippets/switches.h" |
| #include "components/prefs/pref_registry_simple.h" |
| #include "components/prefs/pref_service.h" |
| #include "components/suggestions/proto/suggestions.pb.h" |
| #include "components/variations/variations_associated_data.h" |
| #include "grit/components_strings.h" |
| #include "ui/base/l10n/l10n_util.h" |
| #include "ui/gfx/image/image.h" |
| |
| using image_fetcher::ImageDecoder; |
| using image_fetcher::ImageFetcher; |
| using suggestions::ChromeSuggestion; |
| using suggestions::SuggestionsProfile; |
| using suggestions::SuggestionsService; |
| |
| namespace ntp_snippets { |
| |
| namespace { |
| |
| // Number of snippets requested to the server. Consider replacing sparse UMA |
| // histograms with COUNTS() if this number increases beyond 50. |
| const int kMaxSnippetCount = 10; |
| |
| // Number of archived snippets we keep around in memory. |
| const int kMaxArchivedSnippetCount = 200; |
| |
| // Default values for snippets fetching intervals - once per day only. |
| const int kDefaultFetchingIntervalWifiSeconds = 0; |
| const int kDefaultFetchingIntervalFallbackSeconds = 24 * 60 * 60; |
| |
| // Variation parameters than can override the default fetching intervals. |
| const char kFetchingIntervalWifiParamName[] = |
| "fetching_interval_wifi_seconds"; |
| const char kFetchingIntervalFallbackParamName[] = |
| "fetching_interval_fallback_seconds"; |
| |
| const int kDefaultExpiryTimeMins = 3 * 24 * 60; |
| |
| base::TimeDelta GetFetchingInterval(const char* switch_name, |
| const char* param_name, |
| int default_value_seconds) { |
| int value_seconds = default_value_seconds; |
| |
| // The default value can be overridden by a variation parameter. |
| // TODO(treib,jkrcal): Use GetVariationParamValueByFeature and get rid of |
| // kStudyName, also in NTPSnippetsFetcher. |
| std::string param_value_str = variations::GetVariationParamValue( |
| ntp_snippets::kStudyName, param_name); |
| if (!param_value_str.empty()) { |
| int param_value_seconds = 0; |
| if (base::StringToInt(param_value_str, ¶m_value_seconds)) |
| value_seconds = param_value_seconds; |
| else |
| LOG(WARNING) << "Invalid value for variation parameter " << param_name; |
| } |
| |
| // A value from the command line parameter overrides anything else. |
| const base::CommandLine& cmdline = *base::CommandLine::ForCurrentProcess(); |
| if (cmdline.HasSwitch(switch_name)) { |
| std::string str = cmdline.GetSwitchValueASCII(switch_name); |
| int switch_value_seconds = 0; |
| if (base::StringToInt(str, &switch_value_seconds)) |
| value_seconds = switch_value_seconds; |
| else |
| LOG(WARNING) << "Invalid value for switch " << switch_name; |
| } |
| return base::TimeDelta::FromSeconds(value_seconds); |
| } |
| |
| base::TimeDelta GetFetchingIntervalWifi() { |
| return GetFetchingInterval(switches::kFetchingIntervalWifiSeconds, |
| kFetchingIntervalWifiParamName, |
| kDefaultFetchingIntervalWifiSeconds); |
| } |
| |
| base::TimeDelta GetFetchingIntervalFallback() { |
| return GetFetchingInterval(switches::kFetchingIntervalFallbackSeconds, |
| kFetchingIntervalFallbackParamName, |
| kDefaultFetchingIntervalFallbackSeconds); |
| } |
| |
| // Extracts the hosts from |suggestions| and returns them in a set. |
| std::set<std::string> GetSuggestionsHostsImpl( |
| const SuggestionsProfile& suggestions) { |
| std::set<std::string> hosts; |
| for (int i = 0; i < suggestions.suggestions_size(); ++i) { |
| const ChromeSuggestion& suggestion = suggestions.suggestions(i); |
| GURL url(suggestion.url()); |
| if (url.is_valid()) |
| hosts.insert(url.host()); |
| } |
| return hosts; |
| } |
| |
| std::set<std::string> GetAllIDs(const NTPSnippet::PtrVector& snippets) { |
| std::set<std::string> ids; |
| for (const std::unique_ptr<NTPSnippet>& snippet : snippets) { |
| ids.insert(snippet->id()); |
| for (const SnippetSource& source : snippet->sources()) |
| ids.insert(source.url.spec()); |
| } |
| return ids; |
| } |
| |
| std::set<std::string> GetMainIDs(const NTPSnippet::PtrVector& snippets) { |
| std::set<std::string> ids; |
| for (const std::unique_ptr<NTPSnippet>& snippet : snippets) |
| ids.insert(snippet->id()); |
| return ids; |
| } |
| |
| bool IsSnippetInSet(const std::unique_ptr<NTPSnippet>& snippet, |
| const std::set<std::string>& ids, |
| bool match_all_ids) { |
| if (ids.count(snippet->id())) |
| return true; |
| if (!match_all_ids) |
| return false; |
| for (const SnippetSource& source : snippet->sources()) { |
| if (ids.count(source.url.spec())) |
| return true; |
| } |
| return false; |
| } |
| |
| void EraseMatchingSnippets(NTPSnippet::PtrVector* snippets, |
| const std::set<std::string>& matching_ids, |
| bool match_all_ids) { |
| snippets->erase( |
| std::remove_if(snippets->begin(), snippets->end(), |
| [&matching_ids, match_all_ids]( |
| const std::unique_ptr<NTPSnippet>& snippet) { |
| return IsSnippetInSet(snippet, matching_ids, |
| match_all_ids); |
| }), |
| snippets->end()); |
| } |
| |
| void Compact(NTPSnippet::PtrVector* snippets) { |
| snippets->erase( |
| std::remove_if( |
| snippets->begin(), snippets->end(), |
| [](const std::unique_ptr<NTPSnippet>& snippet) { return !snippet; }), |
| snippets->end()); |
| } |
| |
| } // namespace |
| |
| NTPSnippetsService::NTPSnippetsService( |
| Observer* observer, |
| CategoryFactory* category_factory, |
| PrefService* pref_service, |
| SuggestionsService* suggestions_service, |
| const std::string& application_language_code, |
| NTPSnippetsScheduler* scheduler, |
| std::unique_ptr<NTPSnippetsFetcher> snippets_fetcher, |
| std::unique_ptr<ImageFetcher> image_fetcher, |
| std::unique_ptr<ImageDecoder> image_decoder, |
| std::unique_ptr<NTPSnippetsDatabase> database, |
| std::unique_ptr<NTPSnippetsStatusService> status_service) |
| : ContentSuggestionsProvider(observer, category_factory), |
| state_(State::NOT_INITED), |
| pref_service_(pref_service), |
| suggestions_service_(suggestions_service), |
| articles_category_( |
| category_factory->FromKnownCategory(KnownCategories::ARTICLES)), |
| application_language_code_(application_language_code), |
| scheduler_(scheduler), |
| snippets_fetcher_(std::move(snippets_fetcher)), |
| image_fetcher_(std::move(image_fetcher)), |
| image_decoder_(std::move(image_decoder)), |
| database_(std::move(database)), |
| snippets_status_service_(std::move(status_service)), |
| fetch_when_ready_(false), |
| nuke_when_initialized_(false), |
| thumbnail_requests_throttler_( |
| pref_service, |
| RequestThrottler::RequestType::CONTENT_SUGGESTION_THUMBNAIL) { |
| // Articles category always exists; others will be added as needed. |
| categories_[articles_category_] = CategoryContent(); |
| categories_[articles_category_].localized_title = |
| l10n_util::GetStringUTF16(IDS_NTP_ARTICLE_SUGGESTIONS_SECTION_HEADER); |
| observer->OnCategoryStatusChanged(this, articles_category_, |
| categories_[articles_category_].status); |
| if (database_->IsErrorState()) { |
| EnterState(State::ERROR_OCCURRED); |
| UpdateAllCategoryStatus(CategoryStatus::LOADING_ERROR); |
| return; |
| } |
| |
| database_->SetErrorCallback(base::Bind(&NTPSnippetsService::OnDatabaseError, |
| base::Unretained(this))); |
| |
| // We transition to other states while finalizing the initialization, when the |
| // database is done loading. |
| database_->LoadSnippets(base::Bind(&NTPSnippetsService::OnDatabaseLoaded, |
| base::Unretained(this))); |
| } |
| |
| NTPSnippetsService::~NTPSnippetsService() = default; |
| |
| // static |
| void NTPSnippetsService::RegisterProfilePrefs(PrefRegistrySimple* registry) { |
| registry->RegisterListPref(prefs::kSnippetHosts); |
| registry->RegisterInt64Pref(prefs::kSnippetBackgroundFetchingIntervalWifi, 0); |
| registry->RegisterInt64Pref(prefs::kSnippetBackgroundFetchingIntervalFallback, |
| 0); |
| |
| NTPSnippetsStatusService::RegisterProfilePrefs(registry); |
| } |
| |
| void NTPSnippetsService::FetchSnippets(bool interactive_request) { |
| if (ready()) |
| FetchSnippetsFromHosts(GetSuggestionsHosts(), interactive_request); |
| else |
| fetch_when_ready_ = true; |
| } |
| |
| void NTPSnippetsService::FetchSnippetsFromHosts( |
| const std::set<std::string>& hosts, |
| bool interactive_request) { |
| if (!ready()) |
| return; |
| |
| // Empty categories are marked as loading; others are unchanged. |
| for (const auto& item : categories_) { |
| Category category = item.first; |
| const CategoryContent& content = item.second; |
| if (content.snippets.empty()) |
| UpdateCategoryStatus(category, CategoryStatus::AVAILABLE_LOADING); |
| } |
| |
| std::set<std::string> excluded_ids; |
| for (const auto& item : categories_) { |
| const CategoryContent& content = item.second; |
| for (const auto& snippet : content.dismissed) |
| excluded_ids.insert(snippet->id()); |
| } |
| snippets_fetcher_->FetchSnippetsFromHosts(hosts, application_language_code_, |
| excluded_ids, kMaxSnippetCount, |
| interactive_request); |
| } |
| |
| void NTPSnippetsService::RescheduleFetching(bool force) { |
| // The scheduler only exists on Android so far, it's null on other platforms. |
| if (!scheduler_) |
| return; |
| |
| if (ready()) { |
| base::TimeDelta old_interval_wifi = |
| base::TimeDelta::FromInternalValue(pref_service_->GetInt64( |
| prefs::kSnippetBackgroundFetchingIntervalWifi)); |
| base::TimeDelta old_interval_fallback = |
| base::TimeDelta::FromInternalValue(pref_service_->GetInt64( |
| prefs::kSnippetBackgroundFetchingIntervalFallback)); |
| base::TimeDelta interval_wifi = GetFetchingIntervalWifi(); |
| base::TimeDelta interval_fallback = GetFetchingIntervalFallback(); |
| if (force || interval_wifi != old_interval_wifi || |
| interval_fallback != old_interval_fallback) { |
| scheduler_->Schedule(interval_wifi, interval_fallback); |
| pref_service_->SetInt64(prefs::kSnippetBackgroundFetchingIntervalWifi, |
| interval_wifi.ToInternalValue()); |
| pref_service_->SetInt64( |
| prefs::kSnippetBackgroundFetchingIntervalFallback, |
| interval_fallback.ToInternalValue()); |
| } |
| } else { |
| // If we're NOT_INITED, we don't know whether to schedule or un-schedule. |
| // If |force| is false, all is well: We'll reschedule on the next state |
| // change anyway. If it's true, then unschedule here, to make sure that the |
| // next reschedule actually happens. |
| if (state_ != State::NOT_INITED || force) { |
| scheduler_->Unschedule(); |
| pref_service_->ClearPref(prefs::kSnippetBackgroundFetchingIntervalWifi); |
| pref_service_->ClearPref( |
| prefs::kSnippetBackgroundFetchingIntervalFallback); |
| } |
| } |
| } |
| |
| CategoryStatus NTPSnippetsService::GetCategoryStatus(Category category) { |
| DCHECK(categories_.find(category) != categories_.end()); |
| return categories_[category].status; |
| } |
| |
| CategoryInfo NTPSnippetsService::GetCategoryInfo(Category category) { |
| DCHECK(categories_.find(category) != categories_.end()); |
| const CategoryContent& content = categories_[category]; |
| return CategoryInfo(content.localized_title, |
| ContentSuggestionsCardLayout::FULL_CARD, |
| /* has_more_button */ false, |
| /* show_if_empty */ true); |
| } |
| |
| void NTPSnippetsService::DismissSuggestion( |
| const ContentSuggestion::ID& suggestion_id) { |
| if (!ready()) |
| return; |
| |
| DCHECK(base::ContainsKey(categories_, suggestion_id.category())); |
| |
| CategoryContent* content = &categories_[suggestion_id.category()]; |
| auto it = std::find_if( |
| content->snippets.begin(), content->snippets.end(), |
| [&suggestion_id](const std::unique_ptr<NTPSnippet>& snippet) { |
| return snippet->id() == suggestion_id.id_within_category(); |
| }); |
| if (it == content->snippets.end()) |
| return; |
| |
| (*it)->set_dismissed(true); |
| |
| database_->SaveSnippet(**it); |
| database_->DeleteImage(suggestion_id.id_within_category()); |
| |
| content->dismissed.push_back(std::move(*it)); |
| content->snippets.erase(it); |
| } |
| |
| void NTPSnippetsService::FetchSuggestionImage( |
| const ContentSuggestion::ID& suggestion_id, |
| const ImageFetchedCallback& callback) { |
| database_->LoadImage( |
| suggestion_id.id_within_category(), |
| base::Bind(&NTPSnippetsService::OnSnippetImageFetchedFromDatabase, |
| base::Unretained(this), callback, suggestion_id)); |
| } |
| |
| void NTPSnippetsService::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. |
| if (!ready()) |
| nuke_when_initialized_ = true; |
| else |
| NukeAllSnippets(); |
| } |
| |
| void NTPSnippetsService::ClearCachedSuggestions(Category category) { |
| if (!initialized()) |
| return; |
| |
| if (categories_.find(category) == categories_.end()) |
| return; |
| CategoryContent* content = &categories_[category]; |
| if (content->snippets.empty()) |
| return; |
| |
| if (category == articles_category_) { |
| database_->DeleteSnippets(content->snippets); |
| database_->DeleteImages(content->snippets); |
| } |
| content->snippets.clear(); |
| |
| NotifyNewSuggestions(); |
| } |
| |
| void NTPSnippetsService::GetDismissedSuggestionsForDebugging( |
| Category category, |
| const DismissedSuggestionsCallback& callback) { |
| DCHECK(categories_.find(category) != categories_.end()); |
| |
| std::vector<ContentSuggestion> result; |
| const CategoryContent& content = categories_[category]; |
| for (const std::unique_ptr<NTPSnippet>& snippet : content.dismissed) { |
| if (!snippet->is_complete()) |
| continue; |
| ContentSuggestion suggestion(category, snippet->id(), |
| snippet->best_source().url); |
| suggestion.set_amp_url(snippet->best_source().amp_url); |
| suggestion.set_title(base::UTF8ToUTF16(snippet->title())); |
| suggestion.set_snippet_text(base::UTF8ToUTF16(snippet->snippet())); |
| suggestion.set_publish_date(snippet->publish_date()); |
| suggestion.set_publisher_name( |
| base::UTF8ToUTF16(snippet->best_source().publisher_name)); |
| suggestion.set_score(snippet->score()); |
| result.emplace_back(std::move(suggestion)); |
| } |
| callback.Run(std::move(result)); |
| } |
| |
| void NTPSnippetsService::ClearDismissedSuggestionsForDebugging( |
| Category category) { |
| DCHECK(categories_.find(category) != categories_.end()); |
| |
| if (!initialized()) |
| return; |
| |
| CategoryContent* content = &categories_[category]; |
| if (content->dismissed.empty()) |
| return; |
| |
| if (category == articles_category_) { |
| // The image got already deleted when the suggestion was dismissed. |
| database_->DeleteSnippets(content->dismissed); |
| } |
| content->dismissed.clear(); |
| } |
| |
| std::set<std::string> NTPSnippetsService::GetSuggestionsHosts() const { |
| // |suggestions_service_| can be null in tests. |
| if (!suggestions_service_) |
| return std::set<std::string>(); |
| |
| // TODO(treib): This should just call GetSnippetHostsFromPrefs. |
| return GetSuggestionsHostsImpl( |
| suggestions_service_->GetSuggestionsDataFromCache()); |
| } |
| |
| // static |
| int NTPSnippetsService::GetMaxSnippetCountForTesting() { |
| return kMaxSnippetCount; |
| } |
| |
| //////////////////////////////////////////////////////////////////////////////// |
| // Private methods |
| |
| GURL NTPSnippetsService::FindSnippetImageUrl( |
| const ContentSuggestion::ID& suggestion_id) const { |
| DCHECK(categories_.find(suggestion_id.category()) != categories_.end()); |
| |
| const CategoryContent& content = categories_.at(suggestion_id.category()); |
| const NTPSnippet* snippet = |
| content.FindSnippet(suggestion_id.id_within_category()); |
| if (!snippet) |
| return GURL(); |
| return snippet->salient_image_url(); |
| } |
| |
| // image_fetcher::ImageFetcherDelegate implementation. |
| void NTPSnippetsService::OnImageDataFetched( |
| const std::string& id_within_category, |
| const std::string& image_data) { |
| if (image_data.empty()) |
| return; |
| |
| // Only save the image if the corresponding snippet still exists. |
| bool found = false; |
| for (const std::pair<const Category, CategoryContent>& entry : categories_) { |
| if (entry.second.FindSnippet(id_within_category)) { |
| found = true; |
| break; |
| } |
| } |
| if (!found) |
| return; |
| |
| // Only cache the data in the DB, the actual serving is done in the callback |
| // provided to |image_fetcher_| (OnSnippetImageDecodedFromNetwork()). |
| database_->SaveImage(id_within_category, image_data); |
| } |
| |
| void NTPSnippetsService::OnDatabaseLoaded(NTPSnippet::PtrVector snippets) { |
| if (state_ == State::ERROR_OCCURRED) |
| return; |
| DCHECK(state_ == State::NOT_INITED); |
| DCHECK_EQ(1u, categories_.size()); // Only articles category, so far. |
| DCHECK(categories_.find(articles_category_) != categories_.end()); |
| |
| // TODO(sfiera): support non-article categories in database. |
| CategoryContent* content = &categories_[articles_category_]; |
| for (std::unique_ptr<NTPSnippet>& snippet : snippets) { |
| if (snippet->is_dismissed()) |
| content->dismissed.emplace_back(std::move(snippet)); |
| else |
| content->snippets.emplace_back(std::move(snippet)); |
| } |
| |
| std::sort(content->snippets.begin(), content->snippets.end(), |
| [](const std::unique_ptr<NTPSnippet>& lhs, |
| const std::unique_ptr<NTPSnippet>& rhs) { |
| return lhs->score() > rhs->score(); |
| }); |
| |
| ClearExpiredDismissedSnippets(); |
| ClearOrphanedImages(); |
| FinishInitialization(); |
| } |
| |
| void NTPSnippetsService::OnDatabaseError() { |
| EnterState(State::ERROR_OCCURRED); |
| UpdateAllCategoryStatus(CategoryStatus::LOADING_ERROR); |
| } |
| |
| // TODO(dgn): name clash between content suggestions and suggestions hosts. |
| // method name should be changed. |
| void NTPSnippetsService::OnSuggestionsChanged( |
| const SuggestionsProfile& suggestions) { |
| DCHECK(initialized()); |
| |
| std::set<std::string> hosts = GetSuggestionsHostsImpl(suggestions); |
| if (hosts == GetSnippetHostsFromPrefs()) |
| return; |
| |
| // Remove existing snippets that aren't in the suggestions anymore. |
| // |
| // TODO(treib,maybelle): If there is another source with an allowed host, |
| // then we should fall back to that. |
| // |
| // TODO(sfiera): determine when non-article categories should restrict hosts, |
| // and apply the same logic to them here. Maybe never? |
| // |
| // First, move them over into |to_delete|. |
| CategoryContent* content = &categories_[articles_category_]; |
| NTPSnippet::PtrVector to_delete; |
| for (std::unique_ptr<NTPSnippet>& snippet : content->snippets) { |
| if (!hosts.count(snippet->best_source().url.host())) |
| to_delete.emplace_back(std::move(snippet)); |
| } |
| Compact(&content->snippets); |
| ArchiveSnippets(articles_category_, &to_delete); |
| |
| StoreSnippetHostsToPrefs(hosts); |
| |
| // We removed some suggestions, so we want to let the client know about that. |
| // The fetch might take a long time or not complete so we don't want to wait |
| // for its callback. |
| NotifyNewSuggestions(); |
| |
| FetchSnippetsFromHosts(hosts, /*interactive_request=*/false); |
| } |
| |
| void NTPSnippetsService::OnFetchFinished( |
| NTPSnippetsFetcher::OptionalSnippets snippets) { |
| if (!ready()) |
| return; |
| |
| for (auto& item : categories_) { |
| CategoryContent* content = &item.second; |
| content->provided_by_server = false; |
| } |
| |
| // Clear up expired dismissed snippets before we use them to filter new ones. |
| ClearExpiredDismissedSnippets(); |
| |
| // If snippets were fetched successfully, update our |categories_| from each |
| // category provided by the server. |
| if (snippets) { |
| // TODO(jkrcal): A bit hard to understand with so many variables called |
| // "*categor*". Isn't here some room for simplification? |
| for (NTPSnippetsFetcher::FetchedCategory& fetched_category : *snippets) { |
| Category category = fetched_category.category; |
| |
| // TODO(sfiera): Avoid hard-coding articles category checks in so many |
| // places. |
| if (category != articles_category_) { |
| // Only update titles from server-side provided categories. |
| categories_[category].localized_title = |
| fetched_category.localized_title; |
| } |
| categories_[category].provided_by_server = true; |
| |
| DCHECK_LE(snippets->size(), static_cast<size_t>(kMaxSnippetCount)); |
| // TODO(sfiera): histograms for server categories. |
| // Sparse histogram used because the number of snippets is small (bound by |
| // kMaxSnippetCount). |
| if (category == articles_category_) { |
| UMA_HISTOGRAM_SPARSE_SLOWLY("NewTabPage.Snippets.NumArticlesFetched", |
| fetched_category.snippets.size()); |
| } |
| |
| ReplaceSnippets(category, std::move(fetched_category.snippets)); |
| } |
| } |
| |
| for (const auto& item : categories_) { |
| Category category = item.first; |
| UpdateCategoryStatus(category, CategoryStatus::AVAILABLE); |
| } |
| |
| // TODO(sfiera): equivalent metrics for non-articles. |
| const CategoryContent& content = categories_[articles_category_]; |
| UMA_HISTOGRAM_SPARSE_SLOWLY("NewTabPage.Snippets.NumArticles", |
| content.snippets.size()); |
| if (content.snippets.empty() && !content.dismissed.empty()) { |
| UMA_HISTOGRAM_COUNTS("NewTabPage.Snippets.NumArticlesZeroDueToDiscarded", |
| content.dismissed.size()); |
| } |
| |
| // TODO(sfiera): notify only when a category changed above. |
| NotifyNewSuggestions(); |
| |
| // Reschedule after a successful fetch. This resets all currently scheduled |
| // fetches, to make sure the fallback interval triggers only if no wifi fetch |
| // succeeded, and also that we don't do a background fetch immediately after |
| // a user-initiated one. |
| if (snippets) |
| RescheduleFetching(true); |
| } |
| |
| void NTPSnippetsService::ArchiveSnippets(Category category, |
| NTPSnippet::PtrVector* to_archive) { |
| CategoryContent* content = &categories_[category]; |
| |
| // TODO(sfiera): handle DB for non-articles too. |
| if (category == articles_category_) { |
| database_->DeleteSnippets(*to_archive); |
| // Do not delete the thumbnail images as they are still handy on open NTPs. |
| } |
| |
| // Archive previous snippets - 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())); |
| Compact(to_archive); |
| |
| // If there are more archived snippets than we want to keep, delete the |
| // oldest ones by their fetch time (which are always in the back). |
| if (content->archived.size() > kMaxArchivedSnippetCount) { |
| NTPSnippet::PtrVector to_delete( |
| std::make_move_iterator(content->archived.begin() + |
| kMaxArchivedSnippetCount), |
| std::make_move_iterator(content->archived.end())); |
| content->archived.resize(kMaxArchivedSnippetCount); |
| if (category == articles_category_) |
| database_->DeleteImages(to_delete); |
| } |
| } |
| |
| void NTPSnippetsService::ReplaceSnippets(Category category, |
| NTPSnippet::PtrVector new_snippets) { |
| DCHECK(ready()); |
| CategoryContent* content = &categories_[category]; |
| |
| // Remove new snippets that have been dismissed. |
| EraseMatchingSnippets(&new_snippets, GetAllIDs(content->dismissed), |
| /*match_all_ids=*/true); |
| |
| // Fill in default publish/expiry dates where required. |
| for (std::unique_ptr<NTPSnippet>& snippet : new_snippets) { |
| if (snippet->publish_date().is_null()) |
| snippet->set_publish_date(base::Time::Now()); |
| if (snippet->expiry_date().is_null()) { |
| snippet->set_expiry_date( |
| snippet->publish_date() + |
| base::TimeDelta::FromMinutes(kDefaultExpiryTimeMins)); |
| } |
| |
| // TODO(treib): Prefetch and cache the snippet image. crbug.com/605870 |
| } |
| |
| if (!base::CommandLine::ForCurrentProcess()->HasSwitch( |
| switches::kAddIncompleteSnippets)) { |
| int num_new_snippets = new_snippets.size(); |
| // Remove snippets that do not have all the info we need to display it to |
| // the user. |
| new_snippets.erase( |
| std::remove_if(new_snippets.begin(), new_snippets.end(), |
| [](const std::unique_ptr<NTPSnippet>& snippet) { |
| return !snippet->is_complete(); |
| }), |
| new_snippets.end()); |
| int num_snippets_dismissed = num_new_snippets - new_snippets.size(); |
| UMA_HISTOGRAM_BOOLEAN("NewTabPage.Snippets.IncompleteSnippetsAfterFetch", |
| num_snippets_dismissed > 0); |
| if (num_snippets_dismissed > 0) { |
| UMA_HISTOGRAM_SPARSE_SLOWLY("NewTabPage.Snippets.NumIncompleteSnippets", |
| num_snippets_dismissed); |
| } |
| } |
| |
| // Do not touch the current set of snippets if the newly fetched one is empty. |
| if (new_snippets.empty()) |
| return; |
| |
| // Remove current snippets that have been fetched again. We do not need to |
| // archive those as they will be in the new current set. |
| EraseMatchingSnippets(&content->snippets, GetMainIDs(new_snippets), |
| /*match_all_ids=*/false); |
| |
| ArchiveSnippets(category, &content->snippets); |
| |
| // TODO(sfiera): handle DB for non-articles too. |
| if (category == articles_category_) { |
| // Save new articles to the DB. |
| database_->SaveSnippets(new_snippets); |
| } |
| |
| content->snippets = std::move(new_snippets); |
| } |
| |
| std::set<std::string> NTPSnippetsService::GetSnippetHostsFromPrefs() const { |
| std::set<std::string> hosts; |
| const base::ListValue* list = pref_service_->GetList(prefs::kSnippetHosts); |
| for (const auto& value : *list) { |
| std::string str; |
| bool success = value->GetAsString(&str); |
| DCHECK(success) << "Failed to parse snippet host from prefs"; |
| hosts.insert(std::move(str)); |
| } |
| return hosts; |
| } |
| |
| void NTPSnippetsService::StoreSnippetHostsToPrefs( |
| const std::set<std::string>& hosts) { |
| base::ListValue list; |
| for (const std::string& host : hosts) |
| list.AppendString(host); |
| pref_service_->Set(prefs::kSnippetHosts, list); |
| } |
| |
| void NTPSnippetsService::ClearExpiredDismissedSnippets() { |
| std::vector<Category> categories_to_erase; |
| |
| const base::Time now = base::Time::Now(); |
| |
| for (auto& item : categories_) { |
| Category category = item.first; |
| CategoryContent* content = &item.second; |
| |
| NTPSnippet::PtrVector to_delete; |
| // Move expired dismissed snippets over into |to_delete|. |
| for (std::unique_ptr<NTPSnippet>& snippet : content->dismissed) { |
| if (snippet->expiry_date() <= now) |
| to_delete.emplace_back(std::move(snippet)); |
| } |
| Compact(&content->dismissed); |
| |
| // Delete the removed article suggestions from the DB. |
| if (category == articles_category_) { |
| // The image got already deleted when the suggestion was dismissed. |
| database_->DeleteSnippets(to_delete); |
| } |
| |
| if (content->snippets.empty() && content->dismissed.empty() && |
| category != articles_category_ && !content->provided_by_server) { |
| categories_to_erase.push_back(category); |
| } |
| } |
| |
| for (Category category : categories_to_erase) { |
| UpdateCategoryStatus(category, CategoryStatus::NOT_PROVIDED); |
| categories_.erase(category); |
| } |
| } |
| |
| void NTPSnippetsService::ClearOrphanedImages() { |
| // TODO(jkrcal): Implement. crbug.com/649009 |
| } |
| |
| void NTPSnippetsService::NukeAllSnippets() { |
| std::vector<Category> categories_to_erase; |
| |
| // Empty the ARTICLES category and remove all others, since they may or may |
| // not be personalized. |
| for (const auto& item : categories_) { |
| Category category = item.first; |
| |
| ClearCachedSuggestions(category); |
| ClearDismissedSuggestionsForDebugging(category); |
| |
| UpdateCategoryStatus(category, CategoryStatus::NOT_PROVIDED); |
| |
| // Remove the category entirely; it may or may not reappear. |
| if (category != articles_category_) |
| categories_to_erase.push_back(category); |
| } |
| |
| for (Category category : categories_to_erase) { |
| categories_.erase(category); |
| } |
| } |
| |
| void NTPSnippetsService::OnSnippetImageFetchedFromDatabase( |
| const ImageFetchedCallback& callback, |
| const ContentSuggestion::ID& suggestion_id, |
| std::string data) { |
| // |image_decoder_| is null in tests. |
| if (image_decoder_ && !data.empty()) { |
| image_decoder_->DecodeImage( |
| data, base::Bind(&NTPSnippetsService::OnSnippetImageDecodedFromDatabase, |
| base::Unretained(this), callback, suggestion_id)); |
| return; |
| } |
| |
| // Fetching from the DB failed; start a network fetch. |
| FetchSnippetImageFromNetwork(suggestion_id, callback); |
| } |
| |
| void NTPSnippetsService::OnSnippetImageDecodedFromDatabase( |
| const ImageFetchedCallback& callback, |
| const ContentSuggestion::ID& suggestion_id, |
| const gfx::Image& image) { |
| if (!image.IsEmpty()) { |
| callback.Run(image); |
| return; |
| } |
| |
| // If decoding the image failed, delete the DB entry. |
| database_->DeleteImage(suggestion_id.id_within_category()); |
| |
| FetchSnippetImageFromNetwork(suggestion_id, callback); |
| } |
| |
| void NTPSnippetsService::FetchSnippetImageFromNetwork( |
| const ContentSuggestion::ID& suggestion_id, |
| const ImageFetchedCallback& callback) { |
| if (categories_.find(suggestion_id.category()) == categories_.end()) { |
| OnSnippetImageDecodedFromNetwork( |
| callback, suggestion_id.id_within_category(), gfx::Image()); |
| return; |
| } |
| |
| GURL image_url = FindSnippetImageUrl(suggestion_id); |
| |
| if (image_url.is_empty() || |
| !thumbnail_requests_throttler_.DemandQuotaForRequest( |
| /*interactive_request=*/true)) { |
| // Return an empty image. Directly, this is never synchronous with the |
| // original FetchSuggestionImage() call - an asynchronous database query has |
| // happened in the meantime. |
| OnSnippetImageDecodedFromNetwork( |
| callback, suggestion_id.id_within_category(), gfx::Image()); |
| return; |
| } |
| |
| image_fetcher_->StartOrQueueNetworkRequest( |
| suggestion_id.id_within_category(), image_url, |
| base::Bind(&NTPSnippetsService::OnSnippetImageDecodedFromNetwork, |
| base::Unretained(this), callback)); |
| } |
| |
| void NTPSnippetsService::OnSnippetImageDecodedFromNetwork( |
| const ImageFetchedCallback& callback, |
| const std::string& id_within_category, |
| const gfx::Image& image) { |
| callback.Run(image); |
| } |
| |
| void NTPSnippetsService::EnterStateReady() { |
| if (nuke_when_initialized_) { |
| NukeAllSnippets(); |
| nuke_when_initialized_ = false; |
| } |
| |
| if (categories_[articles_category_].snippets.empty() || fetch_when_ready_) { |
| // TODO(jkrcal): Fetching snippets automatically upon creation of this |
| // lazily created service can cause troubles, e.g. in unit tests where |
| // network I/O is not allowed. |
| // Either add a DCHECK here that we actually are allowed to do network I/O |
| // or change the logic so that some explicit call is always needed for the |
| // network request. |
| FetchSnippets(/*interactive_request=*/false); |
| fetch_when_ready_ = false; |
| } |
| |
| // FetchSnippets should set the status to |AVAILABLE_LOADING| if relevant, |
| // otherwise we transition to |AVAILABLE| here. |
| if (categories_[articles_category_].status != |
| CategoryStatus::AVAILABLE_LOADING) { |
| UpdateCategoryStatus(articles_category_, CategoryStatus::AVAILABLE); |
| } |
| |
| // If host restrictions are enabled, register for host list updates. |
| // |suggestions_service_| can be null in tests. |
| if (snippets_fetcher_->UsesHostRestrictions() && suggestions_service_) { |
| suggestions_service_subscription_ = |
| suggestions_service_->AddCallback(base::Bind( |
| &NTPSnippetsService::OnSuggestionsChanged, base::Unretained(this))); |
| } |
| } |
| |
| void NTPSnippetsService::EnterStateDisabled() { |
| NukeAllSnippets(); |
| suggestions_service_subscription_.reset(); |
| } |
| |
| void NTPSnippetsService::EnterStateError() { |
| suggestions_service_subscription_.reset(); |
| snippets_status_service_.reset(); |
| } |
| |
| void NTPSnippetsService::FinishInitialization() { |
| if (nuke_when_initialized_) { |
| // We nuke here in addition to EnterStateReady, so that it happens even if |
| // we enter the DISABLED state below. |
| NukeAllSnippets(); |
| nuke_when_initialized_ = false; |
| } |
| |
| snippets_fetcher_->SetCallback( |
| base::Bind(&NTPSnippetsService::OnFetchFinished, base::Unretained(this))); |
| |
| // |image_fetcher_| can be null in tests. |
| if (image_fetcher_) { |
| image_fetcher_->SetImageFetcherDelegate(this); |
| image_fetcher_->SetDataUseServiceName( |
| data_use_measurement::DataUseUserData::NTP_SNIPPETS); |
| } |
| |
| // Note: Initializing the status service will run the callback right away with |
| // the current state. |
| snippets_status_service_->Init(base::Bind( |
| &NTPSnippetsService::OnDisabledReasonChanged, 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. |
| NotifyNewSuggestions(); |
| } |
| |
| void NTPSnippetsService::OnDisabledReasonChanged( |
| DisabledReason disabled_reason) { |
| switch (disabled_reason) { |
| case DisabledReason::NONE: |
| // Do not change the status. That will be done in EnterStateReady(). |
| EnterState(State::READY); |
| break; |
| |
| case DisabledReason::EXPLICITLY_DISABLED: |
| EnterState(State::DISABLED); |
| UpdateAllCategoryStatus(CategoryStatus::CATEGORY_EXPLICITLY_DISABLED); |
| break; |
| |
| case DisabledReason::SIGNED_OUT: |
| EnterState(State::DISABLED); |
| UpdateAllCategoryStatus(CategoryStatus::SIGNED_OUT); |
| break; |
| } |
| } |
| |
| void NTPSnippetsService::EnterState(State state) { |
| if (state == state_) |
| return; |
| |
| 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; |
| EnterStateReady(); |
| break; |
| |
| case State::DISABLED: |
| DCHECK(state_ == State::NOT_INITED || state_ == State::READY); |
| |
| DVLOG(1) << "Entering state: DISABLED"; |
| state_ = State::DISABLED; |
| EnterStateDisabled(); |
| break; |
| |
| case State::ERROR_OCCURRED: |
| DVLOG(1) << "Entering state: ERROR_OCCURRED"; |
| state_ = State::ERROR_OCCURRED; |
| EnterStateError(); |
| break; |
| } |
| |
| // Schedule or un-schedule background fetching after each state change. |
| RescheduleFetching(false); |
| } |
| |
| void NTPSnippetsService::NotifyNewSuggestions() { |
| for (const auto& item : categories_) { |
| Category category = item.first; |
| const CategoryContent& content = item.second; |
| |
| std::vector<ContentSuggestion> result; |
| for (const std::unique_ptr<NTPSnippet>& snippet : content.snippets) { |
| // TODO(sfiera): if a snippet is not going to be displayed, move it |
| // directly to content.dismissed on fetch. Otherwise, we might prune |
| // other snippets to get down to kMaxSnippetCount, only to hide one of the |
| // incomplete ones we kept. |
| if (!snippet->is_complete()) |
| continue; |
| ContentSuggestion suggestion(category, snippet->id(), |
| snippet->best_source().url); |
| suggestion.set_amp_url(snippet->best_source().amp_url); |
| suggestion.set_title(base::UTF8ToUTF16(snippet->title())); |
| suggestion.set_snippet_text(base::UTF8ToUTF16(snippet->snippet())); |
| suggestion.set_publish_date(snippet->publish_date()); |
| suggestion.set_publisher_name( |
| base::UTF8ToUTF16(snippet->best_source().publisher_name)); |
| suggestion.set_score(snippet->score()); |
| result.emplace_back(std::move(suggestion)); |
| } |
| |
| DVLOG(1) << "NotifyNewSuggestions(): " << result.size() |
| << " items in category " << category; |
| observer()->OnNewSuggestions(this, category, std::move(result)); |
| } |
| } |
| |
| void NTPSnippetsService::UpdateCategoryStatus(Category category, |
| CategoryStatus status) { |
| DCHECK(categories_.find(category) != categories_.end()); |
| CategoryContent& content = categories_[category]; |
| 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 NTPSnippetsService::UpdateAllCategoryStatus(CategoryStatus status) { |
| for (const auto& category : categories_) { |
| UpdateCategoryStatus(category.first, status); |
| } |
| } |
| |
| const NTPSnippet* NTPSnippetsService::CategoryContent::FindSnippet( |
| const std::string& id_within_category) const { |
| // Search for the snippet in current and archived snippets. |
| auto it = std::find_if( |
| snippets.begin(), snippets.end(), |
| [&id_within_category](const std::unique_ptr<NTPSnippet>& snippet) { |
| return snippet->id() == id_within_category; |
| }); |
| if (it != snippets.end()) |
| return it->get(); |
| |
| it = std::find_if( |
| archived.begin(), archived.end(), |
| [&id_within_category](const std::unique_ptr<NTPSnippet>& snippet) { |
| return snippet->id() == id_within_category; |
| }); |
| if (it != archived.end()) |
| return it->get(); |
| |
| return nullptr; |
| } |
| |
| NTPSnippetsService::CategoryContent::CategoryContent() = default; |
| NTPSnippetsService::CategoryContent::CategoryContent(CategoryContent&&) = |
| default; |
| NTPSnippetsService::CategoryContent::~CategoryContent() = default; |
| NTPSnippetsService::CategoryContent& NTPSnippetsService::CategoryContent:: |
| operator=(CategoryContent&&) = default; |
| |
| } // namespace ntp_snippets |