diff --git a/chrome/browser/download/download_warning_desktop_hats_utils.cc b/chrome/browser/download/download_warning_desktop_hats_utils.cc index d075fa2a35ff4a..a25d54c7d450c3 100644 --- a/chrome/browser/download/download_warning_desktop_hats_utils.cc +++ b/chrome/browser/download/download_warning_desktop_hats_utils.cc @@ -4,16 +4,20 @@ #include "chrome/browser/download/download_warning_desktop_hats_utils.h" +#include #include #include #include +#include "base/containers/contains.h" #include "base/feature_list.h" +#include "base/functional/bind.h" #include "base/ranges/algorithm.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" @@ -339,6 +343,121 @@ DownloadWarningHatsProductSpecificData::GetStringDataFields( 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 = reinterpret_cast(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) { + TaskKey key = reinterpret_cast(download); + 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, `download` will not be dereferenced while dangling. + return base::BindOnce(&DelayedDownloadWarningHatsLauncher::RemoveTaskIfAny, + weak_factory_.GetWeakPtr(), download); +} + +bool DelayedDownloadWarningHatsLauncher::WasUserActive() const { + return base::Time::Now() - last_activity_ <= delay_; +} + bool CanShowDownloadWarningHatsSurvey(download::DownloadItem* download) { CHECK(download); return download->IsDangerous() && !download->IsDone(); @@ -384,7 +503,9 @@ std::optional MaybeGetDownloadWarningHatsTrigger( void MaybeLaunchDownloadWarningHatsSurvey( Profile* profile, - const DownloadWarningHatsProductSpecificData& psd) { + const DownloadWarningHatsProductSpecificData& psd, + base::OnceClosure success_callback, + base::OnceClosure failure_callback) { std::optional trigger = MaybeGetDownloadWarningHatsTrigger(psd.survey_type()); if (!trigger) { @@ -394,8 +515,8 @@ void MaybeLaunchDownloadWarningHatsSurvey( HatsService* hats_service = HatsServiceFactory::GetForProfile(profile, /*create_if_necessary=*/true); if (hats_service) { - hats_service->LaunchSurvey(*trigger, /*success_callback=*/base::DoNothing(), - /*failure_callback=*/base::DoNothing(), - psd.bits_data(), psd.string_data()); + hats_service->LaunchSurvey(*trigger, std::move(success_callback), + std::move(failure_callback), psd.bits_data(), + psd.string_data()); } } diff --git a/chrome/browser/download/download_warning_desktop_hats_utils.h b/chrome/browser/download/download_warning_desktop_hats_utils.h index 1d7ab4da563e2c..7ac2a47a53173c 100644 --- a/chrome/browser/download/download_warning_desktop_hats_utils.h +++ b/chrome/browser/download/download_warning_desktop_hats_utils.h @@ -5,15 +5,16 @@ #ifndef CHROME_BROWSER_DOWNLOAD_DOWNLOAD_WARNING_DESKTOP_HATS_UTILS_H_ #define CHROME_BROWSER_DOWNLOAD_DOWNLOAD_WARNING_DESKTOP_HATS_UTILS_H_ +#include #include #include #include +#include "base/functional/callback.h" +#include "base/memory/weak_ptr.h" +#include "base/scoped_observation.h" #include "chrome/browser/ui/hats/hats_service.h" - -namespace download { -class DownloadItem; -} +#include "components/download/public/common/download_item.h" // Type of survey (corresponding to a trigger condition) that should be shown. // Do not renumber. @@ -154,6 +155,120 @@ class DownloadWarningHatsProductSpecificData { SurveyStringData string_data_; }; +// A class that manages delayed download warning HaTS survey tasks. It can be +// given a DownloadItem to launch a survey for in the future after some delay, +// and these tasks can be canceled explicitly or automatically (in case of +// the DownloadItem getting destroyed or becoming ineligible for a HaTS survey). +// It also records the last time the user interacted with the browser, and the +// survey is withheld if the user was (presumably) idle for the entire period of +// the delay. (Client should inform this object of browser activity.) +// Note: Currently this is only used for download bubble ignore triggers. +class DelayedDownloadWarningHatsLauncher + : public download::DownloadItem::Observer { + public: + // A callback that allows the completion of the PSD (addition of post-Create() + // fields). + using PsdCompleter = + base::RepeatingCallback; + + // Bundles the objects used to control the task and its lifetime. Can only be + // used once per instance. To cancel, delete this object. The `download` + // and `hats_launcher` must outlive this. + class Task { + public: + // Creates and schedules the task. + Task(DelayedDownloadWarningHatsLauncher& hats_launcher, + download::DownloadItem* download, + base::OnceClosure task, + base::TimeDelta delay); + + Task(const Task&) = delete; + Task& operator=(const Task&) = delete; + + ~Task(); + + private: + void RunTask(); + + // Controls the observation of the download by the parent object. + base::ScopedObservation + observation_; + // Task to show the survey. + base::OnceClosure task_; + // Used to cancel the scheduled task. + base::WeakPtrFactory weak_factory_{this}; + }; + + // `profile` is the profile for which the HaTS surveys should be shown. + // `delay` is the delay that applies to all surveys launched by this object. + // `psd_completer` will be called with the product-specific data right before + // attempting to launch each survey. + DelayedDownloadWarningHatsLauncher( + Profile* profile, + base::TimeDelta delay, + PsdCompleter psd_completer = base::DoNothing()); + + DelayedDownloadWarningHatsLauncher( + const DelayedDownloadWarningHatsLauncher&) = delete; + DelayedDownloadWarningHatsLauncher& operator=( + const DelayedDownloadWarningHatsLauncher&) = delete; + + ~DelayedDownloadWarningHatsLauncher() override; + + // download::DownloadItem::Observer: + // This object is an observer of every download with an entry in `tasks_`. + void OnDownloadUpdated(download::DownloadItem* download) override; + void OnDownloadDestroyed(download::DownloadItem* download) override; + + // Updates the last_activity_ time. + void RecordBrowserActivity(); + + // Schedules a survey to be shown after the delay, if the user has been active + // in the meantime. Does nothing if a task already exists for the download. + // Does nothing if the download is not eligible when scheduling. (The + // scheduled task will also fizzle if the download is not eligible upon + // execution.) Returns whether task was scheduled. + bool TryScheduleTask(DownloadWarningHatsType survey_type, + download::DownloadItem* download); + + // Cancels and removes the task for `download` from the map. Is a no-op if the + // download is not in the map. + void RemoveTaskIfAny(download::DownloadItem* download); + + private: + // Address of a DownloadItem, derived from a DownloadItem*, but it is not to + // be dereferenced. + using TaskKey = std::uintptr_t; + using TasksMap = std::map; + + // Launches the actual survey, if all preconditions are met. + void MaybeLaunchSurveyNow(DownloadWarningHatsType survey_type, + download::DownloadItem* download); + + // Returns a callback that is called to clean up after the survey succeeds or + // fails. + base::OnceClosure MakeSurveyDoneCallback(download::DownloadItem* download); + + // Whether the user was active in the browser during the delay period. + bool WasUserActive() const; + + // Profile to show the surveys for. Must outlive this. + const raw_ptr profile_; + // How long to wait before launching the survey. + const base::TimeDelta delay_; + // Time of the most recent user interaction with the browser. + base::Time last_activity_; + // Maps DownloadItem addresses to their corresponding pending tasks. + TasksMap tasks_; + // Callback that is run to stamp the PSD with any additional fields right + // before attempting to launch the survey. + PsdCompleter psd_completer_; + // Needed because the cleanup callback produced by MakeSurveyDoneCallback + // may outlive this. + base::WeakPtrFactory weak_factory_{this}; +}; + // Returns if the download item is dangerous and not-done. bool CanShowDownloadWarningHatsSurvey(download::DownloadItem* download); @@ -176,6 +291,8 @@ std::optional MaybeGetDownloadWarningHatsTrigger( // launch the survey. void MaybeLaunchDownloadWarningHatsSurvey( Profile* profile, - const DownloadWarningHatsProductSpecificData& psd); + const DownloadWarningHatsProductSpecificData& psd, + base::OnceClosure success_callback = base::DoNothing(), + base::OnceClosure failure_callback = base::DoNothing()); #endif // CHROME_BROWSER_DOWNLOAD_DOWNLOAD_WARNING_DESKTOP_HATS_UTILS_H_ diff --git a/chrome/browser/download/download_warning_desktop_hats_utils_unittest.cc b/chrome/browser/download/download_warning_desktop_hats_utils_unittest.cc index 9000fb40fbbc46..f5cd822eb71bad 100644 --- a/chrome/browser/download/download_warning_desktop_hats_utils_unittest.cc +++ b/chrome/browser/download/download_warning_desktop_hats_utils_unittest.cc @@ -4,9 +4,15 @@ #include "chrome/browser/download/download_warning_desktop_hats_utils.h" +#include + #include "base/files/file_path.h" #include "base/test/scoped_feature_list.h" #include "chrome/browser/download/download_item_warning_data.h" +#include "chrome/browser/ui/hats/hats_service.h" +#include "chrome/browser/ui/hats/hats_service_factory.h" +#include "chrome/browser/ui/hats/mock_hats_service.h" +#include "chrome/browser/ui/hats/survey_config.h" #include "chrome/test/base/testing_profile.h" #include "components/download/public/common/download_danger_type.h" #include "components/download/public/common/download_item.h" @@ -27,6 +33,7 @@ namespace { using download::DownloadItem; +using download::MockDownloadItem; using Fields = DownloadWarningHatsProductSpecificData::Fields; using ::testing::_; using ::testing::Eq; @@ -42,6 +49,7 @@ constexpr char kUrl[] = "https://www.site.example/file.pdf"; constexpr char kReferrerUrl[] = "https://www.site.example/referrer"; constexpr char kPlaceholderPrefix[] = "Not logged"; const base::FilePath::CharType kFilename[] = FILE_PATH_LITERAL("my_file.pdf"); +constexpr base::TimeDelta kIgnoreDelay = base::Seconds(10); // Matcher that checks for the presence of a particular bits data field and // checks that the field value matches the given matcher. @@ -83,43 +91,55 @@ class DownloadWarningDesktopHatsUtilsTest : public ::testing::Test { void SetUp() override { profile_ = TestingProfile::Builder().Build(); - content::DownloadItemUtils::AttachInfoForTesting(&item_, profile_.get(), + item_ = SetUpMockDownloadItem(); + mock_hats_service_ = static_cast( + HatsServiceFactory::GetInstance()->SetTestingFactoryAndUse( + profile_.get(), base::BindRepeating(&BuildMockHatsService))); + } + + void TearDown() override { mock_hats_service_ = nullptr; } + + std::unique_ptr> SetUpMockDownloadItem() { + auto item = std::make_unique>(); + content::DownloadItemUtils::AttachInfoForTesting(item.get(), profile_.get(), nullptr); + SetUpDefaultsForItem(item.get()); + return item; } - // Sets up defaults for the mock download item. These are not necessarily + // Sets up defaults for the mock download item_. These are not necessarily // valid/consistent with the Safe Browsing state, but we just want to test // that the values are reflected in the PSD and don't necessarily care what // they are. // Advances the time by 7 seconds. - void SetUpDefaultsForItem() { - ON_CALL(item_, GetURL()).WillByDefault(ReturnRefOfCopy(GURL(kUrl))); - ON_CALL(item_, GetReferrerUrl()) + void SetUpDefaultsForItem(MockDownloadItem* item) { + ON_CALL(*item, GetURL()).WillByDefault(ReturnRefOfCopy(GURL(kUrl))); + ON_CALL(*item, GetReferrerUrl()) .WillByDefault(ReturnRefOfCopy(GURL(kReferrerUrl))); - ON_CALL(item_, GetFileNameToReportUser()) + ON_CALL(*item, GetFileNameToReportUser()) .WillByDefault(Return(base::FilePath(kFilename))); // Set up the time since download started. base::Time start_time = base::Time::Now(); - ON_CALL(item_, GetStartTime()).WillByDefault(Return(start_time)); + ON_CALL(*item, GetStartTime()).WillByDefault(Return(start_time)); // Add some warning action events. task_environment_.FastForwardBy(base::Seconds(5)); DownloadItemWarningData::AddWarningActionEvent( - &item_, DownloadItemWarningData::WarningSurface::BUBBLE_MAINPAGE, + item, DownloadItemWarningData::WarningSurface::BUBBLE_MAINPAGE, DownloadItemWarningData::WarningAction::SHOWN); task_environment_.FastForwardBy(base::Seconds(1)); DownloadItemWarningData::AddWarningActionEvent( - &item_, DownloadItemWarningData::WarningSurface::BUBBLE_MAINPAGE, + item, DownloadItemWarningData::WarningSurface::BUBBLE_MAINPAGE, DownloadItemWarningData::WarningAction::OPEN_SUBPAGE); task_environment_.FastForwardBy(base::Seconds(1)); DownloadItemWarningData::AddWarningActionEvent( - &item_, DownloadItemWarningData::WarningSurface::BUBBLE_SUBPAGE, + item, DownloadItemWarningData::WarningSurface::BUBBLE_SUBPAGE, DownloadItemWarningData::WarningAction::CLOSE); - ON_CALL(item_, IsDone()).WillByDefault(Return(false)); - ON_CALL(item_, IsDangerous()).WillByDefault(Return(true)); - ON_CALL(item_, GetDangerType()) + ON_CALL(*item, IsDone()).WillByDefault(Return(false)); + ON_CALL(*item, IsDangerous()).WillByDefault(Return(true)); + ON_CALL(*item, GetDangerType()) .WillByDefault( Return(download::DownloadDangerType:: DOWNLOAD_DANGER_TYPE_DANGEROUS_ACCOUNT_COMPROMISE)); @@ -132,12 +152,12 @@ class DownloadWarningDesktopHatsUtilsTest : public ::testing::Test { tailored_verdict.add_adjustments(safe_browsing::ClientDownloadResponse:: TailoredVerdict::ACCOUNT_INFO_STRING); safe_browsing::DownloadProtectionService::SetDownloadProtectionData( - &item_, "token", + item, "token", safe_browsing::ClientDownloadResponse::DANGEROUS_ACCOUNT_COMPROMISE, std::move(tailored_verdict)); #endif // BUILDFLAG(FULL_SAFE_BROWSING) - ON_CALL(item_, HasUserGesture()).WillByDefault(Return(true)); + ON_CALL(*item, HasUserGesture()).WillByDefault(Return(true)); } void ExpectDefaultPsd(const DownloadWarningHatsProductSpecificData& psd) { @@ -192,7 +212,8 @@ class DownloadWarningDesktopHatsUtilsTest : public ::testing::Test { base::test::TaskEnvironment::TimeSource::MOCK_TIME}; base::test::ScopedFeatureList features_; std::unique_ptr profile_; - NiceMock item_; + std::unique_ptr> item_; + raw_ptr mock_hats_service_ = nullptr; }; TEST_F(DownloadWarningDesktopHatsUtilsTest, @@ -200,10 +221,8 @@ TEST_F(DownloadWarningDesktopHatsUtilsTest, safe_browsing::SetSafeBrowsingState( profile_->GetPrefs(), safe_browsing::SafeBrowsingState::NO_SAFE_BROWSING); - SetUpDefaultsForItem(); - auto psd = DownloadWarningHatsProductSpecificData::Create( - DownloadWarningHatsType::kDownloadBubbleBypass, &item_); + DownloadWarningHatsType::kDownloadBubbleBypass, item_.get()); // Test the PSD fields added afterwards. // This shouldn't do anything because this is a download bubble trigger. @@ -239,10 +258,8 @@ TEST_F(DownloadWarningDesktopHatsUtilsTest, profile_->GetPrefs(), safe_browsing::SafeBrowsingState::STANDARD_PROTECTION); - SetUpDefaultsForItem(); - auto psd = DownloadWarningHatsProductSpecificData::Create( - DownloadWarningHatsType::kDownloadsPageHeed, &item_); + DownloadWarningHatsType::kDownloadsPageHeed, item_.get()); // Test the PSD fields added afterwards. psd.AddNumPageWarnings(10); @@ -278,10 +295,8 @@ TEST_F(DownloadWarningDesktopHatsUtilsTest, profile_->GetPrefs(), safe_browsing::SafeBrowsingState::ENHANCED_PROTECTION); - SetUpDefaultsForItem(); - auto psd = DownloadWarningHatsProductSpecificData::Create( - DownloadWarningHatsType::kDownloadBubbleIgnore, &item_); + DownloadWarningHatsType::kDownloadBubbleIgnore, item_.get()); // Test the PSD fields added afterwards. // This shouldn't do anything because this is a download bubble trigger. @@ -311,6 +326,127 @@ TEST_F(DownloadWarningDesktopHatsUtilsTest, EXPECT_THAT(psd, Not(StringDataMatches(Fields::kNumPageWarnings, _))); } +TEST_F(DownloadWarningDesktopHatsUtilsTest, + DelayedDownloadWarningHatsLauncher_LaunchesSurvey) { + base::test::ScopedFeatureList features; + features.InitAndEnableFeatureWithParameters( + safe_browsing::kDownloadWarningSurvey, + {{safe_browsing::kDownloadWarningSurveyType.name, + "2" /*kDownloadBubbleIgnore*/}}); + + DelayedDownloadWarningHatsLauncher launcher{profile_.get(), kIgnoreDelay}; + EXPECT_TRUE(launcher.TryScheduleTask( + DownloadWarningHatsType::kDownloadBubbleIgnore, item_.get())); + launcher.RecordBrowserActivity(); + EXPECT_CALL( + *mock_hats_service_, + LaunchSurvey(kHatsSurveyTriggerDownloadWarningBubbleIgnore, _, _, _, _)); + task_environment_.FastForwardBy(kIgnoreDelay); +} + +TEST_F(DownloadWarningDesktopHatsUtilsTest, + DelayedDownloadWarningHatsLauncher_DoesntScheduleDuplicateSurvey) { + base::test::ScopedFeatureList features; + features.InitAndEnableFeatureWithParameters( + safe_browsing::kDownloadWarningSurvey, + {{safe_browsing::kDownloadWarningSurveyType.name, + "2" /*kDownloadBubbleIgnore*/}}); + + DelayedDownloadWarningHatsLauncher launcher{profile_.get(), kIgnoreDelay}; + EXPECT_TRUE(launcher.TryScheduleTask( + DownloadWarningHatsType::kDownloadBubbleIgnore, item_.get())); + EXPECT_FALSE(launcher.TryScheduleTask( + DownloadWarningHatsType::kDownloadBubbleIgnore, item_.get())); +} + +TEST_F(DownloadWarningDesktopHatsUtilsTest, + DelayedDownloadWarningHatsLauncher_MultipleSurveys) { + safe_browsing::SetSafeBrowsingState( + profile_->GetPrefs(), + safe_browsing::SafeBrowsingState::STANDARD_PROTECTION); + + base::test::ScopedFeatureList features; + features.InitAndEnableFeatureWithParameters( + safe_browsing::kDownloadWarningSurvey, + {{safe_browsing::kDownloadWarningSurveyType.name, + "2" /*kDownloadBubbleIgnore*/}}); + + std::unique_ptr> other_item = + SetUpMockDownloadItem(); + ON_CALL(*other_item, GetFileNameToReportUser()) + .WillByDefault( + Return(base::FilePath(FILE_PATH_LITERAL("other_file.pdf")))); + + DelayedDownloadWarningHatsLauncher launcher{profile_.get(), kIgnoreDelay}; + EXPECT_TRUE(launcher.TryScheduleTask( + DownloadWarningHatsType::kDownloadBubbleIgnore, item_.get())); + launcher.RecordBrowserActivity(); + task_environment_.FastForwardBy(kIgnoreDelay / 2); + EXPECT_TRUE(launcher.TryScheduleTask( + DownloadWarningHatsType::kDownloadBubbleIgnore, other_item.get())); + { + EXPECT_CALL( + *mock_hats_service_, + LaunchSurvey( + kHatsSurveyTriggerDownloadWarningBubbleIgnore, _, _, _, + Contains(Pair(Fields::kFilename, HasSubstr("my_file.pdf"))))); + task_environment_.FastForwardBy(kIgnoreDelay / 2); + } + launcher.RecordBrowserActivity(); + { + EXPECT_CALL( + *mock_hats_service_, + LaunchSurvey( + kHatsSurveyTriggerDownloadWarningBubbleIgnore, _, _, _, + Contains(Pair(Fields::kFilename, HasSubstr("other_file.pdf"))))); + task_environment_.FastForwardBy(kIgnoreDelay / 2); + } +} + +TEST_F(DownloadWarningDesktopHatsUtilsTest, + DelayedDownloadWarningHatsLauncher_WithholdsSurveyIfNoUserActivity) { + base::test::ScopedFeatureList features; + features.InitAndEnableFeatureWithParameters( + safe_browsing::kDownloadWarningSurvey, + {{safe_browsing::kDownloadWarningSurveyType.name, + "2" /*kDownloadBubbleIgnore*/}}); + + DelayedDownloadWarningHatsLauncher launcher{profile_.get(), kIgnoreDelay}; + launcher.TryScheduleTask(DownloadWarningHatsType::kDownloadBubbleIgnore, + item_.get()); + EXPECT_CALL( + *mock_hats_service_, + LaunchSurvey(kHatsSurveyTriggerDownloadWarningBubbleIgnore, _, _, _, _)) + .Times(0); + task_environment_.FastForwardBy(2 * kIgnoreDelay); +} + +TEST_F(DownloadWarningDesktopHatsUtilsTest, + DelayedDownloadWarningHatsLauncher_DeletesTaskWhenItemDeleted) { + base::test::ScopedFeatureList features; + features.InitAndEnableFeatureWithParameters( + safe_browsing::kDownloadWarningSurvey, + {{safe_browsing::kDownloadWarningSurveyType.name, + "2" /*kDownloadBubbleIgnore*/}}); + + DelayedDownloadWarningHatsLauncher launcher{profile_.get(), kIgnoreDelay}; + EXPECT_TRUE(launcher.TryScheduleTask( + DownloadWarningHatsType::kDownloadBubbleIgnore, item_.get())); + item_.reset(); + + launcher.RecordBrowserActivity(); + EXPECT_CALL( + *mock_hats_service_, + LaunchSurvey(kHatsSurveyTriggerDownloadWarningBubbleIgnore, _, _, _, _)) + .Times(0); + task_environment_.FastForwardBy(kIgnoreDelay); + + item_ = SetUpMockDownloadItem(); + // Trying again works because the older task was deleted. + EXPECT_TRUE(launcher.TryScheduleTask( + DownloadWarningHatsType::kDownloadBubbleIgnore, item_.get())); +} + TEST_F(DownloadWarningDesktopHatsUtilsTest, MaybeGetDownloadWarningHatsTrigger_FeatureDisabled) { base::test::ScopedFeatureList features;