blob: 68c36837e8056fbaf3917d715697850f1f407532 [file] [log] [blame]
// Copyright 2025 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
#include "chrome/browser/ui/webui/searchbox/contextual_searchbox_handler.h"
#include <algorithm>
#include <optional>
#include <string>
#include <utility>
#include <vector>
#include "base/containers/span.h"
#include "base/metrics/histogram_functions.h"
#include "base/strings/utf_string_conversions.h"
#include "chrome/browser/contextual_search/contextual_search_web_contents_helper.h"
#include "chrome/browser/ui/browser_window/public/browser_window_interface.h"
#include "chrome/browser/ui/omnibox/omnibox_controller.h"
#include "chrome/browser/ui/tabs/public/tab_features.h"
#include "chrome/browser/ui/tabs/tab_renderer_data.h"
#include "chrome/browser/ui/tabs/tab_strip_model.h"
#include "chrome/browser/ui/webui/new_tab_page/composebox/variations/composebox_fieldtrial.h"
#include "chrome/browser/ui/webui/omnibox_popup/omnibox_popup_web_contents_helper.h"
#include "chrome/browser/ui/webui/webui_embedding_context.h"
#include "components/contextual_search/contextual_search_metrics_recorder.h"
#include "components/google/core/common/google_util.h"
#include "components/lens/contextual_input.h"
#include "components/lens/tab_contextualization_controller.h"
#include "components/omnibox/browser/vector_icons.h"
#include "content/public/browser/render_widget_host_view.h"
#include "content/public/common/url_constants.h"
#include "ui/base/webui/web_ui_util.h"
#include "ui/base/window_open_disposition.h"
#include "ui/base/window_open_disposition_utils.h"
#if !BUILDFLAG(IS_ANDROID)
#include "chrome/browser/contextual_tasks/contextual_tasks_context_service.h"
#include "chrome/browser/contextual_tasks/contextual_tasks_context_service_factory.h"
#endif
namespace {
constexpr int kThumbnailWidth = 125;
constexpr int kThumbnailHeight = 200;
std::optional<lens::ImageEncodingOptions> CreateImageEncodingOptions() {
auto image_upload_config =
ntp_composebox::FeatureConfig::Get().config.composebox().image_upload();
return lens::ImageEncodingOptions{
.enable_webp_encoding = image_upload_config.enable_webp_encoding(),
.max_size = image_upload_config.downscale_max_image_size(),
.max_height = image_upload_config.downscale_max_image_height(),
.max_width = image_upload_config.downscale_max_image_width(),
.compression_quality = image_upload_config.image_compression_quality()};
}
// Returns the ContextualSearchSessionHandle for the given WebContents, or
// nullptr if there is none.
contextual_search::ContextualSearchSessionHandle* GetSessionHandle(
content::WebContents* web_contents) {
auto* contextual_search_web_contents_helper =
ContextualSearchWebContentsHelper::FromWebContents(web_contents);
return contextual_search_web_contents_helper
? contextual_search_web_contents_helper->session_handle()
: nullptr;
}
} // namespace
ContextualOmniboxClient::ContextualOmniboxClient(
Profile* profile,
content::WebContents* web_contents)
: SearchboxOmniboxClient(profile, web_contents) {}
ContextualOmniboxClient::~ContextualOmniboxClient() = default;
std::optional<lens::proto::LensOverlaySuggestInputs>
ContextualOmniboxClient::GetLensOverlaySuggestInputs() const {
auto* contextual_session_handle = GetSessionHandle(web_contents());
return contextual_session_handle
? contextual_session_handle->GetSuggestInputs()
: std::nullopt;
}
void ContextualSearchboxHandler::GetRecentTabs(GetRecentTabsCallback callback) {
std::vector<searchbox::mojom::TabInfoPtr> tabs;
auto* browser_window_interface =
webui::GetBrowserWindowInterface(web_contents_);
if (!browser_window_interface) {
std::move(callback).Run(std::move(tabs));
return;
}
// Iterate through the tab strip model, getting the data for each tab
auto* tab_strip_model = browser_window_interface->GetTabStripModel();
for (int i = 0; i < tab_strip_model->count(); i++) {
content::WebContents* web_contents = tab_strip_model->GetWebContentsAt(i);
tabs::TabInterface* const tab = tab_strip_model->GetTabAtIndex(i);
TabRendererData tab_renderer_data =
TabRendererData::FromTabInModel(tab_strip_model, i);
const auto& last_committed_url = tab_renderer_data.last_committed_url;
// Skip tabs that are still loading, and skip webui.
const bool is_invalid_url = !last_committed_url.is_valid();
const bool is_internal_page =
last_committed_url.SchemeIs(content::kChromeUIScheme) ||
last_committed_url.SchemeIs(content::kChromeUIUntrustedScheme);
if (is_invalid_url || is_internal_page) {
continue;
}
auto tab_data = searchbox::mojom::TabInfo::New();
tab_data->tab_id = tab->GetHandle().raw_value();
tab_data->title = base::UTF16ToUTF8(tab_renderer_data.title);
tab_data->url = last_committed_url;
tab_data->show_in_recent_tab_chip =
!google_util::IsGoogleSearchUrl(last_committed_url);
tab_data->last_active =
std::max(web_contents->GetLastActiveTimeTicks(),
web_contents->GetLastInteractionTimeTicks());
tabs.push_back(std::move(tab_data));
}
// Count duplicate tab titles to record in an UMA histogram.
// For example, If 2 tabs with title "Wikipedia" and 3 tabs with title
// "Weather" are open, this histogram will record 2.
std::map<std::string, int> title_counts;
for (const auto& tab : tabs) {
title_counts[tab->title]++;
}
int duplicate_count =
std::count_if(title_counts.begin(), title_counts.end(),
[](const std::pair<const std::string, int>& pair) {
return pair.second > 1;
});
// Sort the tabs by last active time, and truncate to the maximum number of
// tabs to return.
int max_tab_suggestions =
std::min(static_cast<int>(tabs.size()),
ntp_composebox::kContextMenuMaxTabSuggestions.Get());
std::partial_sort(tabs.begin(), tabs.begin() + max_tab_suggestions,
tabs.end(),
[](const searchbox::mojom::TabInfoPtr& a,
const searchbox::mojom::TabInfoPtr& b) {
return a->last_active > b->last_active;
});
tabs.resize(max_tab_suggestions);
if (auto* metrics_recorder = GetMetricsRecorder()) {
metrics_recorder->RecordTabContextMenuMetrics(tab_strip_model->count(),
duplicate_count);
}
// Invoke the callback with the results.
std::move(callback).Run(std::move(tabs));
}
void ContextualSearchboxHandler::GetTabPreview(int32_t tab_id,
GetTabPreviewCallback callback) {
const tabs::TabHandle handle = tabs::TabHandle(tab_id);
tabs::TabInterface* const tab = handle.Get();
if (!tab) {
std::move(callback).Run(std::nullopt);
return;
}
lens::TabContextualizationController* tab_context_controller =
tab->GetTabFeatures()->tab_contextualization_controller();
content::WebContents* web_contents = tab->GetContents();
tab_context_controller->CaptureScreenshot(
CreateTabPreviewEncodingOptions(web_contents),
base::BindOnce(&ContextualSearchboxHandler::OnPreviewReceived,
weak_ptr_factory_.GetWeakPtr(), std::move(callback)));
}
void ContextualSearchboxHandler::OnPreviewReceived(
GetTabPreviewCallback callback,
const SkBitmap& preview_bitmap) {
std::move(callback).Run(
preview_bitmap.isNull()
? std::nullopt
: std::make_optional(webui::GetBitmapDataUrl(preview_bitmap)));
}
void ContextualSearchboxHandler::OnTabStripModelChanged(
TabStripModel* tab_strip_model,
const TabStripModelChange& change,
const TabStripSelectionChange& selection) {
// TODO(crbug.com/449196853): We should be using the `tab_strip_api` on the
// typescript side, but it's not visible to `cr_components`, so we're using
// `TabStripModelObserver` for now until `tab_strip_api` gets moved out of
// //chrome. The current implementation is likely brittle, as it's not a
// supported API for external users.
if (IsRemoteBound()) {
page_->OnTabStripChanged();
}
}
std::optional<lens::ImageEncodingOptions>
ContextualSearchboxHandler::CreateTabPreviewEncodingOptions(
content::WebContents* web_contents) {
float scale_factor = 1.0f;
if (content::RenderWidgetHostView* view =
web_contents->GetRenderWidgetHostView()) {
scale_factor = view->GetDeviceScaleFactor();
}
const int max_height_pixels =
static_cast<int>(kThumbnailHeight * scale_factor);
const int max_width_pixels = static_cast<int>(kThumbnailWidth * scale_factor);
return lens::ImageEncodingOptions{.max_height = max_height_pixels,
.max_width = max_width_pixels};
}
ContextualSearchboxHandler::ContextualSearchboxHandler(
mojo::PendingReceiver<searchbox::mojom::PageHandler>
pending_searchbox_handler,
Profile* profile,
content::WebContents* web_contents,
std::unique_ptr<OmniboxController> controller)
: SearchboxHandler(std::move(pending_searchbox_handler),
profile,
web_contents,
std::move(controller)),
web_contents_(web_contents) {
auto* contextual_session_handle = GetSessionHandle(web_contents_);
if (contextual_session_handle) {
if (auto* query_controller = contextual_session_handle->GetController()) {
file_upload_status_observer_.Observe(query_controller);
}
auto* browser_window_interface =
webui::GetBrowserWindowInterface(web_contents_);
if (browser_window_interface) {
browser_window_interface->GetTabStripModel()->AddObserver(this);
}
}
#if !BUILDFLAG(IS_ANDROID)
contextual_tasks_context_service_ =
contextual_tasks::ContextualTasksContextServiceFactory::GetForProfile(
profile);
#endif
}
ContextualSearchboxHandler::~ContextualSearchboxHandler() {
auto* helper =
ContextualSearchWebContentsHelper::FromWebContents(web_contents_);
if (helper && helper->session_handle()) {
auto* browser_window_interface =
webui::GetBrowserWindowInterface(web_contents_);
if (browser_window_interface) {
browser_window_interface->GetTabStripModel()->RemoveObserver(this);
}
}
}
contextual_search::ContextualSearchMetricsRecorder*
ContextualSearchboxHandler::GetMetricsRecorder() {
auto* contextual_session_handle = GetSessionHandle(web_contents_);
return contextual_session_handle
? contextual_session_handle->GetMetricsRecorder()
: nullptr;
}
void ContextualSearchboxHandler::NotifySessionStarted() {
auto* contextual_session_handle = GetSessionHandle(web_contents_);
if (contextual_session_handle) {
contextual_session_handle->NotifySessionStarted();
}
}
void ContextualSearchboxHandler::NotifySessionAbandoned() {
auto* contextual_session_handle = GetSessionHandle(web_contents_);
if (contextual_session_handle) {
contextual_session_handle->NotifySessionAbandoned();
}
}
void ContextualSearchboxHandler::AddFileContext(
searchbox::mojom::SelectedFileInfoPtr file_info_mojom,
mojo_base::BigBuffer file_bytes,
AddFileContextCallback callback) {
auto* contextual_session_handle = GetSessionHandle(web_contents_);
if (contextual_session_handle) {
context_input_data_ = std::nullopt;
contextual_session_handle->AddFileContext(
file_info_mojom->mime_type, std::move(file_bytes),
CreateImageEncodingOptions(), std::move(callback));
}
}
void ContextualSearchboxHandler::AddTabContext(int32_t tab_id,
bool delay_upload,
AddTabContextCallback callback) {
// TODO(crbug.com/458050417): Move more of the tab context logic to
// ContextualSessionHandle.
const tabs::TabHandle handle = tabs::TabHandle(tab_id);
tabs::TabInterface* const tab = handle.Get();
if (!tab) {
std::move(callback).Run(std::nullopt);
return;
}
RecordTabClickedMetric(tab);
lens::TabContextualizationController* tab_contextualization_controller =
tab->GetTabFeatures()->tab_contextualization_controller();
auto token = base::UnguessableToken::Create();
tab_contextualization_controller->GetPageContext(
base::BindOnce(&ContextualSearchboxHandler::OnGetTabPageContext,
weak_ptr_factory_.GetWeakPtr(), delay_upload, token));
std::move(callback).Run(token);
}
void ContextualSearchboxHandler::RecordTabClickedMetric(
tabs::TabInterface* const tab) {
auto* metrics_recorder = GetMetricsRecorder();
if (!metrics_recorder) {
return;
}
bool has_duplicate_title = false;
auto* browser_window_interface =
webui::GetBrowserWindowInterface(web_contents_);
if (!browser_window_interface) {
return;
}
auto* tab_strip_model = browser_window_interface->GetTabStripModel();
int tab_index = tab_strip_model->GetIndexOfTab(tab);
if (tab_index == TabStripModel::kNoTab) {
return;
}
TabRendererData current_tab_renderer_data =
TabRendererData::FromTabInModel(tab_strip_model, tab_index);
const std::u16string& current_title = current_tab_renderer_data.title;
int title_count = 0;
std::vector<std::pair<int, base::TimeTicks>> last_active_times;
for (int i = 0; i < tab_strip_model->count(); i++) {
TabRendererData tab_renderer_data =
TabRendererData::FromTabInModel(tab_strip_model, i);
if (tab_renderer_data.title == current_title) {
title_count++;
}
if (tab_renderer_data.tab_interface) {
last_active_times.emplace_back(
i, tab_renderer_data.tab_interface->GetContents()
->GetLastActiveTimeTicks());
}
}
if (title_count > 1) {
has_duplicate_title = true;
}
std::vector<std::pair<int, base::TimeTicks>>
reverse_chron_last_active_times(last_active_times.begin(),
last_active_times.end());
std::sort(reverse_chron_last_active_times.begin(),
reverse_chron_last_active_times.end(),
[](const std::pair<int, base::TimeTicks>& a,
const std::pair<int, base::TimeTicks>& b) {
return a.second > b.second;
});
std::optional<int> recency_ranking;
for (size_t i = 0; i < reverse_chron_last_active_times.size(); ++i) {
if (reverse_chron_last_active_times[i].first == tab_index) {
recency_ranking = i;
break;
}
}
metrics_recorder->RecordTabClickedMetrics(has_duplicate_title,
recency_ranking);
}
void ContextualSearchboxHandler::DeleteContext(
const base::UnguessableToken& context_token) {
if (auto* contextual_session_handle = GetSessionHandle(web_contents_)) {
bool file_was_deleted =
contextual_session_handle->DeleteFile(context_token);
if (!file_was_deleted) {
// It is possible to receive a call to delete a context before that
// context has been created in the query controller. We queue all context
// tokens for deletion at query submission time.
// TODO(crbug.com/456471755): Transfer ownership of the attachments state
// to the ContextualSearchSessionHandle.
deleted_context_tokens_.insert(context_token);
}
}
// If the context token matches the cached tab context, we clear the snapshot.
if (tab_context_snapshot_.has_value() &&
tab_context_snapshot_.value().first == context_token) {
tab_context_snapshot_.reset();
context_input_data_ = std::nullopt;
}
}
void ContextualSearchboxHandler::ClearFiles() {
if (auto* contextual_session_handle = GetSessionHandle(web_contents_)) {
contextual_session_handle->ClearFiles();
}
context_input_data_ = std::nullopt;
tab_context_snapshot_.reset();
}
void ContextualSearchboxHandler::SubmitQuery(const std::string& query_text,
uint8_t mouse_button,
bool alt_key,
bool ctrl_key,
bool meta_key,
bool shift_key) {
const WindowOpenDisposition disposition = ui::DispositionFromClick(
/*middle_button=*/mouse_button == 1, alt_key, ctrl_key, meta_key,
shift_key);
ComputeAndOpenQueryUrl(query_text, disposition, /*additional_params=*/{});
}
void ContextualSearchboxHandler::OnFileUploadStatusChanged(
const base::UnguessableToken& file_token,
lens::MimeType mime_type,
contextual_search::FileUploadStatus file_upload_status,
const std::optional<contextual_search::FileUploadErrorType>& error_type) {
page_->OnContextualInputStatusChanged(
file_token, contextual_search::ToMojom(file_upload_status),
error_type.has_value()
? std::make_optional(contextual_search::ToMojom(error_type.value()))
: std::nullopt);
// TODO(crbug.com/458049845): Move responsibility of updating metrics on file
// upload status change to ContextualSearchSessionEntry.
if (auto* metrics_recorder = GetMetricsRecorder()) {
metrics_recorder->OnFileUploadStatusChanged(mime_type, file_upload_status,
error_type);
}
}
std::string ContextualSearchboxHandler::AutocompleteIconToResourceName(
const gfx::VectorIcon& icon) const {
// The default icon for contextual suggestions is the subdirectory arrow right
// icon. For the Lens composebox and realbox, we want to stay consistent with
// the search loupe instead.
if (icon.name == omnibox::kSubdirectoryArrowRightIcon.name) {
return searchbox_internal::kSearchIconResourceName;
}
return SearchboxHandler::AutocompleteIconToResourceName(icon);
}
void ContextualSearchboxHandler::ComputeAndOpenQueryUrl(
const std::string& query_text,
WindowOpenDisposition disposition,
std::map<std::string, std::string> additional_params) {
auto* contextual_session_handle = GetSessionHandle(web_contents_);
std::vector<const contextual_search::FileInfo*> file_info_list;
if (contextual_session_handle) {
// Upload the cached tab context if it exists.
UploadSnapshotTabContextIfPresent();
auto search_url_request_info =
std::make_unique<contextual_search::ContextualSearchContextController::
CreateSearchUrlRequestInfo>();
// This is the time that the user clicked the submit button, however
// optional autocomplete logic may be run before this if there was a match
// associated with the query.
search_url_request_info->query_start_time = base::Time::Now();
search_url_request_info->query_text = query_text;
search_url_request_info->additional_params = additional_params;
OpenUrl(contextual_session_handle->CreateSearchUrl(
std::move(search_url_request_info)),
disposition);
file_info_list =
contextual_session_handle->GetController()->GetFileInfoList();
}
#if !BUILDFLAG(IS_ANDROID)
// Assume that if we're here and created a composebox query controller that
// this is an AIM search by default.
// Do not provide a callback as this method is only used for dark experiment.
if (contextual_tasks_context_service_) {
std::vector<GURL> explicit_urls;
for (const contextual_search::FileInfo* file_info : file_info_list) {
if (file_info->tab_url) {
explicit_urls.push_back(*(file_info->tab_url));
}
}
contextual_tasks_context_service_->GetRelevantTabsForQuery(
contextual_tasks::TabSelectionOptions(), query_text, explicit_urls,
base::DoNothing());
}
#endif
ClearFiles();
}
void ContextualSearchboxHandler::OnGetTabPageContext(
bool delay_upload,
const base::UnguessableToken& context_token,
std::unique_ptr<lens::ContextualInputData> page_content_data) {
if (deleted_context_tokens_.contains(context_token)) {
// Tab was deleted before the file upload flow could start.
deleted_context_tokens_.erase(context_token);
return;
}
if (delay_upload) {
SnapshotTabContext(context_token, std::move(page_content_data));
} else {
UploadTabContext(context_token, std::move(page_content_data));
}
}
void ContextualSearchboxHandler::SnapshotTabContext(
const base::UnguessableToken& context_token,
std::unique_ptr<lens::ContextualInputData> page_content_data) {
context_input_data_ = *page_content_data;
tab_context_snapshot_.emplace(context_token, std::move(page_content_data));
page_->OnContextualInputStatusChanged(
context_token,
contextual_search::ToMojom(
contextual_search::FileUploadStatus::kProcessing),
std::nullopt);
}
void ContextualSearchboxHandler::UploadTabContext(
const base::UnguessableToken& context_token,
std::unique_ptr<lens::ContextualInputData> page_content_data) {
auto* contextual_session_handle = GetSessionHandle(web_contents_);
if (contextual_session_handle) {
context_input_data_ = std::nullopt;
contextual_session_handle->StartTabContextUploadFlow(
context_token, std::move(page_content_data),
CreateImageEncodingOptions());
}
}
void ContextualSearchboxHandler::UploadSnapshotTabContextIfPresent() {
if (!tab_context_snapshot_.has_value()) {
return;
}
auto [context_token, page_content_data] =
std::move(tab_context_snapshot_.value());
tab_context_snapshot_.reset();
UploadTabContext(context_token, std::move(page_content_data));
}
void ContextualSearchboxHandler::OpenUrl(
GURL url,
const WindowOpenDisposition disposition) {
if (OmniboxPopupWebContentsHelper::FromWebContents(web_contents_.get())) {
auto* browser_window_interface =
webui::GetBrowserWindowInterface(web_contents_);
content::OpenURLParams params(url, content::Referrer(), disposition,
ui::PAGE_TRANSITION_LINK, false);
browser_window_interface->GetTabStripModel()
->GetActiveWebContents()
->OpenURL(params, base::DoNothing());
} else {
content::OpenURLParams params(url, content::Referrer(), disposition,
ui::PAGE_TRANSITION_LINK, false);
web_contents_->OpenURL(params, base::DoNothing());
}
}