| // Copyright 2016 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/ash/hats/hats_notification_controller.h" |
| |
| #include <optional> |
| |
| #include "ash/constants/ash_switches.h" |
| #include "ash/constants/notifier_catalogs.h" |
| #include "ash/public/cpp/notification_utils.h" |
| #include "base/check_op.h" |
| #include "base/command_line.h" |
| #include "base/feature_list.h" |
| #include "base/functional/bind.h" |
| #include "base/logging.h" |
| #include "base/metrics/histogram_functions.h" |
| #include "base/strings/escape.h" |
| #include "base/task/thread_pool.h" |
| #include "base/time/time.h" |
| #include "chrome/app/vector_icons/vector_icons.h" |
| #include "chrome/browser/ash/hats/hats_config.h" |
| #include "chrome/browser/ash/hats/hats_dialog.h" |
| #include "chrome/browser/ash/hats/hats_finch_helper.h" |
| #include "chrome/browser/ash/login/startup_utils.h" |
| #include "chrome/browser/ash/profiles/profile_helper.h" |
| #include "chrome/browser/profiles/profile.h" |
| #include "chrome/browser/profiles/profile_manager.h" |
| #include "chrome/browser/themes/theme_service.h" |
| #include "chrome/browser/themes/theme_service_factory.h" |
| #include "chrome/common/pref_names.h" |
| #include "chrome/grit/generated_resources.h" |
| #include "chromeos/ash/components/install_attributes/install_attributes.h" |
| #include "chromeos/ash/components/network/network_handler.h" |
| #include "chromeos/ash/components/network/network_state.h" |
| #include "chromeos/ash/components/network/network_state_handler.h" |
| #include "chromeos/version/version_loader.h" |
| #include "components/language/core/browser/pref_names.h" |
| #include "components/language/core/common/locale_util.h" |
| #include "components/prefs/pref_service.h" |
| #include "components/version_info/version_info.h" |
| #include "content/public/browser/browser_thread.h" |
| #include "google_apis/gaia/gaia_auth_util.h" |
| #include "ui/base/l10n/l10n_util.h" |
| #include "ui/message_center/message_center.h" |
| #include "ui/message_center/public/cpp/notification.h" |
| #include "ui/message_center/public/cpp/notification_types.h" |
| #include "ui/strings/grit/ui_strings.h" |
| |
| namespace ash { |
| |
| namespace { |
| |
| const char kNotificationOriginUrl[] = "chrome://hats"; |
| |
| const char kNotifierHats[] = "ash.hats"; |
| |
| // The state specific UMA enumerations |
| const int kSurveyTriggeredEnumeration = 1; |
| |
| // TODO(jackshira): Migrate this to a manager class. |
| // Delimiters used to join the separate device info elements into a single |
| // string to be used as site context. |
| const char kDeviceInfoStopKeyword[] = "&"; |
| const char kDeviceInfoKeyValueDelimiter[] = "="; |
| const char kDefaultProfileLocale[] = "en-US"; |
| |
| // TODO(jackshira): Migrate this to a manager class. |
| enum class DeviceInfoKey : unsigned int { |
| BROWSER = 0, |
| PLATFORM, |
| FIRMWARE, |
| LOCALE, |
| }; |
| |
| // TODO(jackshira): Migrate this to a manager class. |
| // Maps the given DeviceInfoKey |key| enum to the corresponding string value |
| // that can be used as a key when creating a URL parameter. |
| const std::string KeyEnumToString(DeviceInfoKey key) { |
| switch (key) { |
| case DeviceInfoKey::BROWSER: |
| return "browser"; |
| case DeviceInfoKey::PLATFORM: |
| return "platform"; |
| case DeviceInfoKey::FIRMWARE: |
| return "firmware"; |
| case DeviceInfoKey::LOCALE: |
| return "locale"; |
| default: |
| NOTREACHED(); |
| } |
| } |
| |
| // Returns true if the given `profile` interacted with non-prioritized HaTS |
| // by either dismissing the notification or taking the survey within a given |
| // `threshold_time`. |
| bool DidShowNonPrioritizedHatsToProfileRecently( |
| const Profile* profile, |
| const base::TimeDelta& threshold_time) { |
| int64_t serialized_timestamp = |
| profile->GetPrefs()->GetInt64(prefs::kHatsLastInteractionTimestamp); |
| |
| base::Time previous_interaction_timestamp = |
| base::Time::FromInternalValue(serialized_timestamp); |
| return previous_interaction_timestamp + threshold_time > base::Time::Now(); |
| } |
| |
| // Returns true if the given |profile| interacted with a prioritized HaTS |
| // by either dismissing the notification or taking another prioritized survey |
| // within |prioritized_threshold_time|. |
| // If |hats_config| is given, then also check if the given |profile| interacted |
| // with that specific prioritized HaTS |hats_config| based on the pref timestamp |
| // |HatsConfig::survey_last_interaction_timestamp_pref_name| within the |
| // |HatsConfig::threshold_time|. |
| bool DidShowPrioritizedHatsToProfileRecently( |
| const Profile* profile, |
| std::optional<raw_ref<const HatsConfig>> hats_config, |
| const base::TimeDelta& prioritized_threshold_time) { |
| base::Time prev_prioritized_interaction = profile->GetPrefs()->GetTime( |
| prefs::kHatsPrioritizedLastInteractionTimestamp); |
| if (prev_prioritized_interaction + prioritized_threshold_time > |
| base::Time::Now()) { |
| return true; |
| } |
| |
| if (!hats_config.has_value()) { |
| return false; |
| } |
| |
| base::Time previous_interaction_timestamp = profile->GetPrefs()->GetTime( |
| hats_config.value()->survey_last_interaction_timestamp_pref_name); |
| |
| return previous_interaction_timestamp + hats_config.value()->threshold_time > |
| base::Time::Now(); |
| } |
| |
| bool DidShowAnyHatsToProfileRecently(const Profile* profile, |
| const base::TimeDelta& threshold_time) { |
| return DidShowNonPrioritizedHatsToProfileRecently(profile, threshold_time) || |
| DidShowPrioritizedHatsToProfileRecently( |
| profile, /*hats_config=*/std::nullopt, threshold_time); |
| } |
| |
| // Returns true if at least |new_device_threshold| time has passed since |
| // OOBE. This is an indirect measure of whether the owner has used the device |
| // for at least |new_device_threshold| time. |
| bool IsNewDevice(base::TimeDelta new_device_threshold) { |
| return StartupUtils::GetTimeSinceOobeFlagFileCreation() <= |
| new_device_threshold; |
| } |
| |
| // Returns true if the |kForceHappinessTrackingSystem| flag is enabled for the |
| // current survey. |
| bool IsTestingEnabled(const HatsConfig& hats_config) { |
| base::CommandLine* command_line = base::CommandLine::ForCurrentProcess(); |
| |
| if (command_line->HasSwitch(switches::kForceHappinessTrackingSystem)) { |
| auto switch_value = command_line->GetSwitchValueASCII( |
| switches::kForceHappinessTrackingSystem); |
| return switch_value.empty() || hats_config.feature.name == switch_value; |
| } |
| |
| return false; |
| } |
| |
| } // namespace |
| |
| // static |
| const char HatsNotificationController::kNotificationId[] = "hats_notification"; |
| |
| HatsNotificationController::HatsNotificationController( |
| Profile* profile, |
| const HatsConfig& hats_config, |
| const base::flat_map<std::string, std::string>& product_specific_data, |
| std::u16string title, |
| std::u16string body) |
| : profile_(profile), |
| hats_config_(hats_config), |
| product_specific_data_(product_specific_data), |
| title_(std::move(title)), |
| body_(std::move(body)) { |
| DCHECK_CURRENTLY_ON(content::BrowserThread::UI); |
| |
| std::string histogram_name = HatsFinchHelper::GetHistogramName(*hats_config_); |
| if (!histogram_name.empty()) { |
| base::UmaHistogramSparse(histogram_name, kSurveyTriggeredEnumeration); |
| } |
| |
| profile_observation_.Observe(profile_); |
| |
| base::ThreadPool::PostTaskAndReplyWithResult( |
| FROM_HERE, {base::MayBlock(), base::TaskPriority::BEST_EFFORT}, |
| base::BindOnce(&IsNewDevice, hats_config.new_device_threshold), |
| base::BindOnce(&HatsNotificationController::Initialize, |
| weak_pointer_factory_.GetWeakPtr())); |
| } |
| |
| HatsNotificationController::HatsNotificationController( |
| Profile* profile, |
| const HatsConfig& hats_config, |
| const base::flat_map<std::string, std::string>& product_specific_data) |
| : HatsNotificationController( |
| profile, |
| hats_config, |
| product_specific_data, |
| l10n_util::GetStringUTF16(IDS_HATS_NOTIFICATION_TITLE), |
| l10n_util::GetStringUTF16(IDS_HATS_NOTIFICATION_BODY)) {} |
| |
| HatsNotificationController::HatsNotificationController( |
| Profile* profile, |
| const HatsConfig& hats_config) |
| : HatsNotificationController(profile, |
| hats_config, |
| base::flat_map<std::string, std::string>()) {} |
| |
| HatsNotificationController::~HatsNotificationController() { |
| DCHECK_CURRENTLY_ON(content::BrowserThread::UI); |
| |
| base::UmaHistogramEnumeration("Browser.ChromeOS.HatsStatus", state_); |
| |
| if (NetworkHandler::IsInitialized()) |
| NetworkHandler::Get()->network_state_handler()->RemoveObserver(this); |
| } |
| |
| void HatsNotificationController::Initialize(bool is_new_device) { |
| DCHECK_CURRENTLY_ON(content::BrowserThread::UI); |
| |
| if (is_new_device && !IsTestingEnabled(*hats_config_)) { |
| // This device has been chosen for a survey, but it is too new. Instead |
| // of showing the user the survey, just mark it as completed. |
| UpdateLastInteractionTime(); |
| |
| state_ = HatsState::kNewDevice; |
| return; |
| } |
| |
| if (NetworkHandler::IsInitialized()) { |
| // Observe NetworkStateHandler to be notified when an internet connection |
| // is available. |
| NetworkStateHandler* handler = |
| NetworkHandler::Get()->network_state_handler(); |
| handler->AddObserver(this); |
| // Create an immediate update for the current default network. |
| const NetworkState* default_network = handler->DefaultNetwork(); |
| NetworkState::PortalState portal_state = |
| default_network ? default_network->portal_state() |
| : NetworkState::PortalState::kUnknown; |
| PortalStateChanged(default_network, portal_state); |
| } |
| } |
| |
| // static |
| bool HatsNotificationController::ShouldShowSurveyToProfile( |
| Profile* profile, |
| const HatsConfig& hats_config) { |
| DCHECK_CURRENTLY_ON(content::BrowserThread::UI); |
| |
| if (IsTestingEnabled(hats_config)) |
| return true; |
| |
| // Do not show the survey if the HaTS feature is disabled for the device. This |
| // flag is controlled by finch and is enabled only when the device has been |
| // selected for the survey. |
| if (!base::FeatureList::IsEnabled(hats_config.feature)) |
| return false; |
| |
| // Do not show survey if this is a guest session. |
| if (profile->IsGuestSession()) |
| return false; |
| |
| // Do not show survey if the user is supervised. |
| if (profile->IsChild()) |
| return false; |
| |
| const bool is_enterprise_enrolled = |
| ash::InstallAttributes::Get()->IsEnterpriseManaged(); |
| |
| HatsFinchHelper hats_finch_helper(profile, hats_config); |
| |
| // Do not show survey to enterprise users. |
| // Exceptions for Googlers if the survey wants Googlers participation. |
| if (is_enterprise_enrolled && |
| !(gaia::IsGoogleInternalAccountEmail(profile->GetProfileUserName()) && |
| hats_finch_helper.IsEnabledForGooglers(hats_config))) { |
| return false; |
| } |
| |
| // Do not show survey to non-owners. However, enterprise-enrolled Googlers |
| // who passed the previous check will not be owners; don't exclude them. |
| if (!is_enterprise_enrolled && !ProfileHelper::IsOwnerProfile(profile)) { |
| return false; |
| } |
| |
| if (!hats_finch_helper.IsDeviceSelectedForCurrentCycle()) |
| return false; |
| |
| // There are two types of HaTS: prioritized and the non prioritized, |
| // both are kept track separately. The following checks both track records. |
| if (DidShowAnyHatsToProfileRecently(profile, kMinimumHatsThreshold)) { |
| return false; |
| } |
| |
| if (hats_config.prioritized) { |
| // Do not show survey to user if the survey is prioritized and: |
| // - User already interacted with the survey within |
| // the threshold set in the config, or |
| // - User already interacted with other prioritized survey within |
| // the past |kPrioritizedHatsThreshold|. |
| if (DidShowPrioritizedHatsToProfileRecently( |
| profile, raw_ref<const HatsConfig>(hats_config), |
| kPrioritizedHatsThreshold)) { |
| return false; |
| } |
| } else { |
| const base::TimeDelta threshold_time = |
| gaia::IsGoogleInternalAccountEmail(profile->GetProfileUserName()) |
| ? kHatsGooglerThreshold |
| : kHatsThreshold; |
| // Do not show survey to user if user has interacted with HaTS within the |
| // past |threshold_time| time delta. This is a global cap applied across |
| // surveys that have not opted out of the global cap of 1 per kHatsThreshold |
| // days. |
| if (DidShowNonPrioritizedHatsToProfileRecently(profile, threshold_time)) { |
| base::UmaHistogramEnumeration("Browser.ChromeOS.HatsStatus", |
| HatsState::kSurveyShownRecently); |
| return false; |
| } |
| } |
| return true; |
| } |
| |
| void HatsNotificationController::Click( |
| const std::optional<int>& button_index, |
| const std::optional<std::u16string>& reply) { |
| DCHECK_CURRENTLY_ON(content::BrowserThread::UI); |
| CHECK(profile_) << "Profile must NOT be null."; |
| |
| UpdateLastInteractionTime(); |
| |
| std::string user_locale = |
| profile_->GetPrefs()->GetString(language::prefs::kApplicationLocale); |
| language::ConvertToActualUILocale(&user_locale); |
| if (!user_locale.length()) |
| user_locale = kDefaultProfileLocale; |
| |
| base::ThreadPool::PostTaskAndReplyWithResult( |
| FROM_HERE, {base::MayBlock(), base::TaskPriority::BEST_EFFORT}, |
| base::BindOnce(&GetFormattedSiteContext, user_locale, |
| product_specific_data_), |
| base::BindOnce(&HatsNotificationController::ShowDialog, |
| weak_pointer_factory_.GetWeakPtr())); |
| |
| state_ = HatsState::kNotificationClicked; |
| |
| // Remove the notification. |
| NetworkHandler::Get()->network_state_handler()->RemoveObserver(this); |
| message_center::MessageCenter::Get()->RemoveNotification(notification_id_, |
| false /* by_user */); |
| notification_id_.clear(); |
| } |
| |
| void HatsNotificationController::ShowDialog(const std::string& site_context) { |
| if (profile_ != ProfileManager::GetActiveUserProfile()) { |
| DVLOG(1) << "Different user detected, not showing dialog"; |
| return; |
| } |
| |
| HatsDialog::Show(HatsFinchHelper::GetTriggerID(*hats_config_), |
| HatsFinchHelper::GetHistogramName(*hats_config_), |
| site_context); |
| } |
| |
| // message_center::NotificationDelegate override: |
| void HatsNotificationController::Close(bool by_user) { |
| DCHECK_CURRENTLY_ON(content::BrowserThread::UI); |
| |
| if (by_user) { |
| UpdateLastInteractionTime(); |
| NetworkHandler::Get()->network_state_handler()->RemoveObserver(this); |
| message_center::MessageCenter::Get()->RemoveNotification(notification_id_, |
| by_user); |
| notification_id_.clear(); |
| state_ = HatsState::kNotificationDismissed; |
| } |
| } |
| |
| // NetworkStateHandlerObserver override: |
| void HatsNotificationController::PortalStateChanged( |
| const NetworkState* default_network, |
| NetworkState::PortalState portal_state) { |
| DCHECK_CURRENTLY_ON(content::BrowserThread::UI); |
| CHECK(profile_) << "Profile must NOT be null."; |
| VLOG(1) << "PortalStateChanged: default_network=" |
| << (default_network ? default_network->path() : "") |
| << ", portal_state=" << portal_state; |
| if (portal_state == NetworkState::PortalState::kOnline) { |
| // Create and display the notification for the user if it doesn't exist. |
| if (notification_id_.empty()) { |
| notification_id_ = kNotificationId; |
| message_center::NotifierId notifier_id( |
| message_center::NotifierType::SYSTEM_COMPONENT, kNotifierHats, |
| NotificationCatalogName::kHats); |
| // Set the profile_id for the NotifierId. |
| // This string should match what InactiveUserNotificationBlocker |
| // expects. |
| notifier_id.profile_id = profile_->GetProfileUserName(); |
| |
| auto notification = ash::CreateSystemNotificationPtr( |
| message_center::NOTIFICATION_TYPE_SIMPLE, notification_id_, title_, |
| body_, |
| l10n_util::GetStringUTF16(IDS_MESSAGE_CENTER_NOTIFIER_HATS_NAME), |
| GURL(kNotificationOriginUrl), notifier_id, |
| message_center::RichNotificationData(), this, kNotificationGoogleIcon, |
| message_center::SystemNotificationWarningLevel::NORMAL); |
| message_center::MessageCenter::Get()->AddNotification( |
| std::move(notification)); |
| } |
| |
| state_ = HatsState::kNotificationDisplayed; |
| } else if (!notification_id_.empty()) { |
| // Hide the notification if device loses its connection to the internet. |
| message_center::MessageCenter::Get()->RemoveNotification(notification_id_, |
| /*by_user=*/false); |
| notification_id_.clear(); |
| } |
| } |
| |
| void HatsNotificationController::OnShuttingDown() { |
| NetworkHandler::Get()->network_state_handler()->RemoveObserver(this); |
| } |
| |
| void HatsNotificationController::OnProfileWillBeDestroyed(Profile* profile) { |
| CHECK_EQ(profile_, profile); |
| profile_ = nullptr; |
| profile_observation_.Reset(); |
| } |
| |
| // TODO(jackshira): Migrate this to a manager class. |
| // static |
| std::string HatsNotificationController::GetFormattedSiteContext( |
| const std::string& user_locale, |
| const base::flat_map<std::string, std::string>& product_specific_data) { |
| base::flat_map<std::string, std::string> context; |
| |
| context[KeyEnumToString(DeviceInfoKey::BROWSER)] = |
| version_info::GetVersionNumber(); |
| |
| std::optional<std::string> version = chromeos::version_loader::GetVersion( |
| chromeos::version_loader::VERSION_FULL); |
| context[KeyEnumToString(DeviceInfoKey::PLATFORM)] = |
| version.value_or("0.0.0.0"); |
| |
| context[KeyEnumToString(DeviceInfoKey::FIRMWARE)] = |
| chromeos::version_loader::GetFirmware(); |
| |
| context[KeyEnumToString(DeviceInfoKey::LOCALE)] = user_locale; |
| |
| for (const auto& pair : context) { |
| if (product_specific_data.contains(pair.first)) { |
| LOG(WARNING) << "Product specific data contains reserved key " |
| << pair.first << ". Value will be overwritten."; |
| } |
| } |
| context.insert(product_specific_data.begin(), product_specific_data.end()); |
| |
| std::stringstream stream; |
| bool first_iteration = true; |
| for (const auto& pair : context) { |
| if (!first_iteration) |
| stream << kDeviceInfoStopKeyword; |
| |
| stream << base::EscapeQueryParamValue(pair.first, /*use_plus=*/false) |
| << kDeviceInfoKeyValueDelimiter |
| << base::EscapeQueryParamValue(pair.second, /*use_plus=*/false); |
| |
| first_iteration = false; |
| } |
| return stream.str(); |
| } |
| |
| void HatsNotificationController::UpdateLastInteractionTime() { |
| DCHECK_CURRENTLY_ON(content::BrowserThread::UI); |
| CHECK(profile_) << "Profile must NOT be null."; |
| |
| PrefService* pref_service = profile_->GetPrefs(); |
| if (!hats_config_->prioritized) { |
| pref_service->SetInt64(prefs::kHatsLastInteractionTimestamp, |
| base::Time::Now().since_origin().InMicroseconds()); |
| } else { |
| pref_service->SetTime( |
| hats_config_->survey_last_interaction_timestamp_pref_name, |
| base::Time::Now()); |
| pref_service->SetTime(prefs::kHatsPrioritizedLastInteractionTimestamp, |
| base::Time::Now()); |
| } |
| } |
| |
| } // namespace ash |