| // Copyright 2020 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/autofill_assistant/browser/trigger_scripts/trigger_script_coordinator.h" |
| |
| #include <array> |
| #include <map> |
| #include <string> |
| |
| #include "base/numerics/clamped_math.h" |
| #include "components/autofill_assistant/browser/client_context.h" |
| #include "components/autofill_assistant/browser/protocol_utils.h" |
| #include "components/autofill_assistant/browser/url_utils.h" |
| #include "components/ukm/content/source_url_recorder.h" |
| #include "components/version_info/version_info.h" |
| #include "content/public/browser/browser_task_traits.h" |
| #include "content/public/browser/browser_thread.h" |
| #include "content/public/browser/navigation_controller.h" |
| #include "net/http/http_status_code.h" |
| |
| namespace { |
| |
| constexpr std::array<const char*, 5> kWhitelistedScriptParameters = { |
| "DEBUG_BUNDLE_ID", "DEBUG_BUNDLE_VERSION", "DEBUG_SOCKET_ID", |
| "FALLBACK_BUNDLE_ID", "FALLBACK_BUNDLE_VERSION"}; |
| |
| std::map<std::string, std::string> ExtractDebugScriptParameters( |
| const autofill_assistant::TriggerContext& trigger_context) { |
| std::map<std::string, std::string> debug_script_parameters; |
| for (const char* parameter : kWhitelistedScriptParameters) { |
| auto value = trigger_context.GetParameter(parameter); |
| if (value) { |
| debug_script_parameters.insert({parameter, *value}); |
| } |
| } |
| return debug_script_parameters; |
| } |
| |
| } // namespace |
| |
| namespace autofill_assistant { |
| |
| TriggerScriptCoordinator::TriggerScriptCoordinator( |
| Client* client, |
| std::unique_ptr<WebController> web_controller, |
| std::unique_ptr<ServiceRequestSender> request_sender, |
| const GURL& get_trigger_scripts_server, |
| std::unique_ptr<StaticTriggerConditions> static_trigger_conditions, |
| std::unique_ptr<DynamicTriggerConditions> dynamic_trigger_conditions, |
| ukm::UkmRecorder* ukm_recorder) |
| : content::WebContentsObserver(client->GetWebContents()), |
| client_(client), |
| request_sender_(std::move(request_sender)), |
| get_trigger_scripts_server_(get_trigger_scripts_server), |
| web_controller_(std::move(web_controller)), |
| static_trigger_conditions_(std::move(static_trigger_conditions)), |
| dynamic_trigger_conditions_(std::move(dynamic_trigger_conditions)), |
| ukm_recorder_(ukm_recorder) {} |
| |
| TriggerScriptCoordinator::~TriggerScriptCoordinator() = default; |
| |
| void TriggerScriptCoordinator::Start( |
| const GURL& deeplink_url, |
| std::unique_ptr<TriggerContext> trigger_context) { |
| deeplink_url_ = deeplink_url; |
| trigger_context_ = std::make_unique<TriggerContextImpl>( |
| ExtractDebugScriptParameters(*trigger_context), |
| trigger_context->experiment_ids()); |
| ClientContextProto client_context; |
| client_context.mutable_chrome()->set_chrome_version( |
| version_info::GetProductNameAndVersionForUserAgent()); |
| |
| request_sender_->SendRequest( |
| get_trigger_scripts_server_, |
| ProtocolUtils::CreateGetTriggerScriptsRequest( |
| deeplink_url_, client_context, trigger_context_->GetParameters()), |
| base::BindOnce(&TriggerScriptCoordinator::OnGetTriggerScripts, |
| weak_ptr_factory_.GetWeakPtr())); |
| } |
| |
| void TriggerScriptCoordinator::OnGetTriggerScripts( |
| int http_status, |
| const std::string& response) { |
| if (http_status != net::HTTP_OK) { |
| Stop(Metrics::LiteScriptFinishedState::LITE_SCRIPT_GET_ACTIONS_FAILED); |
| return; |
| } |
| |
| trigger_scripts_.clear(); |
| additional_allowed_domains_.clear(); |
| base::Optional<int> timeout_ms; |
| int check_interval_ms; |
| if (!ProtocolUtils::ParseTriggerScripts(response, &trigger_scripts_, |
| &additional_allowed_domains_, |
| &check_interval_ms, &timeout_ms)) { |
| Stop(Metrics::LiteScriptFinishedState::LITE_SCRIPT_GET_ACTIONS_PARSE_ERROR); |
| return; |
| } |
| if (trigger_scripts_.empty()) { |
| Stop(Metrics::LiteScriptFinishedState:: |
| LITE_SCRIPT_NO_TRIGGER_SCRIPT_AVAILABLE); |
| return; |
| } |
| trigger_condition_check_interval_ = |
| base::TimeDelta::FromMilliseconds(check_interval_ms); |
| if (timeout_ms.has_value()) { |
| // Note: add 1 for the initial, not-delayed check. |
| initial_trigger_condition_evaluations_ = |
| 1 + base::ClampCeil<int64_t>( |
| base::TimeDelta::FromMilliseconds(*timeout_ms) / |
| trigger_condition_check_interval_); |
| } else { |
| initial_trigger_condition_evaluations_ = -1; |
| } |
| remaining_trigger_condition_evaluations_ = |
| initial_trigger_condition_evaluations_; |
| |
| Metrics::RecordLiteScriptShownToUser( |
| ukm_recorder_, client_->GetWebContents(), |
| Metrics::LiteScriptShownToUser::LITE_SCRIPT_RUNNING); |
| StartCheckingTriggerConditions(); |
| } |
| |
| void TriggerScriptCoordinator::PerformTriggerScriptAction( |
| TriggerScriptProto::TriggerScriptAction action) { |
| switch (action) { |
| case TriggerScriptProto::NOT_NOW: |
| if (visible_trigger_script_ != -1) { |
| Metrics::RecordLiteScriptShownToUser( |
| ukm_recorder_, client_->GetWebContents(), |
| Metrics::LiteScriptShownToUser::LITE_SCRIPT_NOT_NOW); |
| trigger_scripts_[visible_trigger_script_] |
| ->waiting_for_precondition_no_longer_true(true); |
| HideTriggerScript(); |
| } |
| return; |
| case TriggerScriptProto::CANCEL_SESSION: |
| Stop(Metrics::LiteScriptFinishedState:: |
| LITE_SCRIPT_PROMPT_FAILED_CANCEL_SESSION); |
| return; |
| case TriggerScriptProto::CANCEL_FOREVER: |
| Stop(Metrics::LiteScriptFinishedState:: |
| LITE_SCRIPT_PROMPT_FAILED_CANCEL_FOREVER); |
| return; |
| case TriggerScriptProto::SHOW_CANCEL_POPUP: |
| NOTREACHED(); |
| return; |
| case TriggerScriptProto::ACCEPT: |
| if (visible_trigger_script_ == -1) { |
| NOTREACHED(); |
| return; |
| } |
| // Do not hide the trigger script here, to facilitate a smooth transition |
| // to the regular flow. |
| StopCheckingTriggerConditions(); |
| NotifyOnTriggerScriptFinished( |
| Metrics::LiteScriptFinishedState::LITE_SCRIPT_PROMPT_SUCCEEDED); |
| return; |
| case TriggerScriptProto::UNDEFINED: |
| return; |
| } |
| } |
| |
| void TriggerScriptCoordinator::OnBottomSheetClosedWithSwipe() { |
| if (visible_trigger_script_ == -1) { |
| NOTREACHED(); |
| Stop(Metrics::LiteScriptFinishedState::LITE_SCRIPT_UNKNOWN_FAILURE); |
| return; |
| } |
| Metrics::RecordLiteScriptShownToUser( |
| ukm_recorder_, client_->GetWebContents(), |
| Metrics::LiteScriptShownToUser::LITE_SCRIPT_SWIPE_DISMISSED); |
| PerformTriggerScriptAction(trigger_scripts_[visible_trigger_script_] |
| ->AsProto() |
| .on_swipe_to_dismiss()); |
| } |
| |
| bool TriggerScriptCoordinator::OnBackButtonPressed() { |
| if (visible_trigger_script_ == -1) { |
| return false; |
| } |
| if (client_->GetWebContents()->GetController().CanGoBack()) { |
| client_->GetWebContents()->GetController().GoBack(); |
| } |
| // We need to handle this event, because by default the bottom sheet will |
| // close when the back button is pressed. |
| return true; |
| } |
| |
| void TriggerScriptCoordinator::OnKeyboardVisibilityChanged(bool visible) { |
| dynamic_trigger_conditions_->SetKeyboardVisible(visible); |
| RunOutOfScheduleTriggerConditionCheck(); |
| } |
| |
| void TriggerScriptCoordinator::OnTriggerScriptShown(bool success) { |
| if (!success) { |
| Stop(Metrics::LiteScriptFinishedState::LITE_SCRIPT_FAILED_TO_SHOW); |
| return; |
| } |
| } |
| |
| void TriggerScriptCoordinator::OnProactiveHelpSettingChanged( |
| bool proactive_help_enabled) { |
| if (!proactive_help_enabled) { |
| Stop(Metrics::LiteScriptFinishedState:: |
| LITE_SCRIPT_DISABLED_PROACTIVE_HELP_SETTING); |
| return; |
| } |
| } |
| |
| void TriggerScriptCoordinator::Stop(Metrics::LiteScriptFinishedState state) { |
| HideTriggerScript(); |
| StopCheckingTriggerConditions(); |
| NotifyOnTriggerScriptFinished(state); |
| } |
| |
| void TriggerScriptCoordinator::AddObserver(Observer* observer) { |
| observers_.AddObserver(observer); |
| } |
| |
| void TriggerScriptCoordinator::RemoveObserver(const Observer* observer) { |
| observers_.RemoveObserver(observer); |
| } |
| |
| void TriggerScriptCoordinator::DidFinishNavigation( |
| content::NavigationHandle* navigation_handle) { |
| // Ignore navigation events if any of the following is true: |
| // - not currently checking for preconditions (i.e., not yet started). |
| // - not in the main frame. |
| // - document does not change (e.g., same page history navigation). |
| // - WebContents stays at the existing URL (e.g., downloads). |
| if (!is_checking_trigger_conditions_ || !navigation_handle->IsInMainFrame() || |
| navigation_handle->IsSameDocument() || |
| !navigation_handle->HasCommitted()) { |
| return; |
| } |
| |
| // Chrome has encountered an error and is now displaying an error message |
| // (e.g., network connection lost). This will cancel the current trigger |
| // script session. |
| if (navigation_handle->IsErrorPage()) { |
| Stop(Metrics::LiteScriptFinishedState::LITE_SCRIPT_NAVIGATION_ERROR); |
| return; |
| } |
| |
| // The user has navigated away from the target domain. This will cancel the |
| // current trigger script session. |
| if (!url_utils::IsInDomainOrSubDomain(GetCurrentURL(), deeplink_url_) && |
| !url_utils::IsInDomainOrSubDomain(GetCurrentURL(), |
| additional_allowed_domains_)) { |
| Stop(Metrics::LiteScriptFinishedState::LITE_SCRIPT_PROMPT_FAILED_NAVIGATE); |
| return; |
| } |
| } |
| |
| void TriggerScriptCoordinator::OnVisibilityChanged( |
| content::Visibility visibility) { |
| bool visible = visibility == content::Visibility::VISIBLE; |
| if (web_contents_visible_ == visible) { |
| return; |
| } |
| web_contents_visible_ = visible; |
| OnEffectiveVisibilityChanged(); |
| } |
| |
| void TriggerScriptCoordinator::OnTabInteractabilityChanged(bool interactable) { |
| if (web_contents_interactable_ == interactable) { |
| return; |
| } |
| web_contents_interactable_ = interactable; |
| OnEffectiveVisibilityChanged(); |
| } |
| |
| void TriggerScriptCoordinator::OnEffectiveVisibilityChanged() { |
| bool visible = web_contents_visible_ && web_contents_interactable_; |
| if (visible) { |
| // Restore UI on tab switch. NOTE: an arbitrary amount of time can pass |
| // between tab-hide and tab-show. It is not guaranteed that the trigger |
| // script that was shown before is still available, hence we need to fetch |
| // it again. |
| DCHECK(visible_trigger_script_ == -1); |
| Start(deeplink_url_, std::move(trigger_context_)); |
| } else { |
| // Hide UI on tab switch. |
| StopCheckingTriggerConditions(); |
| HideTriggerScript(); |
| } |
| |
| for (Observer& observer : observers_) { |
| observer.OnVisibilityChanged(visible); |
| } |
| } |
| |
| void TriggerScriptCoordinator::WebContentsDestroyed() { |
| if (!finished_state_recorded_) { |
| Metrics::RecordLiteScriptFinished( |
| ukm_recorder_, client_->GetWebContents(), |
| visible_trigger_script_ == -1 |
| ? Metrics::LiteScriptFinishedState:: |
| LITE_SCRIPT_WEB_CONTENTS_DESTROYED_WHILE_INVISIBLE |
| : Metrics::LiteScriptFinishedState:: |
| LITE_SCRIPT_WEB_CONTENTS_DESTROYED_WHILE_VISIBLE); |
| finished_state_recorded_ = true; |
| } |
| } |
| |
| void TriggerScriptCoordinator::StartCheckingTriggerConditions() { |
| is_checking_trigger_conditions_ = true; |
| dynamic_trigger_conditions_->ClearSelectors(); |
| for (const auto& trigger_script : trigger_scripts_) { |
| dynamic_trigger_conditions_->AddSelectorsFromTriggerScript( |
| trigger_script->AsProto()); |
| } |
| static_trigger_conditions_->Init( |
| client_, deeplink_url_, trigger_context_.get(), |
| base::BindOnce(&TriggerScriptCoordinator::CheckDynamicTriggerConditions, |
| weak_ptr_factory_.GetWeakPtr())); |
| } |
| |
| void TriggerScriptCoordinator::CheckDynamicTriggerConditions() { |
| dynamic_trigger_conditions_->Update( |
| web_controller_.get(), |
| base::BindOnce( |
| &TriggerScriptCoordinator::OnDynamicTriggerConditionsEvaluated, |
| weak_ptr_factory_.GetWeakPtr(), |
| /* is_out_of_schedule = */ false)); |
| } |
| |
| void TriggerScriptCoordinator::StopCheckingTriggerConditions() { |
| is_checking_trigger_conditions_ = false; |
| } |
| |
| void TriggerScriptCoordinator::ShowTriggerScript(int index) { |
| if (visible_trigger_script_ == index) { |
| return; |
| } |
| |
| Metrics::RecordLiteScriptShownToUser( |
| ukm_recorder_, client_->GetWebContents(), |
| Metrics::LiteScriptShownToUser::LITE_SCRIPT_SHOWN_TO_USER); |
| visible_trigger_script_ = index; |
| auto proto = trigger_scripts_[index]->AsProto().user_interface(); |
| for (Observer& observer : observers_) { |
| observer.OnTriggerScriptShown(proto); |
| } |
| } |
| |
| void TriggerScriptCoordinator::HideTriggerScript() { |
| if (visible_trigger_script_ == -1) { |
| return; |
| } |
| |
| // Since the trigger script is now hidden, the timer to track the amount of |
| // time a script was invisible is reset. |
| remaining_trigger_condition_evaluations_ = |
| initial_trigger_condition_evaluations_; |
| static_trigger_conditions_->set_is_first_time_user(false); |
| visible_trigger_script_ = -1; |
| for (Observer& observer : observers_) { |
| observer.OnTriggerScriptHidden(); |
| } |
| } |
| |
| void TriggerScriptCoordinator::OnDynamicTriggerConditionsEvaluated( |
| bool is_out_of_schedule) { |
| if (!web_contents_visible_ || !is_checking_trigger_conditions_) { |
| return; |
| } |
| if (!static_trigger_conditions_->has_results() || |
| !dynamic_trigger_conditions_->HasResults()) { |
| DCHECK(is_out_of_schedule); |
| return; |
| } |
| |
| std::vector<bool> evaluated_trigger_conditions; |
| for (const auto& trigger_script : trigger_scripts_) { |
| evaluated_trigger_conditions.emplace_back( |
| trigger_script->EvaluateTriggerConditions( |
| *static_trigger_conditions_, *dynamic_trigger_conditions_)); |
| } |
| |
| // Trigger condition for the currently shown trigger script is no longer true. |
| if (visible_trigger_script_ != -1 && |
| !evaluated_trigger_conditions[visible_trigger_script_]) { |
| Metrics::RecordLiteScriptShownToUser( |
| ukm_recorder_, client_->GetWebContents(), |
| Metrics::LiteScriptShownToUser:: |
| LITE_SCRIPT_HIDE_ON_TRIGGER_CONDITION_NO_LONGER_TRUE); |
| HideTriggerScript(); |
| // Do not return here: a different trigger script may have become eligible |
| // at the same time. |
| } |
| |
| for (size_t i = 0; i < trigger_scripts_.size(); ++i) { |
| // The currently visible trigger script is still visible, nothing to do. |
| if (visible_trigger_script_ != -1 && |
| i == static_cast<size_t>(visible_trigger_script_) && |
| evaluated_trigger_conditions[i]) { |
| DCHECK(!trigger_scripts_[i]->waiting_for_precondition_no_longer_true()); |
| continue; |
| } |
| |
| // The script was waiting for the precondition to no longer be true. |
| // It can now resume regular precondition checking. |
| if (!evaluated_trigger_conditions[i] && |
| trigger_scripts_[i]->waiting_for_precondition_no_longer_true()) { |
| trigger_scripts_[i]->waiting_for_precondition_no_longer_true(false); |
| continue; |
| } |
| |
| if (evaluated_trigger_conditions[i] && visible_trigger_script_ != -1 && |
| i != static_cast<size_t>(visible_trigger_script_)) { |
| // Should not happen, as trigger script conditions should be mutually |
| // exclusive. If it happens, we just ignore it. This is essentially |
| // first-come-first-serve, prioritizing scripts w.r.t. occurrence in the |
| // proto. |
| continue; |
| } |
| |
| // A new trigger script has become eligible for showing. |
| if (evaluated_trigger_conditions[i] && |
| !trigger_scripts_[i]->waiting_for_precondition_no_longer_true()) { |
| ShowTriggerScript(i); |
| } |
| } |
| |
| if (is_out_of_schedule) { |
| // Out-of-schedule checks do not count towards the timeout. |
| return; |
| } |
| if (visible_trigger_script_ == -1 && |
| remaining_trigger_condition_evaluations_ > 0) { |
| remaining_trigger_condition_evaluations_--; |
| } |
| if (remaining_trigger_condition_evaluations_ == 0) { |
| Stop(Metrics::LiteScriptFinishedState:: |
| LITE_SCRIPT_TRIGGER_CONDITION_TIMEOUT); |
| return; |
| } |
| content::GetUIThreadTaskRunner({})->PostDelayedTask( |
| FROM_HERE, |
| base::BindOnce(&TriggerScriptCoordinator::CheckDynamicTriggerConditions, |
| weak_ptr_factory_.GetWeakPtr()), |
| trigger_condition_check_interval_); |
| } |
| |
| void TriggerScriptCoordinator::RunOutOfScheduleTriggerConditionCheck() { |
| OnDynamicTriggerConditionsEvaluated(/* is_out_of_schedule = */ true); |
| } |
| |
| void TriggerScriptCoordinator::NotifyOnTriggerScriptFinished( |
| Metrics::LiteScriptFinishedState state) { |
| if (!finished_state_recorded_) { |
| finished_state_recorded_ = true; |
| Metrics::RecordLiteScriptFinished(ukm_recorder_, client_->GetWebContents(), |
| state); |
| } |
| |
| for (Observer& observer : observers_) { |
| observer.OnTriggerScriptFinished(state); |
| } |
| } |
| |
| GURL TriggerScriptCoordinator::GetCurrentURL() const { |
| GURL current_url = web_contents()->GetLastCommittedURL(); |
| if (current_url.is_empty()) { |
| return deeplink_url_; |
| } |
| return current_url; |
| } |
| |
| } // namespace autofill_assistant |