blob: 0f2153037ce76314f7770ac0dd647a6f345b04cf [file] [log] [blame]
// Copyright 2024 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/download/download_warning_desktop_hats_utils.h"
#include <algorithm>
#include <cstdint>
#include <iterator>
#include <string>
#include <vector>
#include "base/containers/contains.h"
#include "base/feature_list.h"
#include "base/functional/bind.h"
#include "base/strings/strcat.h"
#include "base/strings/string_number_conversions.h"
#include "base/strings/string_util.h"
#include "base/strings/utf_string_conversions.h"
#include "base/task/sequenced_task_runner.h"
#include "base/time/time.h"
#include "base/version_info/channel.h"
#include "build/build_config.h"
#include "chrome/browser/download/download_item_model.h"
#include "chrome/browser/download/download_item_warning_data.h"
#include "chrome/browser/profiles/profile.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/channel_info.h"
#include "chrome/common/pref_names.h"
#include "components/download/public/common/download_item.h"
#include "components/prefs/pref_service.h"
#include "components/safe_browsing/buildflags.h"
#include "components/safe_browsing/core/common/features.h"
#include "components/safe_browsing/core/common/safe_browsing_prefs.h"
#include "content/public/browser/download_item_utils.h"
namespace {
// Placeholder strings for fields that are not logged.
constexpr char kNotAvailable[] = "Not available";
constexpr char kNotLoggedNoSafeBrowsing[] =
"Not logged because Safe Browsing is off";
constexpr char kNotLoggedNoEnhancedProtection[] =
"Not logged because Enhanced Protection is off";
bool IsDownloadBubbleTrigger(DownloadWarningHatsType type) {
return type == DownloadWarningHatsType::kDownloadBubbleBypass ||
type == DownloadWarningHatsType::kDownloadBubbleHeed ||
type == DownloadWarningHatsType::kDownloadBubbleIgnore;
}
bool IsDownloadsPageTrigger(DownloadWarningHatsType type) {
return !IsDownloadBubbleTrigger(type);
}
std::string GetOutcomeStringData(DownloadWarningHatsType type) {
switch (type) {
case DownloadWarningHatsType::kDownloadBubbleBypass:
case DownloadWarningHatsType::kDownloadsPageBypass:
return "Bypassed warning";
case DownloadWarningHatsType::kDownloadBubbleHeed:
case DownloadWarningHatsType::kDownloadsPageHeed:
return "Heeded warning";
case DownloadWarningHatsType::kDownloadBubbleIgnore:
case DownloadWarningHatsType::kDownloadsPageIgnore:
return "Ignored warning";
}
}
std::string GetSurfaceStringData(DownloadWarningHatsType type) {
return IsDownloadBubbleTrigger(type) ? "Download bubble" : "Downloads page";
}
std::string GetDangerTypeStringData(const DownloadItemModel& model) {
std::string danger_type_string =
GetDownloadDangerTypeString(model.GetDangerType());
switch (model.GetTailoredWarningType()) {
case DownloadUIModel::TailoredWarningType::kNoTailoredWarning:
break;
case DownloadUIModel::TailoredWarningType::kCookieTheft:
base::StrAppend(&danger_type_string, {", Cookie theft"});
break;
case DownloadUIModel::TailoredWarningType::kSuspiciousArchive:
base::StrAppend(&danger_type_string, {", Suspicious archive"});
break;
}
return danger_type_string;
}
std::string GetWarningTypeStringData(const DownloadUIModel& model) {
switch (model.GetDangerUiPattern()) {
case DownloadUIModel::DangerUiPattern::kNormal:
case DownloadUIModel::DangerUiPattern::kOther:
return "None";
case DownloadUIModel::DangerUiPattern::kDangerous:
return "Dangerous";
case DownloadUIModel::DangerUiPattern::kSuspicious:
return "Suspicious";
}
}
std::string ElapsedTimeToSecondsString(base::TimeDelta elapsed_time) {
return base::NumberToString(elapsed_time.InSeconds());
}
std::string SafeBrowsingStateToString(
safe_browsing::SafeBrowsingState sb_state) {
switch (sb_state) {
case safe_browsing::SafeBrowsingState::NO_SAFE_BROWSING:
return "No Safe Browsing";
case safe_browsing::SafeBrowsingState::STANDARD_PROTECTION:
return "Standard Protection";
case safe_browsing::SafeBrowsingState::ENHANCED_PROTECTION:
return "Enhanced Protection";
}
}
// Produces a string consisting of comma-separated action events, each of which
// consists of the surface, action, and relative timestamp (ms) separated by
// colons. The first SHOWN event is included, and is the basis of all
// timestamps, however subsequent SHOWN events are not included.
std::string SerializeWarningActionEvents(
DownloadItemWarningData::WarningSurface warning_first_shown_surface,
const std::vector<DownloadItemWarningData::WarningActionEvent>& events) {
// The first SHOWN event is not stored by DownloadItemWarningData, so we
// construct it here.
std::string first_event_string =
DownloadItemWarningData::WarningActionEvent{
warning_first_shown_surface,
DownloadItemWarningData::WarningAction::SHOWN, 0,
/*is_terminal_action=*/false}
.ToString();
std::vector<std::string> event_strings;
event_strings.reserve(events.size() + 1);
event_strings.push_back(std::move(first_event_string));
std::ranges::transform(
events.begin(), events.end(), std::back_inserter(event_strings),
[](const DownloadItemWarningData::WarningActionEvent& event) {
return event.ToString();
});
return base::JoinString(std::move(event_strings), ",");
}
} // namespace
DownloadWarningHatsProductSpecificData::DownloadWarningHatsProductSpecificData(
DownloadWarningHatsType survey_type)
: survey_type_(survey_type) {}
DownloadWarningHatsProductSpecificData::DownloadWarningHatsProductSpecificData(
const DownloadWarningHatsProductSpecificData&) = default;
DownloadWarningHatsProductSpecificData&
DownloadWarningHatsProductSpecificData::operator=(
const DownloadWarningHatsProductSpecificData&) = default;
DownloadWarningHatsProductSpecificData::DownloadWarningHatsProductSpecificData(
DownloadWarningHatsProductSpecificData&&) = default;
DownloadWarningHatsProductSpecificData&
DownloadWarningHatsProductSpecificData::operator=(
DownloadWarningHatsProductSpecificData&&) = default;
DownloadWarningHatsProductSpecificData::
~DownloadWarningHatsProductSpecificData() = default;
// static
DownloadWarningHatsProductSpecificData
DownloadWarningHatsProductSpecificData::Create(
DownloadWarningHatsType survey_type,
download::DownloadItem* download_item) {
CHECK(download_item);
CHECK(CanShowDownloadWarningHatsSurvey(download_item));
DownloadWarningHatsProductSpecificData psd{survey_type};
// Add placeholders for the fields that must be added later, to avoid CHECKing
// even if they are forgotten.
if (IsDownloadBubbleTrigger(survey_type)) {
psd.bits_data_.insert({Fields::kPartialViewInteraction, false});
}
if (IsDownloadsPageTrigger(survey_type)) {
psd.string_data_.insert({Fields::kNumPageWarnings, kNotAvailable});
}
psd.string_data_.insert(
{Fields::kChannel,
std::string(version_info::GetChannelString(chrome::GetChannel()))});
psd.string_data_.insert(
{Fields::kOutcome, GetOutcomeStringData(survey_type)});
psd.string_data_.insert(
{Fields::kSurface, GetSurfaceStringData(survey_type)});
psd.string_data_.insert(
{Fields::kSecondsSinceDownloadStarted,
ElapsedTimeToSecondsString(base::Time::Now() -
download_item->GetStartTime())});
base::Time warning_shown_time =
DownloadItemWarningData::WarningFirstShownTime(download_item);
if (!warning_shown_time.is_null()) {
psd.string_data_.insert(
{Fields::kSecondsSinceWarningShown,
ElapsedTimeToSecondsString(base::Time::Now() - warning_shown_time)});
} else {
psd.string_data_.insert({Fields::kSecondsSinceWarningShown, kNotAvailable});
}
psd.bits_data_.insert(
{Fields::kUserGesture, download_item->HasUserGesture()});
// This won't be used for generating any strings, so it's ok not to use the
// correct StatusTextBuilder.
DownloadItemModel download_model(download_item);
psd.string_data_.insert(
{Fields::kDangerType, GetDangerTypeStringData(download_model)});
psd.string_data_.insert(
{Fields::kWarningType, GetWarningTypeStringData(download_model)});
if (survey_type == DownloadWarningHatsType::kDownloadBubbleIgnore) {
psd.string_data_.insert(
{Fields::kIgnoreTimeoutSeconds,
ElapsedTimeToSecondsString(GetIgnoreDownloadBubbleWarningDelay())});
}
// Assemble the Profile-dependent PSD.
Profile* profile = Profile::FromBrowserContext(
content::DownloadItemUtils::GetBrowserContext(download_item));
if (!profile) {
psd.string_data_.insert({Fields::kSafeBrowsingState, kNotAvailable});
psd.string_data_.insert({Fields::kPartialViewEnabled, kNotAvailable});
psd.string_data_.insert({Fields::kUrlDownload, kNotAvailable});
psd.string_data_.insert({Fields::kUrlReferrer, kNotAvailable});
psd.string_data_.insert({Fields::kFilename, kNotAvailable});
psd.string_data_.insert({Fields::kWarningInteractions, kNotAvailable});
return psd;
}
psd.string_data_.insert(
{Fields::kSafeBrowsingState,
SafeBrowsingStateToString(
safe_browsing::GetSafeBrowsingState(*profile->GetPrefs()))});
psd.bits_data_.insert({Fields::kPartialViewEnabled,
profile->GetPrefs()->GetBoolean(
prefs::kDownloadBubblePartialViewEnabled)});
// URL and filename logged only for Safe Browsing users.
if (safe_browsing::IsSafeBrowsingEnabled(*profile->GetPrefs())) {
psd.string_data_.insert({Fields::kUrlDownload,
download_item->GetURL().possibly_invalid_spec()});
psd.string_data_.insert(
{Fields::kUrlReferrer,
download_item->GetReferrerUrl().possibly_invalid_spec()});
psd.string_data_.insert(
{Fields::kFilename,
base::UTF16ToUTF8(
download_item->GetFileNameToReportUser().LossyDisplayName())});
} else {
psd.string_data_.insert({Fields::kUrlDownload, kNotLoggedNoSafeBrowsing});
psd.string_data_.insert({Fields::kUrlReferrer, kNotLoggedNoSafeBrowsing});
psd.string_data_.insert({Fields::kFilename, kNotLoggedNoSafeBrowsing});
}
// Interaction details logged only for ESB users.
std::optional<DownloadItemWarningData::WarningSurface>
warning_first_shown_surface =
DownloadItemWarningData::WarningFirstShownSurface(download_item);
if (warning_first_shown_surface &&
safe_browsing::IsEnhancedProtectionEnabled(*profile->GetPrefs())) {
std::vector<DownloadItemWarningData::WarningActionEvent>
warning_action_events =
DownloadItemWarningData::GetWarningActionEvents(download_item);
psd.string_data_.insert(
{Fields::kWarningInteractions,
SerializeWarningActionEvents(*warning_first_shown_surface,
warning_action_events)});
} else {
psd.string_data_.insert(
{Fields::kWarningInteractions, kNotLoggedNoEnhancedProtection});
}
return psd;
}
void DownloadWarningHatsProductSpecificData::AddNumPageWarnings(int num) {
if (IsDownloadsPageTrigger(survey_type_)) {
string_data_.insert_or_assign(Fields::kNumPageWarnings,
base::NumberToString(num));
}
}
void DownloadWarningHatsProductSpecificData::AddPartialViewInteraction(
bool partial_view_interaction) {
if (IsDownloadBubbleTrigger(survey_type_)) {
bits_data_.insert_or_assign(Fields::kPartialViewInteraction,
partial_view_interaction);
}
}
// static
std::vector<std::string>
DownloadWarningHatsProductSpecificData::GetBitsDataFields(
DownloadWarningHatsType survey_type) {
std::vector<std::string> fields = {Fields::kPartialViewEnabled,
Fields::kUserGesture};
if (IsDownloadBubbleTrigger(survey_type)) {
fields.push_back(Fields::kPartialViewInteraction);
}
return fields;
}
// static
std::vector<std::string>
DownloadWarningHatsProductSpecificData::GetStringDataFields(
DownloadWarningHatsType survey_type) {
std::vector<std::string> fields = {
Fields::kOutcome,
Fields::kSurface,
Fields::kDangerType,
Fields::kWarningType,
Fields::kSafeBrowsingState,
Fields::kChannel,
Fields::kWarningInteractions,
Fields::kSecondsSinceDownloadStarted,
Fields::kSecondsSinceWarningShown,
Fields::kUrlDownload,
Fields::kUrlReferrer,
Fields::kFilename,
// TODO(chlily): Add kIgnoreTimeout.
};
if (IsDownloadsPageTrigger(survey_type)) {
fields.push_back(Fields::kNumPageWarnings);
}
if (survey_type == DownloadWarningHatsType::kDownloadBubbleIgnore) {
fields.push_back(Fields::kIgnoreTimeoutSeconds);
}
return fields;
}
DelayedDownloadWarningHatsLauncher::Task::Task(
DelayedDownloadWarningHatsLauncher& hats_launcher,
download::DownloadItem* download,
base::OnceClosure task,
base::TimeDelta delay)
: observation_(&hats_launcher), task_(std::move(task)) {
observation_.Observe(download);
base::SequencedTaskRunner::GetCurrentDefault()->PostDelayedTask(
FROM_HERE, base::BindOnce(&Task::RunTask, weak_factory_.GetWeakPtr()),
delay);
}
DelayedDownloadWarningHatsLauncher::Task::~Task() = default;
// It is expected that the caller will delete this after this executes.
void DelayedDownloadWarningHatsLauncher::Task::RunTask() {
CHECK(task_);
std::move(task_).Run();
}
DelayedDownloadWarningHatsLauncher::DelayedDownloadWarningHatsLauncher(
Profile* profile,
base::TimeDelta delay,
PsdCompleter psd_completer)
: profile_(profile),
delay_(delay),
psd_completer_(std::move(psd_completer)) {}
DelayedDownloadWarningHatsLauncher::~DelayedDownloadWarningHatsLauncher() =
default;
void DelayedDownloadWarningHatsLauncher::OnDownloadUpdated(
download::DownloadItem* download) {
// If the formerly-eligible download is no longer eligible, cancel the survey.
if (!CanShowDownloadWarningHatsSurvey(download)) {
RemoveTaskIfAny(download);
}
}
void DelayedDownloadWarningHatsLauncher::OnDownloadDestroyed(
download::DownloadItem* download) {
RemoveTaskIfAny(download);
}
void DelayedDownloadWarningHatsLauncher::RecordBrowserActivity() {
last_activity_ = base::Time::Now();
}
bool DelayedDownloadWarningHatsLauncher::TryScheduleTask(
DownloadWarningHatsType survey_type,
download::DownloadItem* download) {
CHECK(download);
TaskKey key = GetTaskKey(download);
if (base::Contains(tasks_, key)) {
return false;
}
if (!CanShowDownloadWarningHatsSurvey(download)) {
return false;
}
auto [_, inserted] = tasks_.try_emplace(
key, *this, download,
// Unretained is safe because `this` outlives the Task object.
// `download` will be valid for as long as the Task lives, because the
// Observer mechanism will delete the Task if `download` goes away.
base::BindOnce(&DelayedDownloadWarningHatsLauncher::MaybeLaunchSurveyNow,
base::Unretained(this), survey_type, download),
delay_);
return inserted;
}
void DelayedDownloadWarningHatsLauncher::RemoveTaskIfAny(
download::DownloadItem* download) {
RemoveTaskByKeyIfAny(GetTaskKey(download));
}
DelayedDownloadWarningHatsLauncher::TaskKey
DelayedDownloadWarningHatsLauncher::GetTaskKey(
download::DownloadItem* download) {
return reinterpret_cast<TaskKey>(download);
}
void DelayedDownloadWarningHatsLauncher::RemoveTaskByKeyIfAny(TaskKey key) {
tasks_.erase(key);
}
void DelayedDownloadWarningHatsLauncher::MaybeLaunchSurveyNow(
DownloadWarningHatsType survey_type,
download::DownloadItem* download) {
if (!CanShowDownloadWarningHatsSurvey(download) || !WasUserActive()) {
RemoveTaskIfAny(download);
return;
}
auto psd =
DownloadWarningHatsProductSpecificData::Create(survey_type, download);
if (psd_completer_) {
psd_completer_.Run(psd);
}
MaybeLaunchDownloadWarningHatsSurvey(profile_, psd,
MakeSurveyDoneCallback(download),
MakeSurveyDoneCallback(download));
}
base::OnceClosure DelayedDownloadWarningHatsLauncher::MakeSurveyDoneCallback(
download::DownloadItem* download) {
// This is needed to clean up the Task object after the survey runs. It must
// be bound to a WeakPtr because nothing guarantees that this will be alive
// when the survey task finishes (it generally takes a few seconds to actually
// show the survey, and obviously takes much longer for the user to work
// through the survey). If this callback runs, then `this` must still be
// alive, which means the DownloadItem::Observer mechanism is maintaining the
// invariant that any download with an entry in `tasks_` must be alive.
// Therefore, the DownloadItem* will not be used after it is freed.
return base::BindOnce(
&DelayedDownloadWarningHatsLauncher::RemoveTaskByKeyIfAny,
weak_factory_.GetWeakPtr(), GetTaskKey(download));
}
bool DelayedDownloadWarningHatsLauncher::WasUserActive() const {
return base::Time::Now() - last_activity_ <= delay_;
}
bool CanShowDownloadWarningHatsSurvey(download::DownloadItem* download) {
CHECK(download);
return download->IsDangerous() && !download->IsDone();
}
std::optional<std::string> MaybeGetDownloadWarningHatsTrigger(
DownloadWarningHatsType survey_type) {
if (!base::FeatureList::IsEnabled(safe_browsing::kDownloadWarningSurvey)) {
return std::nullopt;
}
const int eligible_survey_type =
safe_browsing::kDownloadWarningSurveyType.Get();
// Configuration error.
if (eligible_survey_type < 0 ||
eligible_survey_type >
static_cast<int>(DownloadWarningHatsType::kMaxValue)) {
return std::nullopt;
}
// User is not assigned to be eligible for this type.
if (static_cast<DownloadWarningHatsType>(eligible_survey_type) !=
survey_type) {
return std::nullopt;
}
switch (survey_type) {
case DownloadWarningHatsType::kDownloadBubbleBypass:
return kHatsSurveyTriggerDownloadWarningBubbleBypass;
case DownloadWarningHatsType::kDownloadBubbleHeed:
return kHatsSurveyTriggerDownloadWarningBubbleHeed;
case DownloadWarningHatsType::kDownloadBubbleIgnore:
return kHatsSurveyTriggerDownloadWarningBubbleIgnore;
case DownloadWarningHatsType::kDownloadsPageBypass:
return kHatsSurveyTriggerDownloadWarningPageBypass;
case DownloadWarningHatsType::kDownloadsPageHeed:
return kHatsSurveyTriggerDownloadWarningPageHeed;
case DownloadWarningHatsType::kDownloadsPageIgnore:
return kHatsSurveyTriggerDownloadWarningPageIgnore;
}
}
base::TimeDelta GetIgnoreDownloadBubbleWarningDelay() {
return base::Seconds(
safe_browsing::kDownloadWarningSurveyIgnoreDelaySeconds.Get());
}
void MaybeLaunchDownloadWarningHatsSurvey(
Profile* profile,
const DownloadWarningHatsProductSpecificData& psd,
base::OnceClosure success_callback,
base::OnceClosure failure_callback) {
std::optional<std::string> trigger =
MaybeGetDownloadWarningHatsTrigger(psd.survey_type());
if (!trigger) {
return;
}
HatsService* hats_service =
HatsServiceFactory::GetForProfile(profile, /*create_if_necessary=*/true);
if (hats_service) {
hats_service->LaunchSurvey(*trigger, std::move(success_callback),
std::move(failure_callback), psd.bits_data(),
psd.string_data());
}
}