blob: cea8c0877857303b0d2b54a1d33f97231c08a0d7 [file] [log] [blame]
// Copyright 2020 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
#include "components/omnibox/browser/most_visited_sites_provider.h"
#include <string>
#include <vector>
#include "base/feature_list.h"
#include "base/functional/bind.h"
#include "base/metrics/histogram_functions.h"
#include "base/strings/escape.h"
#include "components/history/core/browser/top_sites.h"
#include "components/omnibox/browser/autocomplete_input.h"
#include "components/omnibox/browser/autocomplete_match.h"
#include "components/omnibox/browser/autocomplete_match_classification.h"
#include "components/omnibox/common/omnibox_features.h"
#include "components/search_engines/template_url_service.h"
#include "components/url_formatter/url_formatter.h"
#include "third_party/metrics_proto/omnibox_event.pb.h"
#include "third_party/omnibox_proto/types.pb.h"
#include "ui/base/device_form_factor.h"
#include "url/gurl.h"
namespace {
// The relevance score for suggest tiles represented as a single tiling match.
// Suggest tiles are placed in a dedicated SECTION_MOBILE_MOST_VISITED
// making its relative relevance score not important.
constexpr const int kMostVisitedTilesAggregateRelevance = 1;
// The relevance score for suggest tiles represented as individual matches.
// Repeatable Queries are recognized as searches, and may get merged to higher
// ranking search suggestions listed below the carousel.
constexpr const int kMostVisitedTilesIndividualHighRelevance = 1600;
// Matches known to be off-screen by default are listed as low-relevance.
// If we have additional AutocompleteMatches listed below the MV carousel
// pointing to the same destination, we want the tiles to be deduplicated to
// these matches.
constexpr const int kMostVisitedTilesIndividualLowRelevance = 100;
// Index of the last high-relevance tile.
constexpr const int kLastHighRelevanceIndividualTile = 4;
constexpr const int kMaxRecordedTileIndex = 15;
constexpr char kHistogramTileTypeCountSearch[] =
"Omnibox.SuggestTiles.TileTypeCount.Search";
constexpr char kHistogramTileTypeCountURL[] =
"Omnibox.SuggestTiles.TileTypeCount.URL";
constexpr char kHistogramDeletedTileType[] =
"Omnibox.SuggestTiles.DeletedTileType";
constexpr char kHistogramDeletedTileIndex[] =
"Omnibox.SuggestTiles.DeletedTileIndex";
// GENERATED_JAVA_ENUM_PACKAGE: (
// org.chromium.chrome.browser.omnibox.suggestions.mostvisited)
// GENERATED_JAVA_CLASS_NAME_OVERRIDE: SuggestTileType
enum SuggestTileType { kOther = 0, kURL = 1, kSearch = 2, kCount = 3 };
// Constructs an AutocompleteMatch from supplied details.
AutocompleteMatch BuildMatch(AutocompleteProvider* provider,
AutocompleteProviderClient* client,
const std::u16string& description,
const GURL& url,
int relevance,
AutocompleteMatchType::Type type) {
AutocompleteMatch match(provider, relevance, true, type);
match.suggest_type = omnibox::TYPE_NAVIGATION;
match.destination_url = url;
match.fill_into_edit +=
AutocompleteInput::FormattedStringWithEquivalentMeaning(
url, url_formatter::FormatUrl(url), client->GetSchemeClassifier(),
nullptr);
// Zero suggest results should always omit protocols and never appear bold.
auto format_types = AutocompleteMatch::GetFormatTypes(false, false);
match.contents = url_formatter::FormatUrl(
url, format_types, base::UnescapeRule::SPACES, nullptr, nullptr, nullptr);
match.contents_class = ClassifyTermMatches({}, match.contents.length(), 0,
ACMatchClassification::URL);
match.description = AutocompleteMatch::SanitizeString(description);
match.description_class = ClassifyTermMatches({}, match.description.length(),
0, ACMatchClassification::NONE);
match.suggestion_group_id = omnibox::GROUP_MOBILE_MOST_VISITED;
return match;
}
template <typename TileContainer>
bool BuildTileSuggest(AutocompleteProvider* provider,
AutocompleteProviderClient* const client,
ui::DeviceFormFactor device_form_factor,
const TileContainer& container,
ACMatches& matches) {
if (container.empty()) {
base::UmaHistogramExactLinear(kHistogramTileTypeCountSearch, 0,
kMaxRecordedTileIndex);
base::UmaHistogramExactLinear(kHistogramTileTypeCountURL, 0,
kMaxRecordedTileIndex);
return false;
}
size_t num_search_tiles = 0;
size_t num_url_tiles = 0;
if (base::FeatureList::IsEnabled(
omnibox::kMostVisitedTilesHorizontalRenderGroup)) {
auto* const url_service = client->GetTemplateURLService();
auto* const dse = url_service->GetDefaultSearchProvider();
int relevance = kMostVisitedTilesIndividualHighRelevance;
for (const auto& tile : container) {
// TODO(crbug.com/40279214): pass this information from History layer via
// history::MostVisitedURL.
bool is_search =
url_service->IsSearchResultsPageFromDefaultSearchProvider(tile.url);
auto match =
BuildMatch(provider, client, tile.title, tile.url, relevance,
is_search ? AutocompleteMatchType::TILE_REPEATABLE_QUERY
: AutocompleteMatchType::TILE_MOST_VISITED_SITE);
if (is_search) {
match.subtypes.emplace(
omnibox::SUBTYPE_ZERO_PREFIX_LOCAL_FREQUENT_QUERIES);
match.keyword = dse->keyword();
std::u16string query = tile.title;
if (dse->url_ref().SupportsReplacement(
url_service->search_terms_data())) {
dse->ExtractSearchTermsFromURL(
tile.url, url_service->search_terms_data(), &query);
}
match.fill_into_edit = query;
match.contents = query;
match.suggest_type = omnibox::TYPE_QUERY;
// Supply blanket SearchTermsArgs so we can also report SearchBoxStats.
match.search_terms_args =
std::make_unique<TemplateURLRef::SearchTermsArgs>(query);
num_search_tiles++;
} else {
match.subtypes.emplace(
omnibox::SUBTYPE_ZERO_PREFIX_LOCAL_FREQUENT_URLS);
match.subtypes.emplace(omnibox::SUBTYPE_URL_BASED);
num_url_tiles++;
}
matches.emplace_back(std::move(match));
// On phones, we fully expose a fixed number of matches. Matches beyond
// that number are partially or fully concealed by design. Drop relevance
// for these matches.
if (matches.size() == kLastHighRelevanceIndividualTile &&
device_form_factor == ui::DEVICE_FORM_FACTOR_PHONE) {
relevance = kMostVisitedTilesIndividualLowRelevance;
}
--relevance;
}
} else {
AutocompleteMatch match =
BuildMatch(provider, client, std::u16string(), GURL(),
kMostVisitedTilesAggregateRelevance,
AutocompleteMatchType::TILE_NAVSUGGEST);
match.suggest_tiles.reserve(container.size());
auto* const url_service = client->GetTemplateURLService();
for (const auto& tile : container) {
bool is_search =
url_service->IsSearchResultsPageFromDefaultSearchProvider(tile.url);
match.suggest_tiles.push_back({
.url = tile.url,
.title = tile.title,
.is_search = is_search,
});
if (is_search) {
num_search_tiles++;
} else {
num_url_tiles++;
}
}
match.subtypes.emplace(omnibox::SUBTYPE_ZERO_PREFIX_LOCAL_FREQUENT_URLS);
match.subtypes.emplace(omnibox::SUBTYPE_URL_BASED);
matches.push_back(std::move(match));
}
base::UmaHistogramExactLinear(kHistogramTileTypeCountSearch, num_search_tiles,
kMaxRecordedTileIndex);
base::UmaHistogramExactLinear(kHistogramTileTypeCountURL, num_url_tiles,
kMaxRecordedTileIndex);
return true;
}
} // namespace
void MostVisitedSitesProvider::Start(const AutocompleteInput& input,
bool minimal_changes) {
Stop(true, false);
if (!AllowMostVisitedSitesSuggestions(input))
return;
scoped_refptr<history::TopSites> top_sites = client_->GetTopSites();
if (!top_sites)
return;
// If TopSites has not yet been loaded, then `OnMostVisitedUrlsAvailable` will
// be called asynchronously, so we need to first check that async calls are
// allowed for the given input.
if (!top_sites->loaded() && input.omit_asynchronous_matches()) {
return;
}
done_ = false;
// TODO(ender): Relocate this to StartPrefetch() when additional prefetch
// contexts are available.
// TopSites updates itself after a delay. To ensure up-to-date results,
// force an update now.
top_sites->SyncWithHistory();
top_sites->GetMostVisitedURLs(
base::BindRepeating(&MostVisitedSitesProvider::OnMostVisitedUrlsAvailable,
request_weak_ptr_factory_.GetWeakPtr()));
}
void MostVisitedSitesProvider::Stop(bool clear_cached_results,
bool due_to_user_inactivity) {
AutocompleteProvider::Stop(clear_cached_results, due_to_user_inactivity);
request_weak_ptr_factory_.InvalidateWeakPtrs();
}
MostVisitedSitesProvider::MostVisitedSitesProvider(
AutocompleteProviderClient* client,
AutocompleteProviderListener* listener)
: AutocompleteProvider(TYPE_MOST_VISITED_SITES),
device_form_factor_{ui::GetDeviceFormFactor()},
client_{client} {
AddListener(listener);
// TopSites updates itself after a delay. To ensure up-to-date results,
// force an update now.
scoped_refptr<history::TopSites> top_sites = client_->GetTopSites();
if (top_sites) {
top_sites->SyncWithHistory();
}
}
MostVisitedSitesProvider::~MostVisitedSitesProvider() = default;
void MostVisitedSitesProvider::OnMostVisitedUrlsAvailable(
const history::MostVisitedURLList& urls) {
done_ = true;
if (BuildTileSuggest(this, client_, device_form_factor_, urls, matches_)) {
NotifyListeners(true);
}
}
bool MostVisitedSitesProvider::AllowMostVisitedSitesSuggestions(
const AutocompleteInput& input) const {
const auto& page_url = input.current_url();
const auto page_class = input.current_page_classification();
const auto input_type = input.type();
if (!input.IsZeroSuggest()) {
return false;
}
if (client_->IsOffTheRecord())
return false;
// Check whether current context is one that supports MV tiles.
// Any context other than those listed below will be rejected.
if (page_class != metrics::OmniboxEventProto::OTHER &&
page_class != metrics::OmniboxEventProto::ANDROID_SEARCH_WIDGET &&
page_class != metrics::OmniboxEventProto::ANDROID_SHORTCUTS_WIDGET) {
return false;
}
// When omnibox contains pre-populated content, only show zero suggest for
// pages with URLs the user will recognize.
//
// This list intentionally does not include items such as ftp: and file:
// because (a) these do not work on Android and iOS, where most visited
// zero suggest is launched and (b) on desktop, where contextual zero suggest
// is running, these types of schemes aren't eligible to be sent to the
// server to ask for suggestions (and thus in practice we won't display zero
// suggest for them).
if (input_type != metrics::OmniboxInputType::EMPTY &&
!(page_url.is_valid() &&
((page_url.scheme() == url::kHttpScheme) ||
(page_url.scheme() == url::kHttpsScheme) ||
(page_url.scheme() == url::kAboutScheme) ||
(page_url.scheme() ==
client_->GetEmbedderRepresentationOfAboutScheme())))) {
return false;
}
return true;
}
void MostVisitedSitesProvider::BlockURL(const GURL& site_url) {
scoped_refptr<history::TopSites> top_sites = client_->GetTopSites();
if (top_sites) {
top_sites->AddBlockedUrl(site_url);
}
}
void MostVisitedSitesProvider::DeleteMatch(const AutocompleteMatch& match) {
DCHECK(match.type == AutocompleteMatchType::NAVSUGGEST ||
match.type == AutocompleteMatchType::TILE_MOST_VISITED_SITE ||
match.type == AutocompleteMatchType::TILE_REPEATABLE_QUERY);
BlockURL(match.destination_url);
for (auto i = matches_.begin(); i != matches_.end(); ++i) {
if (i->contents == match.contents) {
matches_.erase(i);
break;
}
}
}
void MostVisitedSitesProvider::DeleteMatchElement(
const AutocompleteMatch& source_match,
size_t element_index) {
DCHECK_EQ(source_match.type, AutocompleteMatchType::TILE_NAVSUGGEST);
DCHECK_GE(element_index, 0u);
DCHECK_LT((size_t)element_index, source_match.suggest_tiles.size());
// Attempt to modify the match in place.
DCHECK_EQ(matches_.size(), 1ul);
DCHECK_EQ(matches_[0].type, AutocompleteMatchType::TILE_NAVSUGGEST);
if (source_match.type != AutocompleteMatchType::TILE_NAVSUGGEST ||
element_index < 0u ||
element_index >= source_match.suggest_tiles.size() ||
matches_.size() != 1u ||
matches_[0].type != AutocompleteMatchType::TILE_NAVSUGGEST) {
return;
}
const auto& tile_to_delete = source_match.suggest_tiles[element_index];
base::UmaHistogramExactLinear(kHistogramDeletedTileIndex, element_index,
kMaxRecordedTileIndex);
base::UmaHistogramExactLinear(kHistogramDeletedTileType,
tile_to_delete.is_search
? SuggestTileType::kSearch
: SuggestTileType::kURL,
SuggestTileType::kCount);
BlockURL(tile_to_delete.url);
auto& tiles_to_update = matches_[0].suggest_tiles;
std::erase_if(tiles_to_update, [&tile_to_delete](const auto& tile) {
return tile.url == tile_to_delete.url;
});
if (tiles_to_update.empty()) {
matches_.clear();
}
}