blob: cd930fd13c28812c0a900e8eabefc5abebfc9b74 [file] [log] [blame]
// Copyright 2020 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/user_education/feature_promo_controller.h"
#include "base/auto_reset.h"
#include "base/bind.h"
#include "base/callback_list.h"
#include "base/feature_list.h"
#include "base/metrics/histogram_functions.h"
#include "base/notreached.h"
#include "chrome/browser/ui/user_education/feature_promo_registry.h"
#include "chrome/browser/ui/user_education/feature_promo_snooze_service.h"
#include "chrome/browser/ui/user_education/help_bubble_factory_registry.h"
#include "chrome/browser/ui/user_education/help_bubble_params.h"
#include "chrome/browser/ui/user_education/tutorial/tutorial.h"
#include "chrome/browser/ui/user_education/tutorial/tutorial_service.h"
#include "chrome/grit/generated_resources.h"
#include "components/feature_engagement/public/event_constants.h"
#include "components/feature_engagement/public/feature_constants.h"
#include "ui/accessibility/ax_mode.h"
#include "ui/accessibility/platform/ax_platform_node.h"
#include "ui/base/interaction/element_tracker.h"
#include "ui/base/l10n/l10n_util.h"
#include "ui/views/style/platform_style.h"
FeaturePromoController::PromoHandle::PromoHandle() = default;
FeaturePromoController::PromoHandle::PromoHandle(
base::WeakPtr<FeaturePromoController> controller,
const base::Feature* feature)
: controller_(std::move(controller)), feature_(feature) {
DCHECK(feature_);
}
FeaturePromoController::PromoHandle::PromoHandle(PromoHandle&& other)
: controller_(std::move(other.controller_)),
feature_(std::exchange(other.feature_, nullptr)) {}
FeaturePromoController::PromoHandle::~PromoHandle() {
Release();
}
FeaturePromoController::PromoHandle&
FeaturePromoController::PromoHandle::operator=(PromoHandle&& other) {
if (this != &other) {
Release();
controller_ = std::move(other.controller_);
feature_ = std::exchange(other.feature_, nullptr);
}
return *this;
}
void FeaturePromoController::PromoHandle::Release() {
if (controller_)
controller_->FinishContinuedPromo(feature_);
controller_.reset();
feature_ = nullptr;
}
FeaturePromoController::FeaturePromoController() = default;
FeaturePromoController::~FeaturePromoController() = default;
FeaturePromoControllerCommon::FeaturePromoControllerCommon(
feature_engagement::Tracker* feature_engagement_tracker,
FeaturePromoRegistry* registry,
HelpBubbleFactoryRegistry* help_bubble_registry,
FeaturePromoSnoozeService* snooze_service,
TutorialService* tutorial_service)
: registry_(registry),
feature_engagement_tracker_(feature_engagement_tracker),
bubble_factory_registry_(help_bubble_registry),
snooze_service_(snooze_service),
tutorial_service_(tutorial_service) {
DCHECK(feature_engagement_tracker_);
DCHECK(bubble_factory_registry_);
DCHECK(snooze_service_);
}
FeaturePromoControllerCommon::~FeaturePromoControllerCommon() = default;
bool FeaturePromoControllerCommon::MaybeShowPromo(
const base::Feature& iph_feature,
FeaturePromoSpecification::StringReplacements body_text_replacements,
BubbleCloseCallback close_callback) {
const FeaturePromoSpecification* spec =
registry()->GetParamsForFeature(iph_feature);
if (!spec)
return false;
DCHECK_EQ(&iph_feature, spec->feature());
DCHECK(spec->anchor_element_id());
// Fetch the anchor element. For now, assume all elements are Views.
ui::TrackedElement* const anchor_element =
spec->GetAnchorElement(GetAnchorContext());
if (!anchor_element)
return false;
return MaybeShowPromoFromSpecification(*spec, anchor_element,
std::move(body_text_replacements),
std::move(close_callback));
}
bool FeaturePromoControllerCommon::MaybeShowPromoForDemoPage(
const base::Feature* iph_feature,
FeaturePromoSpecification::StringReplacements body_text_replacements,
BubbleCloseCallback close_callback) {
if (current_iph_feature_ && promo_bubble_)
CloseBubble(*current_iph_feature_);
iph_feature_bypassing_tracker_ = iph_feature;
bool showed_promo = MaybeShowPromo(*iph_feature);
if (!showed_promo && iph_feature_bypassing_tracker_)
iph_feature_bypassing_tracker_ = nullptr;
return showed_promo;
}
bool FeaturePromoControllerCommon::MaybeShowPromoFromSpecification(
const FeaturePromoSpecification& spec,
ui::TrackedElement* anchor_element,
FeaturePromoSpecification::StringReplacements body_text_replacements,
BubbleCloseCallback close_callback) {
CHECK(anchor_element);
if (promos_blocked_for_testing_)
return false;
// A normal promo cannot show if a critical promo is displayed. These
// are not registered with |tracker_| so check here.
if (critical_promo_bubble_)
return false;
// Some contexts and anchors are not appropriate for showing normal promos.
if (!CanShowPromo(anchor_element))
return false;
// Some checks should not be done in demo mode, because we absolutely want to
// trigger the bubble if possible. Put any checks that should be bypassed in
// demo mode in this block.
const base::Feature* feature = spec.feature();
bool feature_is_bypassing_tracker = feature == iph_feature_bypassing_tracker_;
if (!(base::FeatureList::IsEnabled(feature_engagement::kIPHDemoMode) ||
feature_is_bypassing_tracker) &&
snooze_service_->IsBlocked(*feature))
return false;
// Can't show a standard promo if another help bubble is visible.
if (bubble_factory_registry_->is_any_bubble_showing())
return false;
// TODO(crbug.com/1258216): Currently this must be called before
// ShouldTriggerHelpUI() below. See bug for details.
const bool screen_reader_available = CheckScreenReaderPromptAvailable();
if (!feature_is_bypassing_tracker &&
!feature_engagement_tracker_->ShouldTriggerHelpUI(*feature))
return false;
// If the tracker says we should trigger, but we have a promo
// currently showing, there is a bug somewhere in here.
DCHECK(!current_iph_feature_);
current_iph_feature_ = feature;
// Try to show the bubble and bail out if we cannot.
promo_bubble_ = ShowPromoBubbleImpl(
spec, anchor_element, std::move(body_text_replacements),
screen_reader_available, /* is_critical_promo =*/false);
if (!promo_bubble_) {
current_iph_feature_ = nullptr;
if (!feature_is_bypassing_tracker)
feature_engagement_tracker_->Dismissed(*feature);
return false;
}
bubble_closed_callback_ = std::move(close_callback);
// Record count of previous snoozes when an IPH triggers.
if (!feature_is_bypassing_tracker) {
int snooze_count = snooze_service_->GetSnoozeCount(*feature);
base::UmaHistogramExactLinear(
"InProductHelp.Promos.SnoozeCountAtTrigger." +
std::string(feature->name),
snooze_count, FeaturePromoSnoozeService::kUmaMaxSnoozeCount);
snooze_service_->OnPromoShown(*feature);
}
return true;
}
std::unique_ptr<HelpBubble> FeaturePromoControllerCommon::ShowCriticalPromo(
const FeaturePromoSpecification& spec,
ui::TrackedElement* anchor_element,
FeaturePromoSpecification::StringReplacements body_text_replacements) {
if (promos_blocked_for_testing_)
return nullptr;
// Don't preempt an existing critical promo.
if (critical_promo_bubble_)
return nullptr;
// If a normal bubble is showing, close it. Won't affect a promo continued
// after its bubble has closed.
if (current_iph_feature_)
CloseBubble(*current_iph_feature_);
// Snooze and tutorial are not supported for critical promos.
DCHECK_NE(FeaturePromoSpecification::PromoType::kSnooze, spec.promo_type());
DCHECK_NE(FeaturePromoSpecification::PromoType::kTutorial, spec.promo_type());
// Create the bubble.
auto bubble = ShowPromoBubbleImpl(
spec, anchor_element, std::move(body_text_replacements),
CheckScreenReaderPromptAvailable(), /* is_critical_promo =*/true);
critical_promo_bubble_ = bubble.get();
return bubble;
}
bool FeaturePromoControllerCommon::IsPromoActive(
const base::Feature& iph_feature,
bool include_continued_promos) const {
if (current_iph_feature_ != &iph_feature)
return false;
return include_continued_promos ||
(promo_bubble_ && promo_bubble_->is_open());
}
bool FeaturePromoControllerCommon::CloseBubble(
const base::Feature& iph_feature) {
if (current_iph_feature_ != &iph_feature)
return false;
const bool was_open = promo_bubble_ && promo_bubble_->is_open();
if (promo_bubble_)
promo_bubble_->Close();
if (iph_feature_bypassing_tracker_ == &iph_feature)
iph_feature_bypassing_tracker_ = nullptr;
return was_open;
}
bool FeaturePromoControllerCommon::DismissNonCriticalBubbleInRegion(
const gfx::Rect& screen_bounds) {
if (promo_bubble_ && promo_bubble_->is_open() &&
promo_bubble_->GetBoundsInScreen().Intersects(screen_bounds)) {
const bool result = CloseBubble(*current_iph_feature_);
DCHECK(result);
return result;
}
return false;
}
FeaturePromoController::PromoHandle
FeaturePromoControllerCommon::CloseBubbleAndContinuePromo(
const base::Feature& iph_feature) {
DCHECK_EQ(current_iph_feature_, &iph_feature);
continuing_after_bubble_closed_ = true;
const bool result = CloseBubble(iph_feature);
DCHECK(result);
return PromoHandle(GetAsWeakPtr(), &iph_feature);
}
base::WeakPtr<FeaturePromoController>
FeaturePromoControllerCommon::GetAsWeakPtr() {
return weak_ptr_factory_.GetWeakPtr();
}
FeaturePromoControllerCommon::TestLock
FeaturePromoControllerCommon::BlockPromosForTesting() {
if (current_iph_feature_)
CloseBubble(*current_iph_feature_);
return std::make_unique<base::AutoReset<bool>>(&promos_blocked_for_testing_,
true);
}
bool FeaturePromoControllerCommon::CheckScreenReaderPromptAvailable() const {
if (!ui::AXPlatformNode::GetAccessibilityMode().has_mode(
ui::AXMode::kScreenReader)) {
return false;
}
// If we're in demo mode and screen reader is on, always play the demo
// without querying the FE backend, since the backend will return false for
// all promos other than the one that's being demoed. If we didn't have this
// code the screen reader prompt would never play.
if (base::FeatureList::IsEnabled(feature_engagement::kIPHDemoMode) ||
iph_feature_bypassing_tracker_)
return true;
if (!feature_engagement_tracker_->ShouldTriggerHelpUI(
feature_engagement::kIPHFocusHelpBubbleScreenReaderPromoFeature)) {
return false;
}
// TODO(crbug.com/1258216): Once we have our answer, immediately dismiss
// so that this doesn't interfere with actually showing the bubble. This
// dismiss can be moved elsewhere once we support concurrency.
feature_engagement_tracker_->Dismissed(
feature_engagement::kIPHFocusHelpBubbleScreenReaderPromoFeature);
return true;
}
std::unique_ptr<HelpBubble> FeaturePromoControllerCommon::ShowPromoBubbleImpl(
const FeaturePromoSpecification& spec,
ui::TrackedElement* anchor_element,
FeaturePromoSpecification::StringReplacements body_text_replacements,
bool screen_reader_prompt_available,
bool is_critical_promo) {
HelpBubbleParams create_params;
create_params.body_text = l10n_util::GetStringFUTF16(
spec.bubble_body_string_id(), std::move(body_text_replacements), nullptr);
create_params.title_text = spec.bubble_title_text();
if (spec.screen_reader_string_id()) {
create_params.screenreader_text =
spec.screen_reader_accelerator()
? l10n_util::GetStringFUTF16(
spec.screen_reader_string_id(),
spec.screen_reader_accelerator()
.GetAccelerator(GetAcceleratorProvider())
.GetShortcutText())
: l10n_util::GetStringUTF16(spec.screen_reader_string_id());
}
create_params.body_icon = spec.bubble_icon();
create_params.arrow = spec.bubble_arrow();
// Critical promos don't time out.
if (is_critical_promo)
create_params.timeout = base::Seconds(0);
if (spec.promo_type() == FeaturePromoSpecification::PromoType::kSnooze &&
base::FeatureList::IsEnabled(
feature_engagement::kIPHDesktopSnoozeFeature)) {
CHECK(spec.feature());
create_params.buttons = CreateSnoozeButtons(*spec.feature());
} else if (spec.promo_type() ==
FeaturePromoSpecification::PromoType::kTutorial) {
CHECK(spec.feature());
create_params.buttons =
CreateTutorialButtons(*spec.feature(), spec.tutorial_id());
create_params.force_close_button = true;
}
// Feature isn't present for some critical promos.
if (spec.feature()) {
create_params.dismiss_callback = base::BindOnce(
&FeaturePromoControllerCommon::OnHelpBubbleDismissed,
weak_ptr_factory_.GetWeakPtr(), base::Unretained(spec.feature()));
}
if (CheckScreenReaderPromptAvailable()) {
create_params.keyboard_navigation_hint = GetFocusHelpBubbleScreenReaderHint(
spec.promo_type(), anchor_element, is_critical_promo);
}
const bool had_screen_reader_promo =
!create_params.keyboard_navigation_hint.empty();
auto help_bubble = bubble_factory_registry_->CreateHelpBubble(
anchor_element, std::move(create_params));
if (help_bubble) {
// Record that the focus help message was actually read to the user. See the
// note in MaybeShowPromoImpl().
// TODO(crbug.com/1258216): Rewrite this when we have the ability for FE
// promos to ignore other active promos.
if (had_screen_reader_promo) {
feature_engagement_tracker_->NotifyEvent(
feature_engagement::events::kFocusHelpBubbleAcceleratorPromoRead);
}
// Listen for the bubble being closed.
bubble_closed_subscription_ = help_bubble->AddOnCloseCallback(
base::BindOnce(&FeaturePromoControllerCommon::OnHelpBubbleClosed,
base::Unretained(this)));
}
return help_bubble;
}
void FeaturePromoControllerCommon::FinishContinuedPromo(
const base::Feature* iph_feature) {
DCHECK(continuing_after_bubble_closed_);
if (iph_feature_bypassing_tracker_ != iph_feature)
feature_engagement_tracker_->Dismissed(*iph_feature);
else
iph_feature_bypassing_tracker_ = nullptr;
if (current_iph_feature_ == iph_feature) {
current_iph_feature_ = nullptr;
continuing_after_bubble_closed_ = false;
}
}
void FeaturePromoControllerCommon::OnHelpBubbleClosed(HelpBubble* bubble) {
// Since we're in the middle of processing callbacks we can't reset our
// subscription but since it's a weak pointer (internally) and since we should
// should only get called here once, it's not a big deal if we don't reset
// it.
if (bubble == critical_promo_bubble_) {
critical_promo_bubble_ = nullptr;
} else if (bubble == promo_bubble_.get()) {
if (!continuing_after_bubble_closed_) {
if (iph_feature_bypassing_tracker_ != current_iph_feature_)
feature_engagement_tracker_->Dismissed(*current_iph_feature_);
else
iph_feature_bypassing_tracker_ = nullptr;
current_iph_feature_ = nullptr;
}
promo_bubble_.reset();
} else {
NOTREACHED();
}
if (bubble_closed_callback_)
std::move(bubble_closed_callback_).Run();
}
void FeaturePromoControllerCommon::OnHelpBubbleSnoozed(
const base::Feature* feature) {
if (iph_feature_bypassing_tracker_ != feature)
snooze_service_->OnUserSnooze(*feature);
}
void FeaturePromoControllerCommon::OnHelpBubbleDismissed(
const base::Feature* feature) {
if (snooze_service_ && iph_feature_bypassing_tracker_ != feature)
snooze_service_->OnUserDismiss(*feature);
}
void FeaturePromoControllerCommon::OnTutorialStarted(
const base::Feature* iph_feature,
TutorialIdentifier tutorial_id) {
if (!promo_bubble_ || !promo_bubble_->is_open()) {
NOTREACHED();
} else {
DCHECK_EQ(current_iph_feature_, iph_feature);
tutorial_promo_handle_ = CloseBubbleAndContinuePromo(*iph_feature);
DCHECK(tutorial_promo_handle_.is_valid());
tutorial_service_->StartTutorial(
tutorial_id, GetAnchorContext(),
base::BindOnce(&FeaturePromoControllerCommon::OnTutorialComplete,
weak_ptr_factory_.GetWeakPtr(),
base::Unretained(iph_feature)),
base::BindOnce(&FeaturePromoControllerCommon::OnTutorialAborted,
weak_ptr_factory_.GetWeakPtr(),
base::Unretained(iph_feature)));
}
}
void FeaturePromoControllerCommon::OnTutorialComplete(
const base::Feature* iph_feature) {
tutorial_promo_handle_.Release();
if (snooze_service_)
snooze_service_->OnUserDismiss(*iph_feature);
}
void FeaturePromoControllerCommon::OnTutorialAborted(
const base::Feature* iph_feature) {
tutorial_promo_handle_.Release();
if (snooze_service_)
snooze_service_->OnUserSnooze(*iph_feature);
}
std::vector<HelpBubbleButtonParams>
FeaturePromoControllerCommon::CreateSnoozeButtons(
const base::Feature& feature) {
std::vector<HelpBubbleButtonParams> buttons;
HelpBubbleButtonParams snooze_button;
snooze_button.text = GetSnoozeButtonText();
snooze_button.is_default = false;
snooze_button.callback = base::BindOnce(
&FeaturePromoControllerCommon::OnHelpBubbleSnoozed,
weak_ptr_factory_.GetWeakPtr(), base::Unretained(&feature));
buttons.push_back(std::move(snooze_button));
HelpBubbleButtonParams dismiss_button;
dismiss_button.text = GetDismissButtonText();
dismiss_button.is_default = true;
dismiss_button.callback = base::BindOnce(
&FeaturePromoControllerCommon::OnHelpBubbleDismissed,
weak_ptr_factory_.GetWeakPtr(), base::Unretained(&feature));
buttons.push_back(std::move(dismiss_button));
if (IsOkButtonLeading())
std::swap(buttons[0], buttons[1]);
return buttons;
}
std::vector<HelpBubbleButtonParams>
FeaturePromoControllerCommon::CreateTutorialButtons(
const base::Feature& feature,
TutorialIdentifier tutorial_id) {
std::vector<HelpBubbleButtonParams> buttons;
if (base::FeatureList::IsEnabled(
feature_engagement::kIPHDesktopSnoozeFeature)) {
HelpBubbleButtonParams snooze_button;
snooze_button.text = l10n_util::GetStringUTF16(IDS_PROMO_SNOOZE_BUTTON);
snooze_button.is_default = false;
snooze_button.callback = base::BindRepeating(
&FeaturePromoControllerCommon::OnHelpBubbleSnoozed,
weak_ptr_factory_.GetWeakPtr(), base::Unretained(&feature));
buttons.push_back(std::move(snooze_button));
} else {
HelpBubbleButtonParams dismiss_button;
dismiss_button.text = l10n_util::GetStringUTF16(IDS_PROMO_DISMISS_BUTTON);
dismiss_button.is_default = false;
dismiss_button.callback = base::BindRepeating(
&FeaturePromoControllerCommon::OnHelpBubbleDismissed,
weak_ptr_factory_.GetWeakPtr(), base::Unretained(&feature));
buttons.push_back(std::move(dismiss_button));
}
HelpBubbleButtonParams tutorial_button;
tutorial_button.text =
l10n_util::GetStringUTF16(IDS_PROMO_SHOW_TUTORIAL_BUTTON);
tutorial_button.is_default = true;
tutorial_button.callback = base::BindRepeating(
&FeaturePromoControllerCommon::OnTutorialStarted,
weak_ptr_factory_.GetWeakPtr(), base::Unretained(&feature), tutorial_id);
buttons.push_back(std::move(tutorial_button));
if (views::PlatformStyle::kIsOkButtonLeading)
std::swap(buttons[0], buttons[1]);
return buttons;
}
// static
bool FeaturePromoControllerCommon::active_window_check_blocked_ = false;
// static
FeaturePromoControllerCommon::TestLock
FeaturePromoControllerCommon::BlockActiveWindowCheckForTesting() {
return std::make_unique<base::AutoReset<bool>>(&active_window_check_blocked_,
true);
}