|  | // 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/strings/grit/components_strings.h" | 
|  | #include "components/sync_sessions/synced_session.h" | 
|  | #include "components/variations/variations_associated_data.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::SyncedSessionWindow; | 
|  | 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, | 
|  | ContentSuggestionsAdditionalAction::VIEW_ALL, | 
|  | /*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, | 
|  | ImageFetchedCallback callback) { | 
|  | base::ThreadTaskRunnerHandle::Get()->PostTask( | 
|  | FROM_HERE, base::BindOnce(std::move(callback), gfx::Image())); | 
|  | } | 
|  |  | 
|  | void ForeignSessionsSuggestionsProvider::Fetch( | 
|  | const Category& category, | 
|  | const std::set<std::string>& known_suggestion_ids, | 
|  | FetchDoneCallback callback) { | 
|  | LOG(DFATAL) | 
|  | << "ForeignSessionsSuggestionsProvider has no |Fetch| functionality!"; | 
|  | base::ThreadTaskRunnerHandle::Get()->PostTask( | 
|  | FROM_HERE, base::BindOnce(std::move(callback), | 
|  | Status(StatusCode::PERMANENT_ERROR, | 
|  | "ForeignSessionsSuggestionsProvider " | 
|  | "has no |Fetch| functionality!"), | 
|  | 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() { | 
|  | // Ignored. | 
|  | } | 
|  |  | 
|  | void ForeignSessionsSuggestionsProvider::GetDismissedSuggestionsForDebugging( | 
|  | Category category, | 
|  | 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)); | 
|  | } | 
|  | std::move(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 auto& key_value : session->windows) { | 
|  | for (const std::unique_ptr<SessionTab>& tab : | 
|  | key_value.second->wrapped_window.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 |