blob: 2ec270462702b08e1ac8e42a7ab1fd09fc677926 [file]
// Copyright 2026 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/safe_browsing/android/suspicious_site_controller_android.h"
#include <utility>
#include "base/metrics/histogram_functions.h"
#include "base/no_destructor.h"
#include "base/strings/stringprintf.h"
#include "base/task/single_thread_task_runner.h"
#include "base/types/pass_key.h"
#include "chrome/browser/browser_process.h"
#include "chrome/browser/content_settings/host_content_settings_map_factory.h"
#include "chrome/browser/history/history_service_factory.h"
#include "chrome/browser/profiles/profile.h"
#include "chrome/browser/safe_browsing/android/safe_browsing_referring_app_bridge_android.h"
#include "chrome/browser/safe_browsing/safe_browsing_service.h"
#include "chrome/browser/ui/android/safe_browsing/suspicious_site_dialog_view_android.h"
#include "chrome/browser/ui/hats/hats_service.h"
#include "chrome/browser/ui/hats/hats_service_factory.h"
#include "chrome/browser/ui/hats/survey_config.h"
#include "chrome/common/url_constants.h"
#include "chrome/common/webui_url_constants.h"
#include "components/content_settings/core/browser/host_content_settings_map.h"
#include "components/safe_browsing/content/browser/ui_manager.h"
#include "components/safe_browsing/core/browser/suspicious_site_warning_allowlist.h"
#include "components/safe_browsing/core/common/features.h"
#include "components/security_interstitials/content/security_interstitial_tab_helper.h"
#include "components/strings/grit/components_strings.h"
#include "content/public/browser/navigation_controller.h"
#include "content/public/browser/navigation_entry.h"
#include "content/public/browser/navigation_handle.h"
#include "content/public/browser/web_contents.h"
#include "content/public/common/referrer.h"
#include "ui/android/window_android.h"
#include "ui/base/l10n/l10n_util.h"
#include "ui/base/page_transition_types.h"
#include "ui/base/window_open_disposition.h"
#include "url/gurl.h"
#include "url/origin.h"
namespace safe_browsing {
namespace {
base::OnceClosure* GetShownCallback() {
static base::NoDestructor<base::OnceClosure> callback;
return callback.get();
}
base::OnceClosure* GetDismissedCallback() {
static base::NoDestructor<base::OnceClosure> callback;
return callback.get();
}
const char* UserChoiceToString(
SuspiciousSiteControllerAndroid::UserChoice choice) {
switch (choice) {
case SuspiciousSiteControllerAndroid::UserChoice::kMarkAsSafe:
return "mark_as_safe";
case SuspiciousSiteControllerAndroid::UserChoice::kBackToSafety:
return "back_to_safety";
case SuspiciousSiteControllerAndroid::UserChoice::kDismiss:
return "dismiss";
case SuspiciousSiteControllerAndroid::UserChoice::kManualNavigation:
return "manual_navigation";
}
NOTREACHED();
}
} // namespace
WEB_CONTENTS_USER_DATA_KEY_IMPL(SuspiciousSiteControllerAndroid);
SuspiciousSiteControllerAndroid::SuspiciousSiteControllerAndroid(
content::WebContents* web_contents)
: content::WebContentsObserver(web_contents),
content::WebContentsUserData<SuspiciousSiteControllerAndroid>(
*web_contents) {
CHECK(web_contents->GetPrimaryMainFrame());
}
SuspiciousSiteControllerAndroid::~SuspiciousSiteControllerAndroid() {
// If the dialog was shown, log the tracked warning outcome directly.
if (has_shown_) {
base::UmaHistogramEnumeration(
"SafeBrowsing.SuspiciousSiteWarning.WarningOutcome", warning_outcome_);
}
// Remove the active suspicious site entry from AllowlistUrlSet when this
// controller is destroyed (e.g. when navigating away or closing the tab) so
// that the security state is restored.
if (!current_suspicious_url_.is_empty() && web_contents()) {
SafeBrowsingService* sb_service =
g_browser_process->safe_browsing_service();
if (sb_service && sb_service->ui_manager()) {
sb_service->ui_manager()->RemoveAllowlistUrlSetThreatType(
base::PassKey<SuspiciousSiteControllerAndroid>(),
current_suspicious_url_, navigation_id_, web_contents(),
/*from_pending_only=*/true,
SBThreatType::SB_THREAT_TYPE_WARNABLE_SUSPICIOUS_SITE);
}
}
}
// static
void SuspiciousSiteControllerAndroid::ShowForWebContents(
content::WebContents* web_contents,
const security_interstitials::UnsafeResource& resource) {
if (FromWebContents(web_contents)) {
web_contents->RemoveUserData(UserDataKey());
}
CreateForWebContents(web_contents);
auto* controller = FromWebContents(web_contents);
controller->resource_ = resource;
controller->navigation_id_ = resource.navigation_id;
controller->is_suspended_ = true;
auto* tracker = AsyncCheckTracker::FromWebContents(web_contents);
if (tracker && !controller->async_check_observation_.IsObserving()) {
controller->async_check_observation_.Observe(tracker);
}
controller->MaybeShowDialog();
}
void SuspiciousSiteControllerAndroid::DidFinishNavigation(
content::NavigationHandle* navigation_handle) {
if (!navigation_handle->IsInPrimaryMainFrame() ||
!navigation_handle->HasCommitted() ||
navigation_handle->IsSameDocument()) {
return;
}
// If this navigation failed or if a different navigation completed, clean up.
if (navigation_handle->IsErrorPage() || !navigation_id_.has_value() ||
navigation_handle->GetNavigationId() != navigation_id_.value()) {
// The user navigated away while the warning was showing. The Java modal
// dialog manager normally reports this as DismissalCause::NAVIGATE when the
// navigation starts, but not when the navigation was already in flight
// when the dialog appeared or when the activity has no tab-modal lifetime
// handler, so record the outcome here if it is still undecided.
if (has_shown_ && !is_suspended_ &&
warning_outcome_ == WarningOutcome::kUnknown) {
CloseDialog(ui::ModalDialogWrapper::DismissalCause::NAVIGATE);
}
web_contents()->RemoveUserData(UserDataKey());
return;
}
navigation_committed_ = true;
MaybeShowDialog();
}
void SuspiciousSiteControllerAndroid::OnVisibilityChanged(
content::Visibility visibility) {
if (visibility == content::Visibility::VISIBLE && is_suspended_) {
base::SingleThreadTaskRunner::GetCurrentDefault()->PostTask(
FROM_HERE,
base::BindOnce(&SuspiciousSiteControllerAndroid::MaybeShowDialog,
weak_ptr_factory_.GetWeakPtr()));
} else if (visibility == content::Visibility::HIDDEN) {
is_suspended_ = true;
}
}
void SuspiciousSiteControllerAndroid::OnAsyncSafeBrowsingCheckCompleted() {
MaybeShowDialog();
}
void SuspiciousSiteControllerAndroid::
OnAsyncSafeBrowsingCheckTrackerDestructed() {
async_check_observation_.Reset();
}
void SuspiciousSiteControllerAndroid::MaybeShowDialog() {
if (!web_contents() || !navigation_id_.has_value()) {
return;
}
// 1. Interstitials take precedence: suppress and clean up if an interstitial
// is pending/active.
auto* interstitial_helper =
security_interstitials::SecurityInterstitialTabHelper::FromWebContents(
web_contents());
if (interstitial_helper &&
interstitial_helper->HasPendingOrActiveInterstitial()) {
web_contents()->RemoveUserData(UserDataKey());
return;
}
// 2. Defer if background async Safe Browsing checks are still in-flight.
auto* tracker = AsyncCheckTracker::FromWebContents(web_contents());
if (tracker && tracker->IsNavigationPending(navigation_id_.value())) {
is_suspended_ = true;
return;
}
// 3. Defer if target navigation has not committed yet.
if (!navigation_committed_) {
if (tracker &&
tracker->GetNavigationCommittedTimestamp(navigation_id_.value())
.has_value()) {
navigation_committed_ = true;
} else {
is_suspended_ = true;
return;
}
}
// 4. Suppress and clean up if the site has been allowlisted by the user.
if (web_contents()->GetBrowserContext()) {
Profile* profile =
Profile::FromBrowserContext(web_contents()->GetBrowserContext());
if (profile) {
HostContentSettingsMap* hcsm =
HostContentSettingsMapFactory::GetForProfile(profile);
if (hcsm &&
SuspiciousSiteWarningAllowlist(hcsm).IsSiteAllowedForHost(
std::string(web_contents()->GetLastCommittedURL().host()))) {
web_contents()->RemoveUserData(UserDataKey());
return;
}
}
}
// 5. Defer if native Android window is not attached or web contents is
// hidden.
ui::WindowAndroid* window_android = web_contents()->GetTopLevelNativeWindow();
if (!window_android ||
web_contents()->GetVisibility() != content::Visibility::VISIBLE) {
is_suspended_ = true;
return;
}
is_suspended_ = false;
ShowDialog();
}
void SuspiciousSiteControllerAndroid::ShowDialog() {
if (GetShownCallback() && !GetShownCallback()->is_null()) {
std::move(*GetShownCallback()).Run();
}
// Reset closing state for the current dialog presentation.
is_closing_ = false;
ui::WindowAndroid* window_android = web_contents()->GetTopLevelNativeWindow();
if (!window_android) {
is_suspended_ = true;
return;
}
dialog_shown_time_ = base::TimeTicks::Now();
SafeBrowsingService* sb_service = g_browser_process->safe_browsing_service();
if (sb_service && sb_service->ui_manager()) {
// If a previous suspicious site URL was registered for this tab, clear it
// first before setting the new one.
if (!current_suspicious_url_.is_empty()) {
sb_service->ui_manager()->RemoveAllowlistUrlSetThreatType(
base::PassKey<SuspiciousSiteControllerAndroid>(),
current_suspicious_url_, navigation_id_, web_contents(),
/*from_pending_only=*/true,
SBThreatType::SB_THREAT_TYPE_WARNABLE_SUSPICIOUS_SITE);
}
}
// Populate current_suspicious_url_ and current_referrer_url_ for the active
// warning session.
current_suspicious_url_ = web_contents()->GetLastCommittedURL();
if (auto* entry = web_contents()->GetController().GetLastCommittedEntry()) {
current_referrer_url_ = entry->GetReferrer().url;
}
// Pre-fetch repeat visit count right after populating
// current_suspicious_url_.
FetchRepeatVisitCount();
if (sb_service && sb_service->ui_manager()) {
// Add the suspicious site URL to AllowlistUrlSet with pending=true.
// AllowlistUrlSet stores the threat type for WebContents, enabling
// the chrome_security_state computation to return
// MALICIOUS_CONTENT_STATUS_WARNABLE_SUSPICIOUS_SITE so the red warning icon
// remains active in the Omnibox and Page Info even if the dialog is closed.
sb_service->ui_manager()->AddToAllowlistUrlSet(
current_suspicious_url_, navigation_id_, web_contents(),
/*is_pending=*/true,
SBThreatType::SB_THREAT_TYPE_WARNABLE_SUSPICIOUS_SITE);
}
dialog_view_.reset();
dialog_view_ = std::make_unique<SuspiciousSiteDialogViewAndroid>(*this);
// TODO(crbug.com/532598569): Investigate if destroying an existing dialog,
// creating a new one, and displaying it causes UI flicker.
dialog_view_->Show(*window_android);
if (!has_shown_) {
sb_service->ui_manager()->CreateAndSendClientSafeBrowsingWarningShownReport(
resource_.value());
base::UmaHistogramEnumeration(
"SafeBrowsing.SuspiciousSiteWarning.UserInteraction",
UserInteraction::kShown);
has_shown_ = true;
}
}
void SuspiciousSiteControllerAndroid::CloseDialog(
ui::ModalDialogWrapper::DismissalCause dismissal_cause) {
if (is_closing_) {
return;
}
// Prevent false telemetry/HaTS triggers from phantom interactions while
// hidden, but allow lifecycle and terminal system events to properly update
// state and clean up backgrounded tabs.
if (is_suspended_ &&
dismissal_cause != ui::ModalDialogWrapper::DismissalCause::TAB_SWITCHED &&
dismissal_cause !=
ui::ModalDialogWrapper::DismissalCause::ACTIVITY_DESTROYED &&
dismissal_cause !=
ui::ModalDialogWrapper::DismissalCause::DIALOG_INTERACTION_DEFERRED &&
dismissal_cause !=
ui::ModalDialogWrapper::DismissalCause::TAB_DESTROYED &&
dismissal_cause !=
ui::ModalDialogWrapper::DismissalCause::WEB_CONTENTS_DESTROYED) {
return;
}
if (GetDismissedCallback() && !GetDismissedCallback()->is_null()) {
std::move(*GetDismissedCallback()).Run();
}
switch (dismissal_cause) {
case ui::ModalDialogWrapper::DismissalCause::NAVIGATE_BACK:
case ui::ModalDialogWrapper::DismissalCause::NAVIGATE_BACK_OR_TOUCH_OUTSIDE:
HandleBackNavigation(UserInteraction::kSystemBack);
return;
case ui::ModalDialogWrapper::DismissalCause::NAVIGATE:
is_closing_ = true;
warning_outcome_ = WarningOutcome::kAdhered;
base::UmaHistogramEnumeration(
"SafeBrowsing.SuspiciousSiteWarning.UserInteraction",
UserInteraction::kManualNavigation);
MaybeTriggerHatsSurvey(UserChoice::kManualNavigation);
is_suspended_ = true;
break;
case ui::ModalDialogWrapper::DismissalCause::TAB_SWITCHED:
case ui::ModalDialogWrapper::DismissalCause::DIALOG_INTERACTION_DEFERRED:
case ui::ModalDialogWrapper::DismissalCause::ACTIVITY_DESTROYED:
if (!dialog_shown_time_.is_null()) {
dialog_state_.accumulated_visible_time +=
base::TimeTicks::Now() - dialog_shown_time_;
dialog_shown_time_ = base::TimeTicks();
}
is_suspended_ = true;
break;
case ui::ModalDialogWrapper::DismissalCause::TAB_DESTROYED:
case ui::ModalDialogWrapper::DismissalCause::WEB_CONTENTS_DESTROYED:
is_closing_ = true;
warning_outcome_ = WarningOutcome::kAdhered;
base::UmaHistogramEnumeration(
"SafeBrowsing.SuspiciousSiteWarning.UserInteraction",
UserInteraction::kCloseTab);
is_suspended_ = true;
break;
case ui::ModalDialogWrapper::DismissalCause::TOUCH_OUTSIDE:
case ui::ModalDialogWrapper::DismissalCause::ACTION_ON_CONTENT:
is_closing_ = true;
warning_outcome_ = WarningOutcome::kBypassed;
base::UmaHistogramEnumeration(
"SafeBrowsing.SuspiciousSiteWarning.UserInteraction",
UserInteraction::kDismissed);
MaybeTriggerHatsSurvey(UserChoice::kDismiss);
is_suspended_ = false;
break;
default:
is_closing_ = true;
is_suspended_ = false;
break;
}
dialog_view_.reset();
is_closing_ = false;
}
void SuspiciousSiteControllerAndroid::HandleBackNavigation(
UserInteraction interaction_type) {
if (is_closing_) {
return;
}
is_closing_ = true;
has_shown_ = true;
warning_outcome_ = WarningOutcome::kAdhered;
dialog_view_.reset();
base::UmaHistogramEnumeration(
"SafeBrowsing.SuspiciousSiteWarning.UserInteraction", interaction_type);
MaybeTriggerHatsSurvey(UserChoice::kBackToSafety);
content::WebContents* contents = web_contents();
CHECK(contents);
auto& controller = contents->GetController();
if (controller.CanGoBack()) {
controller.GoBack();
} else {
controller.LoadURLWithParams(content::NavigationController::LoadURLParams(
GURL(chrome::kChromeUINewTabURL)));
}
// NOTE: Calling RemoveUserData synchronously destroys this object, so there
// must be no member accesses after this point.
contents->RemoveUserData(UserDataKey());
}
void SuspiciousSiteControllerAndroid::OnContinueButtonClicked() {
if (is_closing_) {
return;
}
is_closing_ = true;
has_shown_ = true;
warning_outcome_ = WarningOutcome::kBypassed;
dialog_view_.reset();
base::UmaHistogramEnumeration(
"SafeBrowsing.SuspiciousSiteWarning.UserInteraction",
UserInteraction::kMarkAsSafe);
MaybeTriggerHatsSurvey(UserChoice::kMarkAsSafe);
if (web_contents()->GetBrowserContext()) {
Profile* profile =
Profile::FromBrowserContext(web_contents()->GetBrowserContext());
if (profile) {
HostContentSettingsMap* hcsm =
HostContentSettingsMapFactory::GetForProfile(profile);
if (hcsm) {
SuspiciousSiteWarningAllowlist(hcsm).AllowSiteForHost(
std::string(web_contents()->GetLastCommittedURL().host()));
}
}
}
// NOTE: Calling RemoveUserData synchronously destroys this object, so there
// must be no member accesses after this point.
web_contents()->RemoveUserData(UserDataKey());
}
void SuspiciousSiteControllerAndroid::FetchRepeatVisitCount() {
if (dialog_state_.has_fetched_history || !web_contents() ||
!web_contents()->GetBrowserContext()) {
return;
}
GURL site_url = web_contents()->GetLastCommittedURL();
if (!site_url.is_valid()) {
return;
}
dialog_state_.has_fetched_history = true;
Profile* profile =
Profile::FromBrowserContext(web_contents()->GetBrowserContext());
if (!profile) {
return;
}
// Use IMPLICIT_ACCESS because this history query is an automated background
// feature check for survey telemetry rather than a direct user interaction
// with the History UI.
history::HistoryService* history_service =
HistoryServiceFactory::GetForProfile(profile,
ServiceAccessType::IMPLICIT_ACCESS);
if (history_service) {
history_service->GetVisibleVisitCountToHost(
site_url,
base::BindOnce(&SuspiciousSiteControllerAndroid::OnGetVisibleVisitCount,
weak_ptr_factory_.GetWeakPtr()),
&history_task_tracker_);
}
}
void SuspiciousSiteControllerAndroid::OnGetVisibleVisitCount(
history::VisibleVisitCountToHostResult result) {
if (result.success && result.count > 1) {
dialog_state_.repeat_visit = true;
}
}
void SuspiciousSiteControllerAndroid::MaybeTriggerHatsSurvey(
UserChoice user_choice) {
if (!base::FeatureList::IsEnabled(
safe_browsing::kSuspiciousSiteWarningSurvey)) {
return;
}
if (!web_contents() || !web_contents()->GetBrowserContext()) {
return;
}
Profile* profile =
Profile::FromBrowserContext(web_contents()->GetBrowserContext());
if (!profile) {
return;
}
HatsService* hats_service =
HatsServiceFactory::GetForProfile(profile, /*create_if_necessary=*/true);
if (!hats_service) {
return;
}
base::TimeDelta total_visible_time = dialog_state_.accumulated_visible_time;
if (!dialog_shown_time_.is_null()) {
total_visible_time += base::TimeTicks::Now() - dialog_shown_time_;
}
std::string visible_time_str =
base::StringPrintf("%.2f", total_visible_time.InSecondsF());
std::string referring_app;
internal::ReferringAppInfo referring_app_info =
GetReferringAppInfo(web_contents(), /*get_webapk_info=*/false);
if (referring_app_info.has_referring_app()) {
referring_app = referring_app_info.referring_app_name;
}
std::string referrer_origin;
if (current_referrer_url_.is_valid()) {
url::Origin origin = url::Origin::Create(current_referrer_url_);
if (!origin.opaque()) {
referrer_origin = origin.Serialize();
}
}
std::string site_origin =
current_suspicious_url_.is_valid()
? url::Origin::Create(current_suspicious_url_).Serialize()
: "";
SurveyBitsData bits_data = {
{"did_proceed", warning_outcome_ == WarningOutcome::kBypassed},
{"learn_more_clicked", dialog_state_.learn_more_clicked},
{"repeat_visit", dialog_state_.repeat_visit}};
SurveyStringData string_data = {
{"site_origin", site_origin},
{"user_choice", UserChoiceToString(user_choice)},
{"time_prompt_visible", visible_time_str},
{"referrer_origin", referrer_origin},
{"referring_app", referring_app}};
std::string trigger_id =
warning_outcome_ == WarningOutcome::kBypassed
? safe_browsing::kSuspiciousSiteWarningSurveyProceedTriggerId.Get()
: safe_browsing::kSuspiciousSiteWarningSurveyHeedTriggerId.Get();
HatsService::SurveyOptions survey_options(
/*custom_invitation=*/
l10n_util::GetStringUTF16(IDS_SAFE_BROWSING_HATS_CUSTOM_INVITATION));
hats_service->LaunchSurveyForWebContents(
kHatsSurveyTriggerSuspiciousSiteWarning, web_contents(), bits_data,
string_data, /*success_callback=*/base::DoNothing(),
/*failure_callback=*/base::DoNothing(),
trigger_id.empty() ? std::nullopt : std::make_optional(trigger_id),
survey_options);
}
void SuspiciousSiteControllerAndroid::OnHelpCenterLinkClicked() {
if (!dialog_state_.learn_more_clicked) {
dialog_state_.learn_more_clicked = true;
base::UmaHistogramEnumeration(
"SafeBrowsing.SuspiciousSiteWarning.UserInteraction",
UserInteraction::kLearnMore);
}
content::WebContents* contents = web_contents();
CHECK(contents);
content::OpenURLParams params =
content::OpenURLParams::CreateBrowserInitiated(
GURL(chrome::kUnsafeSiteWarningHelpCenterURL),
WindowOpenDisposition::NEW_FOREGROUND_TAB, ui::PAGE_TRANSITION_LINK);
contents->OpenURL(params, /*navigation_handle_callback=*/{});
}
std::u16string SuspiciousSiteControllerAndroid::GetPrimaryButtonText() const {
return l10n_util::GetStringUTF16(IDS_SUSPICIOUS_SITE_BACK_TO_SAFETY);
}
std::u16string SuspiciousSiteControllerAndroid::GetSecondaryButtonText() const {
return l10n_util::GetStringUTF16(IDS_SUSPICIOUS_SITE_PROCEED_BUTTON);
}
std::u16string SuspiciousSiteControllerAndroid::GetTitle() const {
return l10n_util::GetStringUTF16(IDS_SUSPICIOUS_SITE_TITLE);
}
std::u16string SuspiciousSiteControllerAndroid::GetWarningDetailText() const {
return l10n_util::GetStringUTF16(IDS_SUSPICIOUS_SITE_MESSAGE);
}
// static
void SuspiciousSiteControllerAndroid::SetDialogShownCallbackForTesting(
base::OnceClosure callback) {
*GetShownCallback() = std::move(callback);
}
// static
void SuspiciousSiteControllerAndroid::SetDialogDismissedCallbackForTesting(
base::OnceClosure callback) {
*GetDismissedCallback() = std::move(callback);
}
} // namespace safe_browsing