| // Copyright 2020 The Chromium Authors |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| #include "ash/clipboard/clipboard_nudge_controller.h" |
| |
| #include "ash/clipboard/clipboard_history_item.h" |
| #include "ash/clipboard/clipboard_history_util.h" |
| #include "ash/clipboard/clipboard_nudge.h" |
| #include "ash/clipboard/clipboard_nudge_constants.h" |
| #include "ash/constants/ash_features.h" |
| #include "ash/constants/ash_pref_names.h" |
| #include "ash/constants/notifier_catalogs.h" |
| #include "ash/public/cpp/resources/grit/ash_public_unscaled_resources.h" |
| #include "ash/public/cpp/system/anchored_nudge_data.h" |
| #include "ash/public/cpp/system/anchored_nudge_manager.h" |
| #include "ash/session/session_controller_impl.h" |
| #include "ash/shell.h" |
| #include "ash/strings/grit/ash_strings.h" |
| #include "base/json/values_util.h" |
| #include "base/metrics/histogram_functions.h" |
| #include "base/notreached.h" |
| #include "chromeos/constants/chromeos_features.h" |
| #include "components/prefs/pref_registry_simple.h" |
| #include "components/prefs/pref_service.h" |
| #include "components/prefs/scoped_user_pref_update.h" |
| #include "ui/base/clipboard/clipboard_monitor.h" |
| #include "ui/base/l10n/l10n_util.h" |
| #include "ui/base/resource/resource_bundle.h" |
| |
| namespace ash { |
| namespace { |
| |
| // Clock that can be overridden for testing. |
| base::Clock* g_clock_override = nullptr; |
| |
| // Capped nudge constants ------------------------------------------------------ |
| // The pref keys used by the capped nudges (i.e. the nudges that have a |
| // limited number of times they can be shown to a user). The associated pref |
| // data are recorded across user sessions. |
| |
| // The last time shown, shared by all capped nudges. Updated when a nudge shows. |
| constexpr char kCappedNudgeLastTimeShown[] = "last_time_shown"; |
| |
| // The shown count of duplicate copy nudges. |
| constexpr char kShownCountDuplicateCopyNudge[] = |
| "shown_count_duplicate_copy_nudge"; |
| |
| // The shown count of onboarding nudges. |
| constexpr char kShownCountOnboardingNudge[] = "shown_count"; |
| |
| // Constants ------------------------------------------------------------------- |
| |
| // The id used for clipboard nudges. |
| constexpr char kClipboardNudgeId[] = "ClipboardContextualNudge"; |
| |
| // The maximum number of 1 second buckets, used to record the time delta between |
| // when a nudge shows and when the clipboard history menu shows or clipboard |
| // history data is pasted. |
| constexpr int kMaxSeconds = 61; |
| |
| // Helpers --------------------------------------------------------------------- |
| |
| int GetBodyTextStringId(ClipboardNudgeType nudge_type) { |
| switch (nudge_type) { |
| case ClipboardNudgeType::kDuplicateCopyNudge: |
| return IDS_ASH_MULTIPASTE_DUPLICATE_COPY_NUDGE; |
| case ClipboardNudgeType::kOnboardingNudge: |
| return IDS_ASH_MULTIPASTE_CONTEXTUAL_NUDGE; |
| case ClipboardNudgeType::kScreenshotNotificationNudge: |
| return IDS_ASH_MULTIPASTE_SCREENSHOT_NOTIFICATION_NUDGE; |
| case ClipboardNudgeType::kZeroStateNudge: |
| return IDS_ASH_MULTIPASTE_ZERO_STATE_CONTEXTUAL_NUDGE; |
| } |
| } |
| |
| NudgeCatalogName GetCatalogName(ClipboardNudgeType type) { |
| switch (type) { |
| case kOnboardingNudge: |
| return NudgeCatalogName::kClipboardHistoryOnboarding; |
| case kZeroStateNudge: |
| return NudgeCatalogName::kClipboardHistoryZeroState; |
| case kScreenshotNotificationNudge: |
| NOTREACHED(); |
| break; |
| case kDuplicateCopyNudge: |
| return NudgeCatalogName::kClipboardHistoryDuplicateCopy; |
| } |
| return NudgeCatalogName::kTestCatalogName; |
| } |
| |
| ui::ImageModel GetImage(ClipboardNudgeType type) { |
| switch (type) { |
| case kDuplicateCopyNudge: |
| case kOnboardingNudge: |
| return ui::ResourceBundle::GetSharedInstance().GetThemedLottieImageNamed( |
| IDR_CLIPBOARD_NUDGE_COPIED_IMAGE); |
| case kScreenshotNotificationNudge: |
| case kZeroStateNudge: |
| return ui::ResourceBundle::GetSharedInstance().GetThemedLottieImageNamed( |
| IDR_CLIPBOARD_NUDGE_SELECT_IMAGE); |
| } |
| } |
| |
| base::Time GetTime() { |
| return g_clock_override ? g_clock_override->Now() : base::Time::Now(); |
| } |
| |
| // Capped nudge helpers -------------------------------------------------------- |
| |
| // Returns true if `type` indicates a capped nudge. |
| bool IsCappedNudge(ClipboardNudgeType type) { |
| switch (type) { |
| case kOnboardingNudge: |
| case kDuplicateCopyNudge: |
| return true; |
| case kScreenshotNotificationNudge: |
| case kZeroStateNudge: |
| return false; |
| } |
| } |
| |
| // Gets the pref key to the shown count of the specified capped nudge. |
| const char* GetCappedNudgeShownCountPrefKey(ClipboardNudgeType type) { |
| CHECK(IsCappedNudge(type)); |
| switch (type) { |
| case kOnboardingNudge: |
| return kShownCountOnboardingNudge; |
| case kDuplicateCopyNudge: |
| return kShownCountDuplicateCopyNudge; |
| case kScreenshotNotificationNudge: |
| case kZeroStateNudge: |
| NOTREACHED_NORETURN(); |
| } |
| } |
| |
| // Gets the number of times the specified capped nudge has shown across user |
| // sessions. |
| int GetCappedNudgeShownCount(const PrefService& prefs, |
| ClipboardNudgeType type) { |
| return prefs.GetDict(prefs::kMultipasteNudges) |
| .FindInt(GetCappedNudgeShownCountPrefKey(type)) |
| .value_or(0); |
| } |
| |
| // Gets the last time the capped nudge was shown across user sessions. |
| base::Time GetCappedNudgeLastShownTime(const PrefService& prefs) { |
| const std::optional<base::Time> last_shown_time = base::ValueToTime( |
| prefs.GetDict(prefs::kMultipasteNudges).Find(kCappedNudgeLastTimeShown)); |
| return last_shown_time.value_or(base::Time()); |
| } |
| |
| // Checks if a capped nudge of the specified `type` can be shown. Returns true |
| // if: |
| // 1. The specified nudge's shown count is below the threshold; AND |
| // 2. Enough time has elapsed since the last capped nudge, if any, was shown. |
| bool ShouldShowCappedNudge(const PrefService& prefs, ClipboardNudgeType type) { |
| // We should not show more nudges after hitting the limit. |
| if (GetCappedNudgeShownCount(prefs, type) >= kCappedNudgeShownLimit) { |
| return false; |
| } |
| |
| // Returns true if: |
| // 1. No capped nudge has been shown; OR |
| // 2. Enough time has elapsed since the last capped nudge was shown. |
| const base::Time last_shown_time = GetCappedNudgeLastShownTime(prefs); |
| return last_shown_time.is_null() || |
| GetTime() - last_shown_time > kCappedNudgeMinInterval; |
| } |
| |
| } // namespace |
| |
| // ClipboardNudgeController::NudgeTimeDeltaRecorder --------------------------- |
| |
| constexpr ClipboardNudgeController::NudgeTimeDeltaRecorder:: |
| NudgeTimeDeltaRecorder(ClipboardNudgeType nudge_type) |
| : nudge_type_(nudge_type) {} |
| |
| ClipboardNudgeController::NudgeTimeDeltaRecorder::~NudgeTimeDeltaRecorder() { |
| Reset(); |
| } |
| |
| void ClipboardNudgeController::NudgeTimeDeltaRecorder::OnNudgeShown() { |
| Reset(); |
| nudge_shown_time_ = GetTime(); |
| } |
| |
| void ClipboardNudgeController::NudgeTimeDeltaRecorder:: |
| OnClipboardHistoryPasted() { |
| if (ShouldRecordClipboardHistoryPasteTimeDelta()) { |
| base::UmaHistogramExactLinear( |
| GetClipboardHistoryPasteTimeDeltaHistogram(nudge_type_), |
| GetTimeSinceNudgeShown().InSeconds(), kMaxSeconds); |
| has_recorded_paste_ = true; |
| } |
| } |
| |
| void ClipboardNudgeController::NudgeTimeDeltaRecorder:: |
| OnClipboardHistoryMenuShown() { |
| if (ShouldRecordMenuOpenTimeDelta()) { |
| base::UmaHistogramExactLinear(GetMenuOpenTimeDeltaHistogram(nudge_type_), |
| GetTimeSinceNudgeShown().InSeconds(), |
| kMaxSeconds); |
| has_recorded_menu_shown_ = true; |
| } |
| } |
| |
| void ClipboardNudgeController::NudgeTimeDeltaRecorder::Reset() { |
| // Record `kMaxSeconds` if the standalone clipboard history menu has never |
| // shown since the last nudge shown, if any. |
| if (ShouldRecordMenuOpenTimeDelta()) { |
| base::UmaHistogramExactLinear(GetMenuOpenTimeDeltaHistogram(nudge_type_), |
| kMaxSeconds, kMaxSeconds); |
| } |
| |
| // Record `kMaxSeconds` if the clipboard history data has never been pasted |
| // since the last nudge shown, if any. |
| if (ShouldRecordClipboardHistoryPasteTimeDelta()) { |
| base::UmaHistogramExactLinear( |
| GetClipboardHistoryPasteTimeDeltaHistogram(nudge_type_), kMaxSeconds, |
| kMaxSeconds); |
| } |
| |
| nudge_shown_time_ = base::Time(); |
| has_recorded_menu_shown_ = false; |
| has_recorded_paste_ = false; |
| } |
| |
| base::TimeDelta |
| ClipboardNudgeController::NudgeTimeDeltaRecorder::GetTimeSinceNudgeShown() |
| const { |
| CHECK(!nudge_shown_time_.is_null()); |
| return GetTime() - nudge_shown_time_; |
| } |
| |
| bool ClipboardNudgeController::NudgeTimeDeltaRecorder:: |
| ShouldRecordClipboardHistoryPasteTimeDelta() const { |
| return !nudge_shown_time_.is_null() && !has_recorded_paste_; |
| } |
| |
| bool ClipboardNudgeController::NudgeTimeDeltaRecorder:: |
| ShouldRecordMenuOpenTimeDelta() const { |
| return !nudge_shown_time_.is_null() && !has_recorded_menu_shown_; |
| } |
| |
| // ClipboardNudgeController ---------------------------------------------------- |
| |
| ClipboardNudgeController::ClipboardNudgeController( |
| ClipboardHistory* clipboard_history) { |
| clipboard_history_observation_.Observe(clipboard_history); |
| clipboard_history_controller_observation_.Observe( |
| ClipboardHistoryController::Get()); |
| clipboard_monitor_observation_.Observe(ui::ClipboardMonitor::GetInstance()); |
| } |
| |
| ClipboardNudgeController::~ClipboardNudgeController() = default; |
| |
| // static |
| void ClipboardNudgeController::RegisterProfilePrefs( |
| PrefRegistrySimple* registry) { |
| registry->RegisterDictionaryPref(prefs::kMultipasteNudges); |
| } |
| |
| void ClipboardNudgeController::OnClipboardHistoryItemAdded( |
| const ClipboardHistoryItem& item, |
| bool is_duplicate) { |
| const PrefService* const prefs = |
| Shell::Get()->session_controller()->GetLastActiveUserPrefService(); |
| if (!prefs) { |
| return; |
| } |
| |
| if (ShouldShowCappedNudge(*prefs, ClipboardNudgeType::kOnboardingNudge)) { |
| switch (onboarding_state_) { |
| case OnboardingState::kInit: |
| onboarding_state_ = OnboardingState::kFirstCopy; |
| break; |
| case OnboardingState::kFirstPaste: |
| onboarding_state_ = OnboardingState::kSecondCopy; |
| break; |
| case OnboardingState::kFirstCopy: |
| case OnboardingState::kSecondCopy: |
| break; |
| } |
| } |
| |
| if (chromeos::features::IsClipboardHistoryRefreshEnabled() && is_duplicate && |
| ShouldShowCappedNudge(*prefs, ClipboardNudgeType::kDuplicateCopyNudge)) { |
| ShowNudge(ClipboardNudgeType::kDuplicateCopyNudge); |
| } |
| } |
| |
| std::optional<base::Time> ClipboardNudgeController::GetNudgeLastTimeShown() |
| const { |
| const base::Time& nudge_last_time_shown = |
| base::ranges::max( |
| {&duplicate_copy_nudge_recorder_, &onboarding_nudge_recorder_, |
| &screenshot_nudge_recorder_, &zero_state_nudge_recorder_}, |
| /*comp=*/{}, /*proj=*/&NudgeTimeDeltaRecorder::nudge_shown_time) |
| ->nudge_shown_time(); |
| |
| return nudge_last_time_shown.is_null() |
| ? std::nullopt |
| : std::make_optional(nudge_last_time_shown); |
| } |
| |
| void ClipboardNudgeController::MarkScreenshotNotificationShown() { |
| base::UmaHistogramBoolean(kClipboardHistoryScreenshotNotificationShowCount, |
| true); |
| screenshot_nudge_recorder_.OnNudgeShown(); |
| } |
| |
| void ClipboardNudgeController::OnClipboardDataRead() { |
| if (const PrefService* const prefs = |
| Shell::Get()->session_controller()->GetLastActiveUserPrefService(); |
| clipboard_history_util::IsEnabledInCurrentMode() && prefs && |
| ShouldShowCappedNudge(*prefs, ClipboardNudgeType::kOnboardingNudge)) { |
| switch (onboarding_state_) { |
| case OnboardingState::kInit: |
| return; |
| case OnboardingState::kFirstCopy: |
| onboarding_state_ = OnboardingState::kFirstPaste; |
| last_paste_timestamp_ = GetTime(); |
| return; |
| case OnboardingState::kFirstPaste: |
| // Subsequent pastes should reset the timestamp. |
| last_paste_timestamp_ = GetTime(); |
| return; |
| case OnboardingState::kSecondCopy: |
| if (GetTime() - last_paste_timestamp_ < kMaxTimeBetweenPaste) { |
| ShowNudge(ClipboardNudgeType::kOnboardingNudge); |
| } else { |
| // Reset `onboarding_state_` to `kFirstPaste` when too much time has |
| // elapsed since the last paste. |
| onboarding_state_ = OnboardingState::kFirstPaste; |
| last_paste_timestamp_ = GetTime(); |
| } |
| return; |
| } |
| } |
| } |
| |
| void ClipboardNudgeController::OnClipboardHistoryMenuShown( |
| crosapi::mojom::ClipboardHistoryControllerShowSource show_source) { |
| // The clipboard history nudges specifically suggest trying the Search+V |
| // shortcut. Opening the menu any other way should not count as the user |
| // responding to the nudge. |
| if (show_source != |
| crosapi::mojom::ClipboardHistoryControllerShowSource::kAccelerator) { |
| return; |
| } |
| |
| onboarding_nudge_recorder_.OnClipboardHistoryMenuShown(); |
| zero_state_nudge_recorder_.OnClipboardHistoryMenuShown(); |
| screenshot_nudge_recorder_.OnClipboardHistoryMenuShown(); |
| |
| if (features::IsSystemNudgeMigrationEnabled()) { |
| AnchoredNudgeManager::Get()->MaybeRecordNudgeAction( |
| NudgeCatalogName::kClipboardHistoryOnboarding); |
| AnchoredNudgeManager::Get()->MaybeRecordNudgeAction( |
| NudgeCatalogName::kClipboardHistoryZeroState); |
| } else { |
| SystemNudgeController::MaybeRecordNudgeAction( |
| NudgeCatalogName::kClipboardHistoryOnboarding); |
| SystemNudgeController::MaybeRecordNudgeAction( |
| NudgeCatalogName::kClipboardHistoryZeroState); |
| } |
| |
| if (chromeos::features::IsClipboardHistoryRefreshEnabled()) { |
| duplicate_copy_nudge_recorder_.OnClipboardHistoryMenuShown(); |
| if (features::IsSystemNudgeMigrationEnabled()) { |
| AnchoredNudgeManager::Get()->MaybeRecordNudgeAction( |
| NudgeCatalogName::kClipboardHistoryDuplicateCopy); |
| } else { |
| SystemNudgeController::MaybeRecordNudgeAction( |
| NudgeCatalogName::kClipboardHistoryDuplicateCopy); |
| } |
| } |
| } |
| |
| void ClipboardNudgeController::OnClipboardHistoryPasted() { |
| onboarding_nudge_recorder_.OnClipboardHistoryPasted(); |
| zero_state_nudge_recorder_.OnClipboardHistoryPasted(); |
| screenshot_nudge_recorder_.OnClipboardHistoryPasted(); |
| |
| if (chromeos::features::IsClipboardHistoryRefreshEnabled()) { |
| duplicate_copy_nudge_recorder_.OnClipboardHistoryPasted(); |
| } |
| } |
| |
| void ClipboardNudgeController::ShowNudge(ClipboardNudgeType nudge_type) { |
| current_nudge_type_ = nudge_type; |
| |
| if (features::IsSystemNudgeMigrationEnabled()) { |
| const std::u16string shortcut_key = |
| clipboard_history_util::GetShortcutKeyName(); |
| const std::u16string body_text = l10n_util::GetStringFUTF16( |
| GetBodyTextStringId(current_nudge_type_), shortcut_key); |
| |
| AnchoredNudgeData nudge_data( |
| kClipboardNudgeId, GetCatalogName(current_nudge_type_), body_text); |
| nudge_data.image_model = GetImage(current_nudge_type_); |
| |
| AnchoredNudgeManager::Get()->Show(nudge_data); |
| } else { |
| SystemNudgeController::ShowNudge(); |
| } |
| |
| switch (nudge_type) { |
| case ClipboardNudgeType::kOnboardingNudge: |
| onboarding_nudge_recorder_.OnNudgeShown(); |
| base::UmaHistogramBoolean(kClipboardHistoryOnboardingNudgeShowCount, |
| true); |
| break; |
| case ClipboardNudgeType::kZeroStateNudge: |
| zero_state_nudge_recorder_.OnNudgeShown(); |
| base::UmaHistogramBoolean(kClipboardHistoryZeroStateNudgeShowCount, true); |
| break; |
| case ClipboardNudgeType::kScreenshotNotificationNudge: |
| NOTREACHED_NORETURN(); |
| case ClipboardNudgeType::kDuplicateCopyNudge: |
| CHECK(chromeos::features::IsClipboardHistoryRefreshEnabled()); |
| duplicate_copy_nudge_recorder_.OnNudgeShown(); |
| base::UmaHistogramBoolean(kClipboardHistoryDuplicateCopyNudgeShowCount, |
| true); |
| break; |
| } |
| |
| // Reset `onboarding_state_`. |
| onboarding_state_ = OnboardingState::kInit; |
| |
| if (PrefService* const prefs = |
| Shell::Get()->session_controller()->GetLastActiveUserPrefService(); |
| prefs && IsCappedNudge(nudge_type)) { |
| ScopedDictPrefUpdate update(prefs, prefs::kMultipasteNudges); |
| update->Set(GetCappedNudgeShownCountPrefKey(nudge_type), |
| GetCappedNudgeShownCount(*prefs, nudge_type) + 1); |
| update->Set(kCappedNudgeLastTimeShown, base::TimeToValue(GetTime())); |
| } |
| } |
| |
| void ClipboardNudgeController::OverrideClockForTesting( |
| base::Clock* test_clock) { |
| DCHECK(!g_clock_override); |
| g_clock_override = test_clock; |
| } |
| |
| void ClipboardNudgeController::ClearClockOverrideForTesting() { |
| DCHECK(g_clock_override); |
| g_clock_override = nullptr; |
| } |
| |
| std::unique_ptr<SystemNudge> ClipboardNudgeController::CreateSystemNudge() { |
| return std::make_unique<ClipboardNudge>(current_nudge_type_, |
| GetCatalogName(current_nudge_type_)); |
| } |
| |
| } // namespace ash |