| // Copyright 2018 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 "chrome/browser/ui/hats/hats_service.h" |
| |
| #include <memory> |
| #include <utility> |
| |
| #include "base/feature_list.h" |
| #include "base/metrics/field_trial_params.h" |
| #include "base/metrics/histogram_macros.h" |
| #include "base/rand_util.h" |
| #include "base/util/values/values_util.h" |
| #include "base/values.h" |
| #include "base/version.h" |
| #include "chrome/browser/browser_process.h" |
| #include "chrome/browser/prefs/incognito_mode_prefs.h" |
| #include "chrome/browser/profiles/profile.h" |
| #include "chrome/browser/profiles/profiles_state.h" |
| #include "chrome/browser/ui/browser_finder.h" |
| #include "chrome/browser/ui/browser_window.h" |
| #include "chrome/common/chrome_features.h" |
| #include "chrome/common/pref_names.h" |
| #include "components/metrics_services_manager/metrics_services_manager.h" |
| #include "components/prefs/pref_registry_simple.h" |
| #include "components/prefs/scoped_user_pref_update.h" |
| #include "components/version_info/version_info.h" |
| #include "content/public/browser/browser_thread.h" |
| #include "content/public/browser/web_contents.h" |
| #include "net/base/network_change_notifier.h" |
| |
| constexpr char kHatsSurveyTriggerSatisfaction[] = "satisfaction"; |
| constexpr char kHatsSurveyTriggerSettings[] = "settings"; |
| constexpr char kHatsSurveyTriggerSettingsPrivacy[] = "settings-privacy"; |
| |
| namespace { |
| |
| const base::Feature* survey_features[] = { |
| &features::kHappinessTrackingSurveysForDesktop, |
| &features::kHappinessTrackingSurveysForDesktopSettings, |
| &features::kHappinessTrackingSurveysForDesktopSettingsPrivacy}; |
| |
| // Which survey we're triggering |
| constexpr char kHatsSurveyTrigger[] = "survey"; |
| |
| constexpr char kHatsSurveyProbability[] = "probability"; |
| |
| constexpr char kHatsSurveyEnSiteID[] = "en_site_id"; |
| |
| constexpr double kHatsSurveyProbabilityDefault = 0; |
| |
| constexpr char kHatsSurveyEnSiteIDDefault[] = "bhej2dndhpc33okm6xexsbyv4y"; |
| |
| constexpr base::TimeDelta kMinimumTimeBetweenSurveyStarts = |
| base::TimeDelta::FromDays(60); |
| |
| constexpr base::TimeDelta kMinimumTimeBetweenSurveyChecks = |
| base::TimeDelta::FromDays(1); |
| |
| constexpr base::TimeDelta kMinimumProfileAge = base::TimeDelta::FromDays(30); |
| |
| // Preferences Data Model |
| // The kHatsSurveyMetadata pref points to a dictionary. |
| // The valid keys and value types for this dictionary are as follows: |
| // [trigger].last_major_version ---> Integer |
| // [trigger].last_survey_started_time ---> Time |
| |
| std::string GetMajorVersionPath(const std::string& trigger) { |
| return trigger + ".last_major_version"; |
| } |
| |
| std::string GetLastSurveyStartedTime(const std::string& trigger) { |
| return trigger + ".last_survey_started_time"; |
| } |
| |
| std::string GetIsSurveyFull(const std::string& trigger) { |
| return trigger + ".is_survey_full"; |
| } |
| |
| std::string GetLastSurveyCheckTime(const std::string& trigger) { |
| return trigger + ".last_survey_check_time"; |
| } |
| |
| constexpr char kHatsShouldShowSurveyReasonHistogram[] = |
| "Feedback.HappinessTrackingSurvey.ShouldShowSurveyReason"; |
| |
| // These values are persisted to logs. Entries should not be renumbered and |
| // numeric values should never be reused. |
| enum class ShouldShowSurveyReasons { |
| kYes = 0, |
| kNoOffline = 1, |
| kNoLastSessionCrashed = 2, |
| kNoReceivedSurveyInCurrentMilestone = 3, |
| kNoProfileTooNew = 4, |
| kNoLastSurveyTooRecent = 5, |
| kNoBelowProbabilityLimit = 6, |
| kNoTriggerStringMismatch = 7, |
| kNoNotRegularBrowser = 8, |
| kNoIncognitoDisabled = 9, |
| kNoCookiesBlocked = 10, // Unused. |
| kNoThirdPartyCookiesBlocked = 11, // Unused. |
| kNoSurveyUnreachable = 12, |
| kNoSurveyOverCapacity = 13, |
| kMaxValue = kNoSurveyOverCapacity, |
| }; |
| |
| } // namespace |
| |
| HatsService::SurveyMetadata::SurveyMetadata() = default; |
| |
| HatsService::SurveyMetadata::~SurveyMetadata() = default; |
| |
| HatsService::DelayedSurveyTask::DelayedSurveyTask( |
| HatsService* hats_service, |
| const std::string& trigger, |
| content::WebContents* web_contents) |
| : hats_service_(hats_service), trigger_(trigger) { |
| Observe(web_contents); |
| } |
| |
| HatsService::DelayedSurveyTask::~DelayedSurveyTask() = default; |
| |
| base::WeakPtr<HatsService::DelayedSurveyTask> |
| HatsService::DelayedSurveyTask::GetWeakPtr() { |
| return weak_ptr_factory_.GetWeakPtr(); |
| } |
| |
| void HatsService::DelayedSurveyTask::Launch() { |
| hats_service_->LaunchSurveyForWebContents(trigger_, web_contents()); |
| hats_service_->RemoveTask(*this); |
| } |
| |
| void HatsService::DelayedSurveyTask::WebContentsDestroyed() { |
| hats_service_->RemoveTask(*this); |
| } |
| |
| HatsService::HatsService(Profile* profile) : profile_(profile) { |
| for (auto* survey_feature : survey_features) { |
| if (!base::FeatureList::IsEnabled(*survey_feature)) |
| continue; |
| survey_configs_by_triggers_.emplace( |
| base::FeatureParam<std::string>(survey_feature, kHatsSurveyTrigger, "") |
| .Get(), |
| SurveyConfig( |
| base::FeatureParam<double>(survey_feature, kHatsSurveyProbability, |
| kHatsSurveyProbabilityDefault) |
| .Get(), |
| base::FeatureParam<std::string>(survey_feature, kHatsSurveyEnSiteID, |
| kHatsSurveyEnSiteIDDefault) |
| .Get())); |
| } |
| // Ensure a default survey exists (for demo purpose). |
| if (survey_configs_by_triggers_.find(kHatsSurveyTriggerSatisfaction) == |
| survey_configs_by_triggers_.end()) { |
| survey_configs_by_triggers_.emplace( |
| kHatsSurveyTriggerSatisfaction, |
| SurveyConfig(kHatsSurveyProbabilityDefault, |
| kHatsSurveyEnSiteIDDefault)); |
| } |
| } |
| |
| HatsService::~HatsService() = default; |
| |
| // static |
| void HatsService::RegisterProfilePrefs(PrefRegistrySimple* registry) { |
| registry->RegisterDictionaryPref(prefs::kHatsSurveyMetadata); |
| } |
| |
| void HatsService::LaunchSurvey(const std::string& trigger) { |
| if (!ShouldShowSurvey(trigger)) |
| return; |
| LaunchSurveyForBrowser(trigger, chrome::FindLastActiveWithProfile(profile_)); |
| } |
| |
| bool HatsService::LaunchDelayedSurvey(const std::string& trigger, |
| int timeout_ms) { |
| return base::SequencedTaskRunnerHandle::Get()->PostDelayedTask( |
| FROM_HERE, |
| base::BindOnce(&HatsService::LaunchSurvey, weak_ptr_factory_.GetWeakPtr(), |
| trigger), |
| base::TimeDelta::FromMilliseconds(timeout_ms)); |
| } |
| |
| bool HatsService::LaunchDelayedSurveyForWebContents( |
| const std::string& trigger, |
| content::WebContents* web_contents, |
| int timeout_ms) { |
| DCHECK_CURRENTLY_ON(content::BrowserThread::UI); |
| if (!web_contents) |
| return false; |
| auto result = pending_tasks_.emplace(this, trigger, web_contents); |
| if (!result.second) |
| return false; |
| auto success = base::SequencedTaskRunnerHandle::Get()->PostDelayedTask( |
| FROM_HERE, |
| base::BindOnce( |
| &HatsService::DelayedSurveyTask::Launch, |
| const_cast<HatsService::DelayedSurveyTask&>(*(result.first)) |
| .GetWeakPtr()), |
| base::TimeDelta::FromMilliseconds(timeout_ms)); |
| if (!success) { |
| pending_tasks_.erase(result.first); |
| } |
| return success; |
| } |
| |
| void HatsService::SetSurveyMetadataForTesting( |
| const HatsService::SurveyMetadata& metadata) { |
| const std::string& trigger = kHatsSurveyTriggerSatisfaction; |
| DictionaryPrefUpdate update(profile_->GetPrefs(), prefs::kHatsSurveyMetadata); |
| base::DictionaryValue* pref_data = update.Get(); |
| if (!metadata.last_major_version.has_value() && |
| !metadata.last_survey_started_time.has_value() && |
| !metadata.is_survey_full.has_value() && |
| !metadata.last_survey_check_time.has_value()) { |
| pref_data->RemovePath(trigger); |
| } |
| |
| if (metadata.last_major_version.has_value()) { |
| pref_data->SetIntPath(GetMajorVersionPath(trigger), |
| *metadata.last_major_version); |
| } else { |
| pref_data->RemovePath(GetMajorVersionPath(trigger)); |
| } |
| |
| if (metadata.last_survey_started_time.has_value()) { |
| pref_data->SetPath(GetLastSurveyStartedTime(trigger), |
| util::TimeToValue(*metadata.last_survey_started_time)); |
| } else { |
| pref_data->RemovePath(GetLastSurveyStartedTime(trigger)); |
| } |
| |
| if (metadata.is_survey_full.has_value()) { |
| pref_data->SetBoolPath(GetIsSurveyFull(trigger), *metadata.is_survey_full); |
| } else { |
| pref_data->RemovePath(GetIsSurveyFull(trigger)); |
| } |
| |
| if (metadata.last_survey_check_time.has_value()) { |
| pref_data->SetPath(GetLastSurveyCheckTime(trigger), |
| util::TimeToValue(*metadata.last_survey_check_time)); |
| } else { |
| pref_data->RemovePath(GetLastSurveyCheckTime(trigger)); |
| } |
| } |
| |
| void HatsService::GetSurveyMetadataForTesting( |
| HatsService::SurveyMetadata* metadata) const { |
| const std::string& trigger = kHatsSurveyTriggerSatisfaction; |
| DictionaryPrefUpdate update(profile_->GetPrefs(), prefs::kHatsSurveyMetadata); |
| base::DictionaryValue* pref_data = update.Get(); |
| |
| base::Optional<int> last_major_version = |
| pref_data->FindIntPath(GetMajorVersionPath(trigger)); |
| if (last_major_version.has_value()) |
| metadata->last_major_version = last_major_version; |
| |
| base::Optional<base::Time> last_survey_started_time = |
| util::ValueToTime(pref_data->FindPath(GetLastSurveyStartedTime(trigger))); |
| if (last_survey_started_time.has_value()) |
| metadata->last_survey_started_time = last_survey_started_time; |
| |
| base::Optional<bool> is_survey_full = |
| pref_data->FindBoolPath(GetIsSurveyFull(trigger)); |
| if (is_survey_full.has_value()) |
| metadata->is_survey_full = is_survey_full; |
| |
| base::Optional<base::Time> last_survey_check_time = |
| util::ValueToTime(pref_data->FindPath(GetLastSurveyCheckTime(trigger))); |
| if (last_survey_check_time.has_value()) |
| metadata->last_survey_check_time = last_survey_check_time; |
| } |
| |
| void HatsService::SetSurveyCheckerForTesting( |
| std::unique_ptr<HatsSurveyStatusChecker> checker) { |
| checker_ = std::move(checker); |
| } |
| |
| void HatsService::RemoveTask(const DelayedSurveyTask& task) { |
| pending_tasks_.erase(task); |
| } |
| |
| bool HatsService::HasPendingTasks() { |
| return !pending_tasks_.empty(); |
| } |
| |
| void HatsService::LaunchSurveyForWebContents( |
| const std::string& trigger, |
| content::WebContents* web_contents) { |
| if (ShouldShowSurvey(trigger) && web_contents && |
| web_contents->GetVisibility() == content::Visibility::VISIBLE) { |
| LaunchSurveyForBrowser(trigger, |
| chrome::FindBrowserWithWebContents(web_contents)); |
| } |
| } |
| |
| void HatsService::LaunchSurveyForBrowser(const std::string& trigger, |
| Browser* browser) { |
| if (!browser || !browser->is_type_normal() || |
| !profiles::IsRegularOrGuestSession(browser)) { |
| // Never show HaTS bubble for Incognito mode. |
| UMA_HISTOGRAM_ENUMERATION(kHatsShouldShowSurveyReasonHistogram, |
| ShouldShowSurveyReasons::kNoNotRegularBrowser); |
| return; |
| } |
| if (IncognitoModePrefs::GetAvailability(profile_->GetPrefs()) == |
| IncognitoModePrefs::DISABLED) { |
| // Incognito mode needs to be enabled to create an off-the-record profile |
| // for HaTS dialog. |
| UMA_HISTOGRAM_ENUMERATION(kHatsShouldShowSurveyReasonHistogram, |
| ShouldShowSurveyReasons::kNoIncognitoDisabled); |
| return; |
| } |
| // Checking survey's status could be costly due to a network request, so |
| // we check it at the last. |
| CheckSurveyStatusAndMaybeShow(browser, trigger); |
| } |
| |
| bool HatsService::ShouldShowSurvey(const std::string& trigger) const { |
| // Survey should not be loaded if the corresponding survey config is |
| // unavailable. |
| if (survey_configs_by_triggers_.find(trigger) == |
| survey_configs_by_triggers_.end()) { |
| UMA_HISTOGRAM_ENUMERATION( |
| kHatsShouldShowSurveyReasonHistogram, |
| ShouldShowSurveyReasons::kNoTriggerStringMismatch); |
| return false; |
| } |
| |
| if (base::FeatureList::IsEnabled( |
| features::kHappinessTrackingSurveysForDesktopDemo)) { |
| // Always show the survey in demo mode. |
| return true; |
| } |
| |
| // Survey can not be loaded and shown if there is no network connection. |
| if (net::NetworkChangeNotifier::IsOffline()) { |
| UMA_HISTOGRAM_ENUMERATION(kHatsShouldShowSurveyReasonHistogram, |
| ShouldShowSurveyReasons::kNoOffline); |
| return false; |
| } |
| |
| bool consent_given = |
| g_browser_process->GetMetricsServicesManager()->IsMetricsConsentGiven(); |
| if (!consent_given) |
| return false; |
| |
| if (profile_->GetLastSessionExitType() == Profile::EXIT_CRASHED) { |
| UMA_HISTOGRAM_ENUMERATION(kHatsShouldShowSurveyReasonHistogram, |
| ShouldShowSurveyReasons::kNoLastSessionCrashed); |
| return false; |
| } |
| |
| const base::DictionaryValue* pref_data = |
| profile_->GetPrefs()->GetDictionary(prefs::kHatsSurveyMetadata); |
| base::Optional<int> last_major_version = |
| pref_data->FindIntPath(GetMajorVersionPath(trigger)); |
| if (last_major_version.has_value() && |
| static_cast<uint32_t>(*last_major_version) == |
| version_info::GetVersion().components()[0]) { |
| UMA_HISTOGRAM_ENUMERATION( |
| kHatsShouldShowSurveyReasonHistogram, |
| ShouldShowSurveyReasons::kNoReceivedSurveyInCurrentMilestone); |
| return false; |
| } |
| |
| base::Time now = base::Time::Now(); |
| |
| if ((now - profile_->GetCreationTime()) < kMinimumProfileAge) { |
| UMA_HISTOGRAM_ENUMERATION(kHatsShouldShowSurveyReasonHistogram, |
| ShouldShowSurveyReasons::kNoProfileTooNew); |
| return false; |
| } |
| |
| base::Optional<base::Time> last_survey_started_time = |
| util::ValueToTime(pref_data->FindPath(GetLastSurveyStartedTime(trigger))); |
| if (last_survey_started_time.has_value()) { |
| base::TimeDelta elapsed_time_since_last_start = |
| now - *last_survey_started_time; |
| if (elapsed_time_since_last_start < kMinimumTimeBetweenSurveyStarts) { |
| UMA_HISTOGRAM_ENUMERATION( |
| kHatsShouldShowSurveyReasonHistogram, |
| ShouldShowSurveyReasons::kNoLastSurveyTooRecent); |
| return false; |
| } |
| } |
| |
| auto probability_ = survey_configs_by_triggers_.at(trigger).probability_; |
| bool should_show_survey = base::RandDouble() < probability_; |
| if (!should_show_survey) { |
| UMA_HISTOGRAM_ENUMERATION( |
| kHatsShouldShowSurveyReasonHistogram, |
| ShouldShowSurveyReasons::kNoBelowProbabilityLimit); |
| } |
| |
| return should_show_survey; |
| } |
| |
| void HatsService::CheckSurveyStatusAndMaybeShow(Browser* browser, |
| const std::string& trigger) { |
| // Check the survey status in profile first. |
| // We record the survey's over capacity information in user profile to avoid |
| // duplicated checks since the survey won't change once it is full. |
| const base::DictionaryValue* pref_data = |
| profile_->GetPrefs()->GetDictionary(prefs::kHatsSurveyMetadata); |
| base::Optional<int> is_full = |
| pref_data->FindBoolPath(GetIsSurveyFull(trigger)); |
| if (is_full.has_value() && is_full) |
| return; |
| |
| base::Optional<base::Time> last_survey_check_time = |
| util::ValueToTime(pref_data->FindPath(GetLastSurveyCheckTime(trigger))); |
| if (last_survey_check_time.has_value()) { |
| base::TimeDelta elapsed_time_since_last_check = |
| base::Time::Now() - *last_survey_check_time; |
| if (elapsed_time_since_last_check < kMinimumTimeBetweenSurveyChecks) |
| return; |
| } |
| |
| DCHECK(survey_configs_by_triggers_.find(trigger) != |
| survey_configs_by_triggers_.end()); |
| |
| if (!checker_) |
| checker_ = std::make_unique<HatsSurveyStatusChecker>(profile_); |
| checker_->CheckSurveyStatus( |
| survey_configs_by_triggers_[trigger].en_site_id_, |
| base::BindOnce(&HatsService::ShowSurvey, weak_ptr_factory_.GetWeakPtr(), |
| browser, trigger), |
| base::BindOnce(&HatsService::OnSurveyStatusError, |
| weak_ptr_factory_.GetWeakPtr(), trigger)); |
| } |
| |
| void HatsService::ShowSurvey(Browser* browser, const std::string& trigger) { |
| UMA_HISTOGRAM_ENUMERATION(kHatsShouldShowSurveyReasonHistogram, |
| ShouldShowSurveyReasons::kYes); |
| |
| browser->window()->ShowHatsBubble( |
| survey_configs_by_triggers_[trigger].en_site_id_); |
| |
| DictionaryPrefUpdate update(profile_->GetPrefs(), prefs::kHatsSurveyMetadata); |
| base::DictionaryValue* pref_data = update.Get(); |
| pref_data->SetIntPath(GetMajorVersionPath(trigger), |
| version_info::GetVersion().components()[0]); |
| pref_data->SetPath(GetLastSurveyStartedTime(trigger), |
| util::TimeToValue(base::Time::Now())); |
| checker_.reset(); |
| } |
| |
| void HatsService::OnSurveyStatusError(const std::string& trigger, |
| HatsSurveyStatusChecker::Status error) { |
| DictionaryPrefUpdate update(profile_->GetPrefs(), prefs::kHatsSurveyMetadata); |
| base::DictionaryValue* pref_update_data = update.Get(); |
| |
| if (error == HatsSurveyStatusChecker::Status::kUnreachable) { |
| UMA_HISTOGRAM_ENUMERATION(kHatsShouldShowSurveyReasonHistogram, |
| ShouldShowSurveyReasons::kNoSurveyUnreachable); |
| pref_update_data->SetPath(GetLastSurveyCheckTime(trigger), |
| util::TimeToValue(base::Time::Now())); |
| } else if (error == HatsSurveyStatusChecker::Status::kOverCapacity) { |
| UMA_HISTOGRAM_ENUMERATION(kHatsShouldShowSurveyReasonHistogram, |
| ShouldShowSurveyReasons::kNoSurveyOverCapacity); |
| pref_update_data->SetBoolPath(GetIsSurveyFull(trigger), true); |
| } |
| checker_.reset(); |
| } |