blob: e4a486f495e9dbd353ddc6d01a6c6d9f18a67652 [file] [log] [blame]
// Copyright 2023 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/compose/chrome_compose_client.h"
#include <memory>
#include <optional>
#include <string>
#include <utility>
#include "base/functional/bind.h"
#include "base/json/values_util.h"
#include "base/metrics/user_metrics.h"
#include "base/strings/utf_string_conversion_utils.h"
#include "base/strings/utf_string_conversions.h"
#include "base/task/bind_post_task.h"
#include "base/task/thread_pool.h"
#include "base/third_party/icu/icu_utf.h"
#include "chrome/browser/compose/compose_enabling.h"
#include "chrome/browser/compose/compose_text_usage_logger.h"
#include "chrome/browser/compose/proactive_nudge_tracker.h"
#include "chrome/browser/compose/proto/compose_optimization_guide.pb.h"
#include "chrome/browser/optimization_guide/optimization_guide_keyed_service.h"
#include "chrome/browser/optimization_guide/optimization_guide_keyed_service_factory.h"
#include "chrome/browser/profiles/profile.h"
#include "chrome/browser/profiles/profile_manager.h"
#include "chrome/browser/segmentation_platform/segmentation_platform_service_factory.h"
#include "chrome/browser/signin/identity_manager_factory.h"
#include "chrome/browser/translate/chrome_translate_client.h"
#include "chrome/browser/ui/browser.h"
#include "chrome/browser/ui/browser_dialogs.h"
#include "chrome/browser/ui/browser_element_identifiers.h"
#include "chrome/browser/ui/browser_finder.h"
#include "chrome/browser/ui/chrome_pages.h"
#include "chrome/browser/ui/hats/hats_service_factory.h"
#include "chrome/browser/ui/hats/survey_config.h"
#include "chrome/browser/ui/user_education/show_promo_in_page.h"
#include "chrome/common/compose/type_conversions.h"
#include "chrome/common/pref_names.h"
#include "chrome/common/webui_url_constants.h"
#include "components/autofill/content/browser/content_autofill_client.h"
#include "components/autofill/content/browser/content_autofill_driver.h"
#include "components/autofill/core/browser/filling/filling_product.h"
#include "components/autofill/core/browser/foundations/autofill_client.h"
#include "components/autofill/core/browser/suggestions/suggestion.h"
#include "components/autofill/core/common/aliases.h"
#include "components/autofill/core/common/form_field_data.h"
#include "components/compose/core/browser/compose_features.h"
#include "components/compose/core/browser/compose_manager_impl.h"
#include "components/compose/core/browser/compose_metrics.h"
#include "components/compose/core/browser/config.h"
#include "components/optimization_guide/core/optimization_guide_features.h"
#include "components/optimization_guide/proto/features/compose.pb.h"
#include "components/strings/grit/components_strings.h"
#include "components/unified_consent/pref_names.h"
#include "components/unified_consent/url_keyed_data_collection_consent_helper.h"
#include "content/public/browser/browser_thread.h"
#include "content/public/browser/context_menu_params.h"
#include "content/public/browser/page.h"
#include "content/public/browser/render_frame_host.h"
#include "content/public/browser/web_contents.h"
#include "content/public/browser/web_contents_observer.h"
#include "content/public/browser/web_contents_user_data.h"
#include "mojo/public/cpp/bindings/callback_helpers.h"
#include "services/metrics/public/cpp/ukm_recorder.h"
#include "services/metrics/public/cpp/ukm_source_id.h"
#include "ui/base/l10n/l10n_util.h"
#include "ui/gfx/geometry/rect_conversions.h"
#include "ui/gfx/geometry/rect_f.h"
namespace {
std::u16string RemoveLastCharIfInvalid(std::u16string str) {
// TODO(b/323902463): Have Autofill send a valid string, i.e. truncated to a
// valid grapheme, in FormFieldData.selected_text to ensure greatest
// preservation of the original selected text.
if (!str.empty() && CBU16_IS_LEAD(str.back())) {
str.pop_back();
}
return str;
}
bool ComposeNudgeShowStatusDisabledByConfig(compose::ComposeShowStatus status) {
switch (status) {
case compose::ComposeShowStatus::
kProactiveNudgeDisabledGloballyByUserPreference:
case compose::ComposeShowStatus::
kProactiveNudgeDisabledForSiteByUserPreference:
case compose::ComposeShowStatus::kProactiveNudgeFeatureDisabled:
case compose::ComposeShowStatus::kProactiveNudgeDisabledByMSBB:
case compose::ComposeShowStatus::
kProactiveNudgeBlockedBySegmentationPlatform:
return true;
default:
return false;
}
}
} // namespace
// ChromeComposeClient::FieldChangeObserver
ChromeComposeClient::FieldChangeObserver::FieldChangeObserver(
content::WebContents* web_contents)
: web_contents_(web_contents) {
autofill_managers_observation_.Observe(
web_contents, autofill::ScopedAutofillManagersObservation::
InitializationPolicy::kObservePreexistingManagers);
}
ChromeComposeClient::FieldChangeObserver::~FieldChangeObserver() = default;
void ChromeComposeClient::FieldChangeObserver::OnSuggestionsShown(
autofill::AutofillManager& manager) {
text_field_value_change_event_count_ = 0;
}
void ChromeComposeClient::FieldChangeObserver::OnAfterTextFieldValueChanged(
autofill::AutofillManager& manager,
autofill::FormGlobalId form,
autofill::FieldGlobalId field,
const std::u16string& text_value) {
++text_field_value_change_event_count_;
if (text_field_value_change_event_count_ >=
compose::GetComposeConfig().nudge_field_change_event_max) {
HideComposeNudges();
text_field_value_change_event_count_ = 0;
}
}
void ChromeComposeClient::FieldChangeObserver::HideComposeNudges() {
if (autofill::AutofillClient* autofill_client =
autofill::ContentAutofillClient::FromWebContents(web_contents_)) {
// Only hide open suggestions if they are of compose type.
base::span<const autofill::Suggestion> suggestions =
autofill_client->GetAutofillSuggestions();
if ((suggestions.size() == 1 &&
autofill::GetFillingProductFromSuggestionType(suggestions[0].type) ==
autofill::FillingProduct::kCompose) ||
skip_suggestion_type_for_test_) {
autofill_client->HideAutofillSuggestions(
autofill::SuggestionHidingReason::kFieldValueChanged);
}
}
}
void ChromeComposeClient::FieldChangeObserver::SetSkipSuggestionTypeForTest(
bool skip_suggestion_type) {
skip_suggestion_type_for_test_ = skip_suggestion_type;
}
ChromeComposeClient::ChromeComposeClient(content::WebContents* web_contents)
: content::WebContentsObserver(web_contents),
content::WebContentsUserData<ChromeComposeClient>(*web_contents),
profile_(
Profile::FromBrowserContext(GetWebContents().GetBrowserContext())),
nudge_tracker_(segmentation_platform::SegmentationPlatformServiceFactory::
GetForProfile(profile_),
this),
field_change_observer_(web_contents) {
auto ukm_source_id =
GetWebContents().GetPrimaryMainFrame()->GetPageUkmSourceId();
page_ukm_tracker_ = std::make_unique<compose::PageUkmTracker>(ukm_source_id);
opt_guide_ = OptimizationGuideKeyedServiceFactory::GetForProfile(profile_);
pref_service_ = profile_->GetPrefs();
proactive_nudge_enabled_.Init(prefs::kEnableProactiveNudge, pref_service_);
compose_enabling_ = std::make_unique<ComposeEnabling>(
profile_, IdentityManagerFactory::GetForProfileIfExists(profile_),
OptimizationGuideKeyedServiceFactory::GetForProfile(profile_));
if (GetOptimizationGuide()) {
std::vector<optimization_guide::proto::OptimizationType> types;
if (compose_enabling_->IsEnabled().has_value()) {
types.push_back(optimization_guide::proto::OptimizationType::COMPOSE);
}
if (!types.empty()) {
GetOptimizationGuide()->RegisterOptimizationTypes(types);
}
}
autofill_managers_observation_.Observe(
web_contents, autofill::ScopedAutofillManagersObservation::
InitializationPolicy::kObservePreexistingManagers);
nudge_tracker_.StartObserving(web_contents);
}
ChromeComposeClient::~ChromeComposeClient() {
// Sessions may call back during destruction through ComposeSession::Observer.
// Let's ensure that happens before destroying anything else.
sessions_.clear();
debug_session_.reset();
}
void ChromeComposeClient::BindComposeDialog(
mojo::PendingReceiver<compose::mojom::ComposeClientUntrustedPageHandler>
client_handler,
mojo::PendingReceiver<compose::mojom::ComposeSessionUntrustedPageHandler>
handler,
mojo::PendingRemote<compose::mojom::ComposeUntrustedDialog> dialog) {
client_page_receiver_.reset();
client_page_receiver_.Bind(std::move(client_handler));
url::Origin origin =
GetWebContents().GetPrimaryMainFrame()->GetLastCommittedOrigin();
if (origin ==
url::Origin::Create(GURL(chrome::kChromeUIUntrustedComposeUrl))) {
debug_session_ = std::make_unique<ComposeSession>(
&GetWebContents(), GetModelExecutor(),
GetModelQualityLogsUploaderService(), GetSessionId(),
GetInnerTextProvider(),
autofill::FieldGlobalId{{}, autofill::FieldRendererId(-1)},
IsPageLanguageSupported(), this);
debug_session_->set_collect_inner_text(false);
debug_session_->set_fre_complete(
pref_service_->GetBoolean(prefs::kPrefHasCompletedComposeFRE));
debug_session_->set_current_msbb_state(GetMSBBStateFromPrefs());
debug_session_->Bind(std::move(handler), std::move(dialog));
return;
}
std::optional<FieldIdentifier> target_field;
if (skip_show_dialog_for_test_) {
target_field = active_compose_ids_;
} else if (compose_dialog_controller_) {
target_field = compose_dialog_controller_->GetFieldIds();
}
if (!target_field.has_value()) {
DLOG(WARNING)
<< "Unable to bind dialog because no controller is available.";
compose_dialog_controller_.reset();
return;
}
if (!HasSession(target_field->first)) {
DLOG(WARNING) << "Unable to bind dialog because there is no session for "
"the underlying field.";
compose_dialog_controller_.reset();
return;
}
active_compose_ids_ = target_field;
sessions_.at(active_compose_ids_.value().first)
->Bind(std::move(handler), std::move(dialog));
}
void ChromeComposeClient::ShowComposeDialog(
EntryPoint ui_entry_point,
const autofill::FormFieldData& trigger_field,
std::optional<autofill::AutofillClient::PopupScreenLocation>
popup_screen_location,
ComposeCallback callback) {
active_compose_ids_ = std::make_optional<FieldIdentifier>(
trigger_field.global_id(), trigger_field.renderer_form_id());
// The selected text received from Autofill is a UTF-16 string truncated using
// substr, which will result in a rendered invalid character in the Compose
// dialog if it splits a surrogate pair character. Ensure that any invalid
// characters are removed.
std::string selected_text =
base::UTF16ToUTF8(RemoveLastCharIfInvalid(trigger_field.selected_text()));
// We only want to resume if there is an existing, unexpired session and the
// popup was clicked or the selection is empty. If the context menu is clicked
// with a selection we start a new session using the selection.
bool popup_clicked = ui_entry_point == EntryPoint::kAutofillPopup;
bool resume_current_session = ActiveFieldHasUnexpiredSession() &&
(popup_clicked || selected_text.empty());
if (resume_current_session) {
PrepareToResumeExistingSession(std::move(callback),
/*has_selection=*/!selected_text.empty(),
popup_clicked);
} else {
CreateNewSession(std::move(callback), trigger_field, selected_text,
popup_clicked);
}
last_popup_trigger_source_ =
autofill::AutofillSuggestionTriggerSource::kUnspecified;
if (!skip_show_dialog_for_test_) {
// The bounds given by autofill are relative to the top level frame. Here we
// offset by the WebContents container to make up for that.
gfx::RectF bounds_in_screen = trigger_field.bounds();
bounds_in_screen.Offset(
GetWebContents().GetContainerBounds().OffsetFromOrigin());
show_dialog_start_ = base::TimeTicks::Now();
DCHECK(active_compose_ids_.has_value());
compose_dialog_controller_ = chrome::ShowComposeDialog(
GetWebContents(), bounds_in_screen, active_compose_ids_.value());
}
}
bool ChromeComposeClient::HasSession(
const autofill::FieldGlobalId& trigger_field_id) {
auto it = sessions_.find(trigger_field_id);
return it != sessions_.end();
}
void ChromeComposeClient::ShowUI() {
if (compose_dialog_controller_) {
compose_dialog_controller_->ShowUI(
base::BindOnce(&ChromeComposeClient::ShowSavedStateNotification,
weak_ptr_factory_.GetWeakPtr(),
/*field_id=*/active_compose_ids_->first));
compose::LogComposeDialogOpenLatency(base::TimeTicks::Now() -
show_dialog_start_);
}
}
void ChromeComposeClient::CloseUI(compose::mojom::CloseReason reason) {
switch (reason) {
case compose::mojom::CloseReason::kFirstRunCloseButton:
SetFirstRunSessionCloseReason(
compose::ComposeFreOrMsbbSessionCloseReason::kCloseButtonPressed);
break;
case compose::mojom::CloseReason::kMSBBCloseButton:
SetMSBBSessionCloseReason(
compose::ComposeFreOrMsbbSessionCloseReason::kCloseButtonPressed);
break;
case compose::mojom::CloseReason::kCloseButton:
base::RecordAction(
base::UserMetricsAction("Compose.EndedSession.CloseButtonClicked"));
SetSessionCloseReason(
compose::ComposeSessionCloseReason::kCloseButtonPressed);
LaunchHatsSurveyForActiveSession(
compose::ComposeSessionCloseReason::kCloseButtonPressed);
break;
case compose::mojom::CloseReason::kInsertButton:
base::RecordAction(
base::UserMetricsAction("Compose.EndedSession.InsertButtonClicked"));
SetSessionCloseReason(
compose::ComposeSessionCloseReason::kInsertedResponse);
SetMSBBSessionCloseReason(compose::ComposeFreOrMsbbSessionCloseReason::
kAckedOrAcceptedWithInsert);
LaunchHatsSurveyForActiveSession(
compose::ComposeSessionCloseReason::kInsertedResponse);
SetFirstRunSessionCloseReason(
compose::ComposeFreOrMsbbSessionCloseReason::
kAckedOrAcceptedWithInsert);
page_ukm_tracker_->ComposeTextInserted();
break;
}
RemoveActiveSession();
if (compose_dialog_controller_) {
compose_dialog_controller_->Close();
}
}
void ChromeComposeClient::CompleteFirstRun() {
pref_service_->SetBoolean(prefs::kPrefHasCompletedComposeFRE, true);
// This marks the end of the FRE "session" as the dialog moves to the main UI
// state. Mark all existing sessions as having completed the FRE and log
// relevant metrics.
UpdateAllSessionsWithFirstRunComplete();
open_settings_requested_ = false;
SetFirstRunSessionCloseReason(compose::ComposeFreOrMsbbSessionCloseReason::
kAckedOrAcceptedWithoutInsert);
}
void ChromeComposeClient::OpenComposeSettings() {
Browser* browser = chrome::FindBrowserWithTab(&GetWebContents());
// `browser` should never be null here. This can only be triggered when there
// is an active ComposeSession, which is indirectly owned by the same
// WebContents that holds the field that the Compose dialog is triggered from.
// The session is created when that dialog is opened and it is destroyed if
// its WebContents is destroyed.
CHECK(browser);
ShowPromoInPage::Params params;
params.target_url = chrome::GetSettingsUrl(chrome::kSyncSetupSubPage);
params.bubble_anchor_id = kAnonymizedUrlCollectionPersonalizationSettingId;
params.bubble_arrow = user_education::HelpBubbleArrow::kBottomRight;
params.bubble_text =
l10n_util::GetStringUTF16(IDS_COMPOSE_MSBB_IPH_BUBBLE_TEXT);
params.close_button_alt_text_id =
IDS_COMPOSE_MSBB_IPH_BUBBLE_CLOSE_BUTTON_LABEL_TEXT;
ComposeSession* active_session = GetSessionForActiveComposeField();
if (active_session) {
active_session->set_msbb_settings_opened();
}
base::RecordAction(
base::UserMetricsAction("Compose.SessionPaused.MSBBSettingsShown"));
ShowPromoInPage::Start(browser, std::move(params));
open_settings_requested_ = true;
}
void ChromeComposeClient::GetInnerText(
content::RenderFrameHost& host,
std::optional<int> node_id,
content_extraction::InnerTextCallback callback) {
content_extraction::GetInnerText(host, node_id, std::move(callback));
}
void ChromeComposeClient::UpdateAllSessionsWithFirstRunComplete() {
if (debug_session_) {
debug_session_->SetFirstRunCompleted();
}
for (const auto& session : sessions_) {
session.second->SetFirstRunCompleted();
}
}
void ChromeComposeClient::PrepareToResumeExistingSession(
ComposeCallback callback,
bool has_selection,
bool popup_clicked) {
ComposeSession* current_session = GetSessionForActiveComposeField();
CHECK(current_session);
current_session->set_compose_callback(std::move(callback));
// Update the msbb state which can change while the session is hidden.
current_session->set_current_msbb_state(GetMSBBStateFromPrefs());
current_session->MaybeRefreshPageContext(has_selection);
if (popup_clicked) {
if (last_popup_trigger_source_ ==
autofill::AutofillSuggestionTriggerSource::kComposeDialogLostFocus) {
compose::LogResumeSessionEntryPoint(
compose::ComposeEntryPoint::kSavedStateNotification);
} else {
compose::LogResumeSessionEntryPoint(
compose::ComposeEntryPoint::kSavedStateNudge);
}
} else {
compose::LogResumeSessionEntryPoint(
compose::ComposeEntryPoint::kContextMenu);
}
}
void ChromeComposeClient::CreateNewSession(
ComposeCallback callback,
const autofill::FormFieldData& trigger_field,
std::string_view selected_text,
bool popup_clicked) {
ComposeSession* current_session;
autofill::FieldGlobalId trigger_field_id = active_compose_ids_.value().first;
if (HasSession(trigger_field_id)) {
current_session = sessions_.at(trigger_field_id).get();
// Set the final state for the existing session which will be closed to
// start a new one.
compose::ComposeFreOrMsbbSessionCloseReason fre_or_msbb_close_reason;
if (current_session->HasExpired()) {
base::RecordAction(
base::UserMetricsAction("Compose.EndedSession.EndedImplicitly"));
SetSessionCloseReason(
compose::ComposeSessionCloseReason::kExceededMaxDuration);
fre_or_msbb_close_reason =
compose::ComposeFreOrMsbbSessionCloseReason::kExceededMaxDuration;
} else {
base::RecordAction(base::UserMetricsAction(
"Compose.EndedSession.NewSessionWithSelectedText"));
SetSessionCloseReason(
compose::ComposeSessionCloseReason::kReplacedWithNewSession);
fre_or_msbb_close_reason =
compose::ComposeFreOrMsbbSessionCloseReason::kReplacedWithNewSession;
}
// If the existing session has not accepted consent then set the equivalent
// close reason here. If consent was accepted in this session the close
// reason will remain as |kAckedOrAcceptedWithoutInsert|.
if (!current_session->get_fre_complete()) {
SetFirstRunSessionCloseReason(fre_or_msbb_close_reason);
}
if (!current_session->get_current_msbb_state()) {
SetMSBBSessionCloseReason(fre_or_msbb_close_reason);
}
}
auto new_session = std::make_unique<ComposeSession>(
&GetWebContents(), GetModelExecutor(),
GetModelQualityLogsUploaderService(), GetSessionId(),
GetInnerTextProvider(), trigger_field.global_id(),
IsPageLanguageSupported(), this, std::move(callback));
current_session = new_session.get();
sessions_.insert_or_assign(active_compose_ids_.value().first,
std::move(new_session));
// Set the FRE state of the new session.
auto fre_state =
pref_service_->GetBoolean(prefs::kPrefHasCompletedComposeFRE);
current_session->set_fre_complete(fre_state);
// Set the MSBB state of the new session.
current_session->set_current_msbb_state(GetMSBBStateFromPrefs());
current_session->InitializeWithText(selected_text);
// Record the UI state that new sessions are created in.
if (!fre_state) {
base::RecordAction(
base::UserMetricsAction("Compose.DialogSeen.FirstRunDisclaimer"));
} else if (!GetMSBBStateFromPrefs()) {
base::RecordAction(
base::UserMetricsAction("Compose.DialogSeen.FirstRunMSBB"));
} else {
base::RecordAction(
base::UserMetricsAction("Compose.DialogSeen.MainDialog"));
}
// Only record the selection length for new sessions.
auto utf8_chars = base::CountUnicodeCharacters(selected_text);
compose::LogComposeDialogSelectionLength(
utf8_chars.has_value() ? utf8_chars.value() : 0);
if (popup_clicked) {
switch (most_recent_nudge_entry_point_) {
case compose::ComposeEntryPoint::kProactiveNudge:
current_session->set_started_with_proactive_nudge();
page_ukm_tracker_->ProactiveNudgeOpened();
compose::LogComposeProactiveNudgeCtr(
compose::ComposeNudgeCtrEvent::kDialogOpened);
compose::LogStartSessionEntryPoint(
compose::ComposeEntryPoint::kProactiveNudge);
break;
case compose::ComposeEntryPoint::kSelectionNudge:
compose::LogComposeSelectionNudgeCtr(
compose::ComposeNudgeCtrEvent::kDialogOpened);
compose::LogStartSessionEntryPoint(
compose::ComposeEntryPoint::kSelectionNudge);
break;
case compose::ComposeEntryPoint::kContextMenu:
case compose::ComposeEntryPoint::kSavedStateNudge:
case compose::ComposeEntryPoint::kSavedStateNotification:
break;
}
} else {
compose::LogStartSessionEntryPoint(
compose::ComposeEntryPoint::kContextMenu);
}
}
void ChromeComposeClient::RemoveActiveSession() {
if (debug_session_) {
debug_session_.reset();
return;
}
if (!active_compose_ids_.has_value()) {
return;
}
auto it = sessions_.find(active_compose_ids_.value().first);
CHECK(it != sessions_.end())
<< "Attempted to remove compose session that doesn't exist.";
sessions_.erase(active_compose_ids_.value().first);
active_compose_ids_.reset();
}
void ChromeComposeClient::SetMSBBSessionCloseReason(
compose::ComposeFreOrMsbbSessionCloseReason close_reason) {
if (debug_session_) {
return;
}
ComposeSession* active_session = GetSessionForActiveComposeField();
if (active_session) {
active_session->SetMSBBCloseReason(close_reason);
}
}
void ChromeComposeClient::SetFirstRunSessionCloseReason(
compose::ComposeFreOrMsbbSessionCloseReason close_reason) {
if (debug_session_) {
return;
}
ComposeSession* active_session = GetSessionForActiveComposeField();
if (active_session) {
active_session->SetFirstRunCloseReason(close_reason);
}
}
void ChromeComposeClient::SetSessionCloseReason(
compose::ComposeSessionCloseReason close_reason) {
if (debug_session_) {
return;
}
ComposeSession* active_session = GetSessionForActiveComposeField();
if (active_session) {
active_session->SetCloseReason(close_reason);
}
}
void ChromeComposeClient::LaunchHatsSurveyForActiveSession(
compose::ComposeSessionCloseReason close_reason) {
if (debug_session_) {
return;
}
ComposeSession* active_session = GetSessionForActiveComposeField();
if (active_session) {
active_session->LaunchHatsSurvey(close_reason);
}
}
void ChromeComposeClient::RemoveAllSessions() {
if (debug_session_) {
debug_session_.reset();
}
sessions_.erase(sessions_.begin(), sessions_.end());
active_compose_ids_.reset();
}
void ChromeComposeClient::ShowSavedStateNotification(
autofill::FieldGlobalId field_id) {
if (!active_compose_ids_.has_value()) {
// Do not show the saved state notification on a previous field if another
// autofill suggestion is showing in the newly focused field.
return;
}
if (active_compose_ids_->first != field_id &&
HasSession(active_compose_ids_->first)) {
// Do not show the saved state notification on a previous field if focusing
// on a new field that will show a compose nudge. Do not show nudge and
// saved state notification on two different fields at the same time.
return;
}
if (autofill::AutofillDriver* driver =
autofill::ContentAutofillDriver::GetForRenderFrameHost(
GetWebContents().GetPrimaryMainFrame())) {
driver->RendererShouldTriggerSuggestions(
field_id,
autofill::AutofillSuggestionTriggerSource::kComposeDialogLostFocus);
}
}
ComposeSession* ChromeComposeClient::GetSessionForActiveComposeField() {
if (active_compose_ids_.has_value()) {
auto it = sessions_.find(active_compose_ids_.value().first);
if (it != sessions_.end()) {
return it->second.get();
}
}
return nullptr;
}
bool ChromeComposeClient::IsPageLanguageSupported() {
translate::TranslateManager* translate_manager =
ChromeTranslateClient::GetManagerFromWebContents(&GetWebContents());
return compose_enabling_->IsPageLanguageSupported(translate_manager);
}
bool ChromeComposeClient::GetMSBBStateFromPrefs() {
std::unique_ptr<unified_consent::UrlKeyedDataCollectionConsentHelper> helper =
unified_consent::UrlKeyedDataCollectionConsentHelper::
NewAnonymizedDataCollectionConsentHelper(profile_->GetPrefs());
return !(helper != nullptr && !helper->IsEnabled());
}
compose::ComposeManager& ChromeComposeClient::GetManager() {
return manager_;
}
ComposeEnabling& ChromeComposeClient::GetComposeEnabling() {
return *compose_enabling_;
}
compose::PageUkmTracker* ChromeComposeClient::GetPageUkmTracker() {
return page_ukm_tracker_.get();
}
bool ChromeComposeClient::ActiveFieldHasUnexpiredSession() {
if (ComposeSession* current_session = GetSessionForActiveComposeField()) {
return !current_session->HasExpired();
}
return false;
}
bool ChromeComposeClient::ShouldTriggerPopup(
const autofill::FormData& form_data,
const autofill::FormFieldData& form_field_data,
autofill::AutofillSuggestionTriggerSource trigger_source) {
// Saved state notification needs the active field set earlier here at nudge
// triggering, rather than later when the compose dialog is shown so that we
// can know if the user focused on a different field.
active_compose_ids_ = std::make_optional<FieldIdentifier>(
form_field_data.global_id(), form_field_data.renderer_form_id());
if (ActiveFieldHasUnexpiredSession()) {
if (compose_enabling_->ShouldTriggerSavedStatePopup(trigger_source)) {
last_popup_trigger_source_ = trigger_source;
return true;
}
return false;
}
auto proactive_nudge_status = compose_enabling_->ShouldTriggerNoStatePopup(
form_field_data.autocomplete_attribute(),
form_field_data.allows_writing_suggestions(), profile_, pref_service_,
ChromeTranslateClient::GetManagerFromWebContents(&GetWebContents()),
GetWebContents().GetPrimaryMainFrame()->GetLastCommittedOrigin(),
form_field_data.origin(),
GetWebContents().GetPrimaryMainFrame()->GetLastCommittedURL(),
GetMSBBStateFromPrefs());
compose::ProactiveNudgeTracker::Signals nudge_signals;
nudge_signals.ukm_source_id =
GetWebContents().GetPrimaryMainFrame()->GetPageUkmSourceId();
nudge_signals.page_origin =
web_contents()->GetPrimaryMainFrame()->GetLastCommittedOrigin();
nudge_signals.page_url = web_contents()->GetURL();
nudge_signals.form = form_data;
nudge_signals.field = form_field_data;
nudge_signals.page_change_time = page_change_time_;
if (!proactive_nudge_status.has_value()) {
compose::LogComposeProactiveNudgeShowStatus(proactive_nudge_status.error());
// Record that the nudge could have shown if it was disabled by
// configuration or flags.
if (ComposeNudgeShowStatusDisabledByConfig(
proactive_nudge_status.error())) {
page_ukm_tracker_->ComposeProactiveNudgeShouldShow();
}
if (proactive_nudge_status.error() ==
compose::ComposeShowStatus::kProactiveNudgeFeatureDisabled &&
compose::GetComposeConfig().selection_nudge_enabled) {
// If the proactive nudge is disabled but the selection nudge is is
// enabled we need to initialize the nudge tracker for this form field to
// accept the selection nudge.
return nudge_tracker_.ProactiveNudgeRequestedForFormField(
std::move(nudge_signals));
}
return false;
}
// ProactiveNudgeRequestedForFormField logs metrics for showing the nudge.
if (nudge_tracker_.ProactiveNudgeRequestedForFormField(
std::move(nudge_signals))) {
last_popup_trigger_source_ = trigger_source;
return true;
}
return false;
}
bool ChromeComposeClient::IsPopupTimerRunning() {
return nudge_tracker_.IsTimerRunning();
}
void ChromeComposeClient::DisableProactiveNudge() {
nudge_tracker_.OnUserDisabledNudge(/*single_site_only=*/false);
proactive_nudge_enabled_.SetValue(false);
switch (most_recent_nudge_entry_point_) {
case compose::ComposeEntryPoint::kProactiveNudge:
compose::LogComposeProactiveNudgeCtr(
compose::ComposeNudgeCtrEvent::kUserDisabledProactiveNudge);
GetPageUkmTracker()->ProactiveNudgeDisabledGlobally();
break;
case compose::ComposeEntryPoint::kSelectionNudge:
compose::LogComposeSelectionNudgeCtr(
compose::ComposeNudgeCtrEvent::kUserDisabledProactiveNudge);
break;
case compose::ComposeEntryPoint::kContextMenu:
case compose::ComposeEntryPoint::kSavedStateNudge:
case compose::ComposeEntryPoint::kSavedStateNotification:
break;
}
if (base::FeatureList::IsEnabled(
compose::features::kHappinessTrackingSurveysForComposeAcceptance)) {
HatsService* hats_service = HatsServiceFactory::GetForProfile(
profile_, /*create_if_necessary*/ true);
if (hats_service) {
hats_service->LaunchSurveyForWebContents(
kHatsSurveyTriggerComposeNudgeClose, web_contents(), {}, {});
}
}
}
void ChromeComposeClient::OpenProactiveNudgeSettings() {
Browser* browser = chrome::FindBrowserWithTab(&GetWebContents());
// `browser` should never be null here. This can only be triggered when there
// is an active ComposeSession, which is indirectly owned by the same
// WebContents that holds the field that the Compose dialog is triggered from.
// The session is created when that dialog is opened and it is destroyed if
// its WebContents is destroyed.
CHECK(browser);
switch (most_recent_nudge_entry_point_) {
case compose::ComposeEntryPoint::kProactiveNudge:
compose::LogComposeProactiveNudgeCtr(
compose::ComposeNudgeCtrEvent::kOpenSettings);
break;
case compose::ComposeEntryPoint::kSelectionNudge:
compose::LogComposeSelectionNudgeCtr(
compose::ComposeNudgeCtrEvent::kOpenSettings);
break;
case compose::ComposeEntryPoint::kContextMenu:
case compose::ComposeEntryPoint::kSavedStateNudge:
case compose::ComposeEntryPoint::kSavedStateNotification:
break;
}
chrome::ShowSettingsSubPage(browser, chrome::kAiHelpMeWriteSubpage);
}
void ChromeComposeClient::AddSiteToNeverPromptList(const url::Origin& origin) {
nudge_tracker_.OnUserDisabledNudge(/*single_site_only=*/true);
ScopedDictPrefUpdate update(pref_service_,
prefs::kProactiveNudgeDisabledSitesWithTime);
update->Set(origin.Serialize(), base::TimeToValue(base::Time::Now()));
switch (most_recent_nudge_entry_point_) {
case compose::ComposeEntryPoint::kProactiveNudge:
compose::LogComposeProactiveNudgeCtr(
compose::ComposeNudgeCtrEvent::kUserDisabledSite);
GetPageUkmTracker()->ProactiveNudgeDisabledForSite();
break;
case compose::ComposeEntryPoint::kSelectionNudge:
compose::LogComposeSelectionNudgeCtr(
compose::ComposeNudgeCtrEvent::kUserDisabledSite);
break;
case compose::ComposeEntryPoint::kContextMenu:
case compose::ComposeEntryPoint::kSavedStateNudge:
case compose::ComposeEntryPoint::kSavedStateNotification:
break;
}
}
bool ChromeComposeClient::ShouldTriggerContextMenu(
content::RenderFrameHost* rfh,
content::ContextMenuParams& params) {
translate::TranslateManager* translate_manager =
ChromeTranslateClient::GetManagerFromWebContents(&GetWebContents());
bool allow_context_menu = compose_enabling_->ShouldTriggerContextMenu(
profile_, translate_manager, rfh, params);
if (allow_context_menu) {
page_ukm_tracker_->MenuItemShown();
}
return allow_context_menu;
}
void ChromeComposeClient::OnSessionComplete(
autofill::FieldGlobalId field_global_id,
compose::ComposeSessionCloseReason close_reason,
const compose::ComposeSessionEvents& events) {
nudge_tracker_.ComposeSessionCompleted(field_global_id, close_reason, events);
}
void ChromeComposeClient::OnAfterFocusOnFormField(
autofill::AutofillManager& manager,
autofill::FormGlobalId form,
autofill::FieldGlobalId field) {
// Reset the `active_compose_ids_` on every focus change. This will be set to
// a valid value when triggering a compose nudge or showing the compose
// dialog.
active_compose_ids_.reset();
}
optimization_guide::OptimizationGuideModelExecutor*
ChromeComposeClient::GetModelExecutor() {
return model_executor_for_test_.value_or(
OptimizationGuideKeyedServiceFactory::GetForProfile(
Profile::FromBrowserContext(GetWebContents().GetBrowserContext())));
}
optimization_guide::ModelQualityLogsUploaderService*
ChromeComposeClient::GetModelQualityLogsUploaderService() {
return logs_uploader_service_for_test_.value_or(
OptimizationGuideKeyedServiceFactory::GetForProfile(
Profile::FromBrowserContext(GetWebContents().GetBrowserContext()))
->GetModelQualityLogsUploaderService());
}
base::Token ChromeComposeClient::GetSessionId() {
return session_id_for_test_.value_or(base::Token::CreateRandom());
}
optimization_guide::OptimizationGuideDecider*
ChromeComposeClient::GetOptimizationGuide() {
return opt_guide_;
}
InnerTextProvider* ChromeComposeClient::GetInnerTextProvider() {
return inner_text_provider_for_test_.value_or(this);
}
void ChromeComposeClient::SetModelExecutorForTest(
optimization_guide::OptimizationGuideModelExecutor* model_executor) {
model_executor_for_test_ = model_executor;
}
void ChromeComposeClient::SetModelQualityLogsUploaderServiceForTest(
optimization_guide::ModelQualityLogsUploaderService*
logs_uploader_service) {
logs_uploader_service_for_test_ = logs_uploader_service;
}
void ChromeComposeClient::SetSkipShowDialogForTest(bool should_skip) {
skip_show_dialog_for_test_ = should_skip;
}
void ChromeComposeClient::SetSessionIdForTest(base::Token session_id) {
session_id_for_test_ = session_id;
}
void ChromeComposeClient::SetInnerTextProviderForTest(
InnerTextProvider* inner_text) {
inner_text_provider_for_test_ = inner_text;
}
bool ChromeComposeClient::IsDialogShowing() {
return compose_dialog_controller_ &&
compose_dialog_controller_->IsDialogShowing();
}
int ChromeComposeClient::GetSessionCountForTest() {
return sessions_.size();
}
void ChromeComposeClient::OpenFeedbackPageForTest(std::string feedback_id) {
ComposeSession* active_session = GetSessionForActiveComposeField();
if (active_session) {
active_session->OpenFeedbackPage(feedback_id);
}
}
void ChromeComposeClient::PrimaryPageChanged(content::Page& page) {
RemoveAllSessions();
page_ukm_tracker_ = std::make_unique<compose::PageUkmTracker>(
page.GetMainDocument().GetPageUkmSourceId());
nudge_tracker_.Clear();
compose::ComposeTextUsageLogger::GetOrCreateForCurrentDocument(
&page.GetMainDocument());
page_change_time_ = base::TimeTicks::Now();
}
void ChromeComposeClient::OnWebContentsFocused(
content::RenderWidgetHost* render_widget_host) {
if (!compose_enabling_->IsEnabledForProfile(profile_)) {
return;
}
ComposeSession* active_session = GetSessionForActiveComposeField();
if (open_settings_requested_) {
open_settings_requested_ = false;
if (active_session && !active_session->get_current_msbb_state() &&
active_compose_ids_.has_value()) {
content::RenderFrameHost* top_level_frame =
GetWebContents().GetPrimaryMainFrame();
if (auto* driver = autofill::ContentAutofillDriver::GetForRenderFrameHost(
top_level_frame)) {
GetManager().OpenCompose(
*driver, active_compose_ids_.value().second,
active_compose_ids_.value().first,
compose::ComposeManagerImpl::UiEntryPoint::kContextMenu);
}
}
}
}
void ChromeComposeClient::DidGetUserInteraction(
const blink::WebInputEvent& event) {
if (IsDialogShowing() &&
event.GetType() == blink::WebInputEvent::Type::kGestureScrollBegin) {
// TODO(b/318571287): Log when the dialog is closed due to scrolling.
compose_dialog_controller_->Close();
}
}
void ChromeComposeClient::OnFocusChangedInPage(
content::FocusedNodeDetails* details) {
// TODO(crbug/337690061): Use Autofill events to track focus change.
return nudge_tracker_.FocusChangedInPage();
}
void ChromeComposeClient::ShowProactiveNudge(
autofill::FormGlobalId form,
autofill::FieldGlobalId field,
compose::ComposeEntryPoint entry_point) {
if (autofill::AutofillDriver* driver =
autofill::ContentAutofillDriver::GetForRenderFrameHost(
GetWebContents().GetPrimaryMainFrame())) {
driver->RendererShouldTriggerSuggestions(
field, autofill::AutofillSuggestionTriggerSource::
kComposeDelayedProactiveNudge);
}
most_recent_nudge_entry_point_ = entry_point;
}
compose::ComposeHintMetadata ChromeComposeClient::GetComposeHintMetadata() {
if (!opt_guide_) {
return compose::ComposeHintMetadata::default_instance();
}
optimization_guide::OptimizationMetadata opt_guide_metadata;
auto opt_guide_has_hint = opt_guide_->CanApplyOptimization(
GetWebContents().GetPrimaryMainFrame()->GetLastCommittedURL(),
optimization_guide::proto::OptimizationType::COMPOSE,
&opt_guide_metadata);
if (opt_guide_has_hint !=
optimization_guide::OptimizationGuideDecision::kTrue) {
return compose::ComposeHintMetadata::default_instance();
}
if (opt_guide_metadata.any_metadata().has_value()) {
std::optional<compose::ComposeHintMetadata> compose_metadata =
optimization_guide::ParsedAnyMetadata<compose::ComposeHintMetadata>(
opt_guide_metadata.any_metadata().value());
if (compose_metadata.has_value()) {
return compose_metadata.value();
}
}
return compose::ComposeHintMetadata::default_instance();
}
WEB_CONTENTS_USER_DATA_KEY_IMPL(ChromeComposeClient);