| // Copyright 2016 The Chromium Authors. All rights reserved. | 
 | // Use of this source code is governed by a BSD-style license that can be | 
 | // found in the LICENSE file. | 
 |  | 
 | #include "components/ntp_snippets/sessions/foreign_sessions_suggestions_provider.h" | 
 |  | 
 | #include <algorithm> | 
 | #include <map> | 
 | #include <tuple> | 
 | #include <utility> | 
 |  | 
 | #include "base/strings/string_piece.h" | 
 | #include "base/strings/utf_string_conversions.h" | 
 | #include "base/threading/thread_task_runner_handle.h" | 
 | #include "base/time/time.h" | 
 | #include "components/ntp_snippets/category.h" | 
 | #include "components/ntp_snippets/category_info.h" | 
 | #include "components/ntp_snippets/content_suggestion.h" | 
 | #include "components/ntp_snippets/features.h" | 
 | #include "components/ntp_snippets/pref_names.h" | 
 | #include "components/ntp_snippets/pref_util.h" | 
 | #include "components/prefs/pref_registry_simple.h" | 
 | #include "components/prefs/pref_service.h" | 
 | #include "components/sessions/core/session_types.h" | 
 | #include "components/sync_sessions/synced_session.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" | 
 | #include "url/gurl.h" | 
 |  | 
 | using base::Time; | 
 | using base::TimeDelta; | 
 | using sessions::SerializedNavigationEntry; | 
 | using sessions::SessionTab; | 
 | using sessions::SessionWindow; | 
 | using sync_sessions::SyncedSession; | 
 |  | 
 | using DismissedFilter = base::Callback<bool(const std::string& id)>; | 
 |  | 
 | namespace ntp_snippets { | 
 | namespace { | 
 |  | 
 | const int kMaxForeignTabsTotal = 10; | 
 | const int kMaxForeignTabsPerDevice = 3; | 
 | const int kMaxForeignTabAgeInMinutes = 180; | 
 |  | 
 | const char* kMaxForeignTabsTotalParamName = "max_foreign_tabs_total"; | 
 | const char* kMaxForeignTabsPerDeviceParamName = "max_foreign_tabs_per_device"; | 
 | const char* kMaxForeignTabAgeInMinutesParamName = | 
 |     "max_foreign_tabs_age_in_minutes"; | 
 |  | 
 | int GetMaxForeignTabsTotal() { | 
 |   return variations::GetVariationParamByFeatureAsInt( | 
 |       ntp_snippets::kForeignSessionsSuggestionsFeature, | 
 |       kMaxForeignTabsTotalParamName, kMaxForeignTabsTotal); | 
 | } | 
 |  | 
 | int GetMaxForeignTabsPerDevice() { | 
 |   return variations::GetVariationParamByFeatureAsInt( | 
 |       ntp_snippets::kForeignSessionsSuggestionsFeature, | 
 |       kMaxForeignTabsPerDeviceParamName, kMaxForeignTabsPerDevice); | 
 | } | 
 |  | 
 | TimeDelta GetMaxForeignTabAge() { | 
 |   return TimeDelta::FromMinutes(variations::GetVariationParamByFeatureAsInt( | 
 |       ntp_snippets::kForeignSessionsSuggestionsFeature, | 
 |       kMaxForeignTabAgeInMinutesParamName, kMaxForeignTabAgeInMinutes)); | 
 | } | 
 |  | 
 | // This filter does two things. Most importantly it lets through only ids that | 
 | // have not been dismissed. The other responsibility this class has is it tracks | 
 | // all of the ids that fly past it, and it will save the intersection of | 
 | // initially dismissed ids, and seen ids. This will aggressively prune any | 
 | // dismissal that is not currently blocking a recent tab. | 
 | class PrefsPruningDismissedItemFilter { | 
 |  public: | 
 |   explicit PrefsPruningDismissedItemFilter(PrefService* pref_service) | 
 |       : pref_service_(pref_service), | 
 |         initial_dismissed_ids_(prefs::ReadDismissedIDsFromPrefs( | 
 |             *pref_service_, | 
 |             prefs::kDismissedForeignSessionsSuggestions)) {} | 
 |  | 
 |   ~PrefsPruningDismissedItemFilter() { | 
 |     prefs::StoreDismissedIDsToPrefs(pref_service_, | 
 |                                     prefs::kDismissedForeignSessionsSuggestions, | 
 |                                     active_dismissed_ids_); | 
 |   } | 
 |  | 
 |   // Returns a Callback that can be easily used to filter out ids. Should not be | 
 |   // stored anywhere, the filter should always outlive the returned callback. | 
 |   DismissedFilter ToCallback() { | 
 |     return base::Bind(&PrefsPruningDismissedItemFilter::ShouldInclude, | 
 |                       base::Unretained(this)); | 
 |   } | 
 |  | 
 |  private: | 
 |   bool ShouldInclude(const std::string& id) { | 
 |     if (initial_dismissed_ids_.find(id) == initial_dismissed_ids_.end()) { | 
 |       return true; | 
 |     } | 
 |     active_dismissed_ids_.insert(id); | 
 |     return false; | 
 |   } | 
 |  | 
 |   PrefService* pref_service_; | 
 |  | 
 |   // Ids that we know should be filterd out. | 
 |   std::set<std::string> initial_dismissed_ids_; | 
 |  | 
 |   // Ids that we have seen and were filtered out. This will be what is saved to | 
 |   // preferences upon our destructor. | 
 |   std::set<std::string> active_dismissed_ids_; | 
 |  | 
 |   DISALLOW_COPY_AND_ASSIGN(PrefsPruningDismissedItemFilter); | 
 | }; | 
 |  | 
 | // This filter only lets through ids that should normally be filtered out. As | 
 | // such, this filter should only be used when purposely trying to view dismissed | 
 | // content. | 
 | class InverseDismissedItemFilter { | 
 |  public: | 
 |   explicit InverseDismissedItemFilter(PrefService* pref_service) | 
 |       : dismissed_ids_(prefs::ReadDismissedIDsFromPrefs( | 
 |             *pref_service, | 
 |             prefs::kDismissedForeignSessionsSuggestions)) {} | 
 |  | 
 |   // Returns a Callback that can be easily used to filter out ids. Should not be | 
 |   // stored anywhere, the filter should always outlive the returned callback. | 
 |   DismissedFilter ToCallback() { | 
 |     return base::Bind(&InverseDismissedItemFilter::ShouldInclude, | 
 |                       base::Unretained(this)); | 
 |   } | 
 |  | 
 |  private: | 
 |   bool ShouldInclude(const std::string& id) { | 
 |     return dismissed_ids_.find(id) != dismissed_ids_.end(); | 
 |   } | 
 |  | 
 |   std::set<std::string> dismissed_ids_; | 
 |  | 
 |   DISALLOW_COPY_AND_ASSIGN(InverseDismissedItemFilter); | 
 | }; | 
 |  | 
 | }  // namespace | 
 |  | 
 | // Collection of pointers to various sessions objects that contain a superset of | 
 | // the information needed to create a single suggestion. | 
 | struct ForeignSessionsSuggestionsProvider::SessionData { | 
 |   const sync_sessions::SyncedSession* session; | 
 |   const sessions::SessionTab* tab; | 
 |   const sessions::SerializedNavigationEntry* navigation; | 
 |   bool operator<(const SessionData& other) const { | 
 |     // Note that SerializedNavigationEntry::timestamp() is never set to a | 
 |     // value, so always use SessionTab::timestamp() instead. | 
 |     // TODO(skym): It might be better if we sorted by recency of session, and | 
 |     // only then by recency of the tab. Right now this causes a single | 
 |     // device's tabs to be interleaved with another devices' tabs. | 
 |     return tab->timestamp > other.tab->timestamp; | 
 |   } | 
 | }; | 
 |  | 
 | ForeignSessionsSuggestionsProvider::ForeignSessionsSuggestionsProvider( | 
 |     ContentSuggestionsProvider::Observer* observer, | 
 |     std::unique_ptr<ForeignSessionsProvider> foreign_sessions_provider, | 
 |     PrefService* pref_service) | 
 |     : ContentSuggestionsProvider(observer), | 
 |       category_status_(CategoryStatus::INITIALIZING), | 
 |       provided_category_( | 
 |           Category::FromKnownCategory(KnownCategories::FOREIGN_TABS)), | 
 |       foreign_sessions_provider_(std::move(foreign_sessions_provider)), | 
 |       pref_service_(pref_service) { | 
 |   foreign_sessions_provider_->SubscribeForForeignTabChange( | 
 |       base::Bind(&ForeignSessionsSuggestionsProvider::OnForeignTabChange, | 
 |                  base::Unretained(this))); | 
 |  | 
 |   // If sync is already initialzed, try suggesting now, though this is unlikely. | 
 |   OnForeignTabChange(); | 
 | } | 
 |  | 
 | ForeignSessionsSuggestionsProvider::~ForeignSessionsSuggestionsProvider() = | 
 |     default; | 
 |  | 
 | // static | 
 | void ForeignSessionsSuggestionsProvider::RegisterProfilePrefs( | 
 |     PrefRegistrySimple* registry) { | 
 |   registry->RegisterListPref(prefs::kDismissedForeignSessionsSuggestions); | 
 | } | 
 |  | 
 | CategoryStatus ForeignSessionsSuggestionsProvider::GetCategoryStatus( | 
 |     Category category) { | 
 |   DCHECK_EQ(category, provided_category_); | 
 |   return category_status_; | 
 | } | 
 |  | 
 | CategoryInfo ForeignSessionsSuggestionsProvider::GetCategoryInfo( | 
 |     Category category) { | 
 |   DCHECK_EQ(category, provided_category_); | 
 |   return CategoryInfo(l10n_util::GetStringUTF16( | 
 |                           IDS_NTP_FOREIGN_SESSIONS_SUGGESTIONS_SECTION_HEADER), | 
 |                       ContentSuggestionsCardLayout::MINIMAL_CARD, | 
 |                       /*has_fetch_action=*/false, | 
 |                       /*has_view_all_action=*/true, | 
 |                       /*show_if_empty=*/false, | 
 |                       l10n_util::GetStringUTF16( | 
 |                           IDS_NTP_FOREIGN_SESSIONS_SUGGESTIONS_SECTION_EMPTY)); | 
 | } | 
 |  | 
 | void ForeignSessionsSuggestionsProvider::DismissSuggestion( | 
 |     const ContentSuggestion::ID& suggestion_id) { | 
 |   // Assume this suggestion is still valid, and blindly add it to dismissals. | 
 |   // Pruning will happen the next time we are asked to suggest. | 
 |   std::set<std::string> dismissed_ids = prefs::ReadDismissedIDsFromPrefs( | 
 |       *pref_service_, prefs::kDismissedForeignSessionsSuggestions); | 
 |   dismissed_ids.insert(suggestion_id.id_within_category()); | 
 |   prefs::StoreDismissedIDsToPrefs(pref_service_, | 
 |                                   prefs::kDismissedForeignSessionsSuggestions, | 
 |                                   dismissed_ids); | 
 | } | 
 |  | 
 | void ForeignSessionsSuggestionsProvider::FetchSuggestionImage( | 
 |     const ContentSuggestion::ID& suggestion_id, | 
 |     const ImageFetchedCallback& callback) { | 
 |   base::ThreadTaskRunnerHandle::Get()->PostTask( | 
 |       FROM_HERE, base::Bind(callback, gfx::Image())); | 
 | } | 
 |  | 
 | void ForeignSessionsSuggestionsProvider::Fetch( | 
 |     const Category& category, | 
 |     const std::set<std::string>& known_suggestion_ids, | 
 |     const FetchDoneCallback& callback) { | 
 |   LOG(DFATAL) | 
 |       << "ForeignSessionsSuggestionsProvider has no |Fetch| functionality!"; | 
 |   base::ThreadTaskRunnerHandle::Get()->PostTask( | 
 |       FROM_HERE, | 
 |       base::Bind(callback, Status(StatusCode::PERMANENT_ERROR, | 
 |                                   "ForeignSessionsSuggestionsProvider " | 
 |                                   "has no |Fetch| functionality!"), | 
 |                  base::Passed(std::vector<ContentSuggestion>()))); | 
 | } | 
 |  | 
 | void ForeignSessionsSuggestionsProvider::ClearHistory( | 
 |     Time begin, | 
 |     Time end, | 
 |     const base::Callback<bool(const GURL& url)>& filter) { | 
 |   std::set<std::string> dismissed_ids = prefs::ReadDismissedIDsFromPrefs( | 
 |       *pref_service_, prefs::kDismissedForeignSessionsSuggestions); | 
 |   for (auto iter = dismissed_ids.begin(); iter != dismissed_ids.end();) { | 
 |     if (filter.Run(GURL(*iter))) { | 
 |       iter = dismissed_ids.erase(iter); | 
 |     } else { | 
 |       ++iter; | 
 |     } | 
 |   } | 
 |   prefs::StoreDismissedIDsToPrefs(pref_service_, | 
 |                                   prefs::kDismissedForeignSessionsSuggestions, | 
 |                                   dismissed_ids); | 
 | } | 
 |  | 
 | void ForeignSessionsSuggestionsProvider::ClearCachedSuggestions( | 
 |     Category category) { | 
 |   DCHECK_EQ(category, provided_category_); | 
 |   // Ignored. | 
 | } | 
 |  | 
 | void ForeignSessionsSuggestionsProvider::GetDismissedSuggestionsForDebugging( | 
 |     Category category, | 
 |     const DismissedSuggestionsCallback& callback) { | 
 |   DCHECK_EQ(category, provided_category_); | 
 |   InverseDismissedItemFilter filter(pref_service_); | 
 |   // Use GetSuggestionCandidates instead of BuildSuggestions(), to avoid the | 
 |   // size and duplicate filtering. We want to return a complete list of | 
 |   // everything that could potentially be blocked by the not dismissed filter. | 
 |   std::vector<ContentSuggestion> suggestions; | 
 |   for (auto data : GetSuggestionCandidates(filter.ToCallback())) { | 
 |     suggestions.push_back(BuildSuggestion(data)); | 
 |   } | 
 |   callback.Run(std::move(suggestions)); | 
 | } | 
 |  | 
 | void ForeignSessionsSuggestionsProvider::ClearDismissedSuggestionsForDebugging( | 
 |     Category category) { | 
 |   DCHECK_EQ(category, provided_category_); | 
 |   pref_service_->ClearPref(prefs::kDismissedForeignSessionsSuggestions); | 
 | } | 
 |  | 
 | void ForeignSessionsSuggestionsProvider::OnForeignTabChange() { | 
 |   if (!foreign_sessions_provider_->HasSessionsData()) { | 
 |     if (category_status_ == CategoryStatus::AVAILABLE) { | 
 |       // This is to handle the case where the user disabled sync [sessions] or | 
 |       // logs out after we've already provided actual suggestions. | 
 |       category_status_ = CategoryStatus::NOT_PROVIDED; | 
 |       observer()->OnCategoryStatusChanged(this, provided_category_, | 
 |                                           category_status_); | 
 |     } | 
 |     return; | 
 |   } | 
 |  | 
 |   if (category_status_ != CategoryStatus::AVAILABLE) { | 
 |     // The further below logic will overwrite any error state. This is | 
 |     // currently okay because no where in the current implementation does the | 
 |     // status get set to an error state. Should this change, reconsider the | 
 |     // overwriting logic. | 
 |     DCHECK(category_status_ == CategoryStatus::INITIALIZING || | 
 |            category_status_ == CategoryStatus::NOT_PROVIDED); | 
 |  | 
 |     // It is difficult to tell if sync simply has not initialized yet or there | 
 |     // will never be data because the user is signed out or has disabled the | 
 |     // sessions data type. Because this provider is hidden when there are no | 
 |     // results, always just update to AVAILABLE once we might have results. | 
 |     category_status_ = CategoryStatus::AVAILABLE; | 
 |     observer()->OnCategoryStatusChanged(this, provided_category_, | 
 |                                         category_status_); | 
 |   } | 
 |  | 
 |   // observer()->OnNewSuggestions must be called even when we have no | 
 |   // suggestions to remove previous suggestions that are now filtered out. | 
 |   observer()->OnNewSuggestions(this, provided_category_, BuildSuggestions()); | 
 | } | 
 |  | 
 | std::vector<ContentSuggestion> | 
 | ForeignSessionsSuggestionsProvider::BuildSuggestions() { | 
 |   const int max_foreign_tabs_total = GetMaxForeignTabsTotal(); | 
 |   const int max_foreign_tabs_per_device = GetMaxForeignTabsPerDevice(); | 
 |  | 
 |   PrefsPruningDismissedItemFilter filter(pref_service_); | 
 |   std::vector<SessionData> suggestion_candidates = | 
 |       GetSuggestionCandidates(filter.ToCallback()); | 
 |   // This sorts by recency so that we keep the most recent entries and they | 
 |   // appear as suggestions in reverse chronological order. | 
 |   std::sort(suggestion_candidates.begin(), suggestion_candidates.end()); | 
 |  | 
 |   std::vector<ContentSuggestion> suggestions; | 
 |   std::set<std::string> included_urls; | 
 |   std::map<std::string, int> suggestions_per_session; | 
 |   for (const SessionData& candidate : suggestion_candidates) { | 
 |     const std::string& session_tag = candidate.session->session_tag; | 
 |     auto duplicates_iter = | 
 |         included_urls.find(candidate.navigation->virtual_url().spec()); | 
 |     auto count_iter = suggestions_per_session.find(session_tag); | 
 |     int count = | 
 |         count_iter == suggestions_per_session.end() ? 0 : count_iter->second; | 
 |  | 
 |     // Pick up to max (total and per device) tabs, and ensure no duplicates | 
 |     // are selected. This filtering must be done in a second pass because | 
 |     // this can cause newer tabs occluding less recent tabs, requiring more | 
 |     // than |max_foreign_tabs_per_device| to be considered per device. | 
 |     if (static_cast<int>(suggestions.size()) >= max_foreign_tabs_total || | 
 |         duplicates_iter != included_urls.end() || | 
 |         count >= max_foreign_tabs_per_device) { | 
 |       continue; | 
 |     } | 
 |     included_urls.insert(candidate.navigation->virtual_url().spec()); | 
 |     suggestions_per_session[session_tag] = count + 1; | 
 |     suggestions.push_back(BuildSuggestion(candidate)); | 
 |   } | 
 |  | 
 |   return suggestions; | 
 | } | 
 |  | 
 | std::vector<ForeignSessionsSuggestionsProvider::SessionData> | 
 | ForeignSessionsSuggestionsProvider::GetSuggestionCandidates( | 
 |     const DismissedFilter& suggestions_filter) { | 
 |   const std::vector<const SyncedSession*>& foreign_sessions = | 
 |       foreign_sessions_provider_->GetAllForeignSessions(); | 
 |   const TimeDelta max_foreign_tab_age = GetMaxForeignTabAge(); | 
 |   std::vector<SessionData> suggestion_candidates; | 
 |   for (const SyncedSession* session : foreign_sessions) { | 
 |     for (const std::pair<const SessionID::id_type, | 
 |                          std::unique_ptr<sessions::SessionWindow>>& key_value : | 
 |          session->windows) { | 
 |       for (const std::unique_ptr<SessionTab>& tab : key_value.second->tabs) { | 
 |         if (tab->navigations.empty()) { | 
 |           continue; | 
 |         } | 
 |  | 
 |         const SerializedNavigationEntry& navigation = tab->navigations.back(); | 
 |         const std::string id = navigation.virtual_url().spec(); | 
 |         // TODO(skym): Filter out internal pages. Tabs that contain only | 
 |         // non-syncable content should never reach the local client. However, | 
 |         // sync will let tabs through whose current navigation entry is | 
 |         // internal, as long as a back or forward navigation entry is valid. We | 
 |         // however, are only currently exposing the current entry, and so we | 
 |         // should ideally exclude these. | 
 |         TimeDelta tab_age = Time::Now() - tab->timestamp; | 
 |         if (tab_age < max_foreign_tab_age && suggestions_filter.Run(id)) { | 
 |           suggestion_candidates.push_back( | 
 |               SessionData{session, tab.get(), &navigation}); | 
 |         } | 
 |       } | 
 |     } | 
 |   } | 
 |   return suggestion_candidates; | 
 | } | 
 |  | 
 | ContentSuggestion ForeignSessionsSuggestionsProvider::BuildSuggestion( | 
 |     const SessionData& data) { | 
 |   ContentSuggestion suggestion(provided_category_, | 
 |                                data.navigation->virtual_url().spec(), | 
 |                                data.navigation->virtual_url()); | 
 |   suggestion.set_title(data.navigation->title()); | 
 |   suggestion.set_publish_date(data.tab->timestamp); | 
 |   suggestion.set_publisher_name( | 
 |       base::UTF8ToUTF16(data.navigation->virtual_url().host())); | 
 |   return suggestion; | 
 | } | 
 |  | 
 | }  // namespace ntp_snippets |