blob: 476a34d3ce8a76696ee59d19c0fa12fbe1292ecb [file] [log] [blame]
// Copyright 2023 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/user_education/welcome_tour/welcome_tour_controller.h"
#include <string_view>
#include "ash/accessibility/accessibility_controller.h"
#include "ash/app_list/app_list_controller_impl.h"
#include "ash/ash_element_identifiers.h"
#include "ash/constants/ash_features.h"
#include "ash/public/cpp/app_list/app_list_metrics.h"
#include "ash/public/cpp/system/scoped_nudge_pause.h"
#include "ash/public/cpp/system/scoped_toast_pause.h"
#include "ash/public/cpp/system/system_nudge_pause_manager.h"
#include "ash/public/cpp/system/toast_manager.h"
#include "ash/session/session_controller_impl.h"
#include "ash/shell.h"
#include "ash/strings/grit/ash_strings.h"
#include "ash/user_education/user_education_controller.h"
#include "ash/user_education/user_education_tutorial_controller.h"
#include "ash/user_education/user_education_types.h"
#include "ash/user_education/user_education_util.h"
#include "ash/user_education/welcome_tour/welcome_tour_accelerator_handler.h"
#include "ash/user_education/welcome_tour/welcome_tour_controller_observer.h"
#include "ash/user_education/welcome_tour/welcome_tour_dialog.h"
#include "ash/user_education/welcome_tour/welcome_tour_metrics.h"
#include "ash/user_education/welcome_tour/welcome_tour_notification_blocker.h"
#include "ash/user_education/welcome_tour/welcome_tour_scrim.h"
#include "ash/user_education/welcome_tour/welcome_tour_window_minimizer.h"
#include "ash/webui/system_apps/public/system_web_app_type.h"
#include "base/check_is_test.h"
#include "base/check_op.h"
#include "base/functional/bind.h"
#include "base/functional/callback_helpers.h"
#include "base/timer/elapsed_timer.h"
#include "components/user_education/common/events.h"
#include "components/user_education/common/help_bubble.h"
#include "components/user_education/common/tutorial_description.h"
#include "components/user_manager/user_type.h"
#include "ui/base/interaction/element_identifier.h"
#include "ui/base/interaction/element_tracker.h"
#include "ui/base/interaction/interaction_sequence.h"
#include "ui/base/ui_base_types.h"
#include "ui/display/display.h"
#include "ui/display/screen.h"
#include "ui/display/tablet_state.h"
#include "ui/events/base_event_utils.h"
#include "ui/gfx/paint_vector_icon.h"
#include "ui/views/interaction/element_tracker_views.h"
#include "ui/views/view.h"
namespace ash {
namespace {
// The singleton instance owned by the `UserEducationController`.
WelcomeTourController* g_instance = nullptr;
// Helpers ---------------------------------------------------------------------
user_education::HelpBubbleParams::ExtendedProperties
CreateHelpBubbleExtendedProperties(HelpBubbleId help_bubble_id) {
return user_education_util::CreateExtendedProperties(
user_education_util::CreateExtendedProperties(help_bubble_id),
user_education_util::CreateExtendedProperties(ui::MODAL_TYPE_SYSTEM),
user_education_util::CreateExtendedProperties(
/*body_icon=*/gfx::kNoneIcon));
}
base::RepeatingCallback<void(ui::TrackedElement*)> DefaultNextButtonCallback() {
return base::BindRepeating([](ui::TrackedElement* current_anchor) {
ui::ElementTracker::GetFrameworkDelegate()->NotifyCustomEvent(
current_anchor, user_education::kHelpBubbleNextButtonClickedEvent);
});
}
int64_t GetPrimaryDisplayId() {
return display::Screen::GetScreen()->GetPrimaryDisplay().id();
}
views::View* GetMatchingViewInPrimaryRootWindow(
ui::ElementIdentifier element_id) {
return user_education_util::GetMatchingViewInRootWindow(GetPrimaryDisplayId(),
element_id);
}
views::TrackedElementViews* GetMatchingElementInPrimaryRootWindow(
ui::ElementIdentifier element_id) {
return views::ElementTrackerViews::GetInstance()->GetElementForView(
GetMatchingViewInPrimaryRootWindow(element_id));
}
void LaunchExploreAppAsync(UserEducationPrivateApiKey key) {
UserEducationController::Get()->LaunchSystemWebAppAsync(
key, ash::SystemWebAppType::HELP,
display::Screen::GetScreen()->GetPrimaryDisplay().id());
}
user_education::TutorialDescription::NameElementsCallback
NameMatchingElementInPrimaryRootWindowCallback(ui::ElementIdentifier element_id,
const char* element_name) {
return base::BindRepeating(
[](ui::ElementIdentifier element_id, const char* element_name,
ui::InteractionSequence* sequence, ui::TrackedElement*) {
if (auto* element = GetMatchingElementInPrimaryRootWindow(element_id)) {
sequence->NameElement(element, std::string_view(element_name));
return true;
}
return false;
},
element_id, element_name);
}
} // namespace
// WelcomeTourController -------------------------------------------------------
WelcomeTourController::WelcomeTourController() {
CHECK_EQ(g_instance, nullptr);
g_instance = this;
session_observation_.Observe(Shell::Get()->session_controller());
MaybeStartWelcomeTour();
}
WelcomeTourController::~WelcomeTourController() {
CHECK_EQ(g_instance, this);
g_instance = nullptr;
}
// static
WelcomeTourController* WelcomeTourController::Get() {
return g_instance;
}
void WelcomeTourController::AddObserver(
WelcomeTourControllerObserver* observer) {
observer_list_.AddObserver(observer);
}
void WelcomeTourController::RemoveObserver(
WelcomeTourControllerObserver* observer) {
observer_list_.RemoveObserver(observer);
}
ui::ElementContext WelcomeTourController::GetInitialElementContext() const {
// NOTE: Don't use `GetMatchingElementInPrimaryRootWindow()` here as
// `views::TrackedElementViews` only exist while views are shown and that may
// not be the case when this method is called.
return views::ElementTrackerViews::GetContextForView(
GetMatchingViewInPrimaryRootWindow(kShelfViewElementId));
}
user_education::TutorialDescription
WelcomeTourController::GetTutorialDescription() const {
user_education::TutorialDescription tutorial_description;
tutorial_description.complete_button_text_id =
IDS_ASH_WELCOME_TOUR_COMPLETE_BUTTON_TEXT;
// Step 0: Dialog.
tutorial_description.steps.emplace_back(
user_education::TutorialDescription::HiddenStep::WaitForShown(
kWelcomeTourDialogElementId)
.InAnyContext());
// Wait for the dialog to be hidden before proceeding to the next bubble step.
// Note that if the dialog is closed without the user having accepted it, the
// Welcome Tour will be aborted and the next bubble step will not be reached.
tutorial_description.steps.emplace_back(
user_education::TutorialDescription::HiddenStep::WaitForHidden(
kWelcomeTourDialogElementId)
.InSameContext());
// Step 1: Shelf.
tutorial_description.steps.emplace_back(
user_education::TutorialDescription::BubbleStep(kShelfViewElementId)
.SetBubbleArrow(user_education::HelpBubbleArrow::kBottomCenter)
.SetBubbleBodyText(IDS_ASH_WELCOME_TOUR_SHELF_BUBBLE_BODY_TEXT)
.SetExtendedProperties(CreateHelpBubbleExtendedProperties(
HelpBubbleId::kWelcomeTourShelf))
.AddCustomNextButton(DefaultNextButtonCallback().Then(
base::BindRepeating(&WelcomeTourController::SetCurrentStep,
weak_ptr_factory_.GetMutableWeakPtr(),
welcome_tour_metrics::Step::kStatusArea))));
// Wait for "Next" button click before proceeding to the next bubble step.
// NOTE: This event step also ensures that the next bubble step will show on
// the primary display by naming the primary root window's status area.
tutorial_description.steps.emplace_back(
user_education::TutorialDescription::EventStep(
user_education::kHelpBubbleNextButtonClickedEvent,
kShelfViewElementId)
.NameElements(NameMatchingElementInPrimaryRootWindowCallback(
kUnifiedSystemTrayElementId, kUnifiedSystemTrayElementName))
.InSameContext());
// Step 2: Status area.
tutorial_description.steps.emplace_back(
user_education::TutorialDescription::BubbleStep(
kUnifiedSystemTrayElementName)
.SetBubbleArrow(user_education::HelpBubbleArrow::kBottomRight)
.SetBubbleBodyText(IDS_ASH_WELCOME_TOUR_STATUS_AREA_BUBBLE_BODY_TEXT)
.SetExtendedProperties(CreateHelpBubbleExtendedProperties(
HelpBubbleId::kWelcomeTourStatusArea))
.AddCustomNextButton(DefaultNextButtonCallback().Then(
base::BindRepeating(&WelcomeTourController::SetCurrentStep,
weak_ptr_factory_.GetMutableWeakPtr(),
welcome_tour_metrics::Step::kHomeButton)))
.InAnyContext());
// Wait for "Next" button click before proceeding to the next bubble step.
// NOTE: This event step also ensures that the next bubble step will show on
// the primary display by naming the primary root window's home button.
tutorial_description.steps.emplace_back(
user_education::TutorialDescription::EventStep(
user_education::kHelpBubbleNextButtonClickedEvent,
kUnifiedSystemTrayElementName)
.NameElements(NameMatchingElementInPrimaryRootWindowCallback(
kHomeButtonElementId, kHomeButtonElementName))
.InSameContext());
// Step 3: Home button.
tutorial_description.steps.emplace_back(
user_education::TutorialDescription::BubbleStep(kHomeButtonElementName)
.SetBubbleArrow(user_education::HelpBubbleArrow::kBottomLeft)
.SetBubbleBodyText(IDS_ASH_WELCOME_TOUR_HOME_BUTTON_BUBBLE_BODY_TEXT)
.SetExtendedProperties(CreateHelpBubbleExtendedProperties(
HelpBubbleId::kWelcomeTourHomeButton))
.AddCustomNextButton(base::BindRepeating([](ui::TrackedElement*) {
Shell::Get()->app_list_controller()->Show(
GetPrimaryDisplayId(),
AppListShowSource::kWelcomeTour,
ui::EventTimeForNow(),
/*should_record_metrics=*/true);
})
.Then(base::BindRepeating(
&WelcomeTourController::SetCurrentStep,
weak_ptr_factory_.GetMutableWeakPtr(),
welcome_tour_metrics::Step::kSearch)))
.InAnyContext());
// Step 4: Search box.
tutorial_description.steps.emplace_back(
user_education::TutorialDescription::BubbleStep(kSearchBoxViewElementId)
.SetBubbleArrow(user_education::HelpBubbleArrow::kTopCenter)
.SetBubbleBodyText(IDS_ASH_WELCOME_TOUR_SEARCH_BOX_BUBBLE_BODY_TEXT)
.SetExtendedProperties(CreateHelpBubbleExtendedProperties(
HelpBubbleId::kWelcomeTourSearchBox))
.AddCustomNextButton(DefaultNextButtonCallback().Then(
base::BindRepeating(&WelcomeTourController::SetCurrentStep,
weak_ptr_factory_.GetMutableWeakPtr(),
welcome_tour_metrics::Step::kSettingsApp)))
.InAnyContext());
// Wait for "Next" button click before proceeding to the next bubble step.
tutorial_description.steps.emplace_back(
user_education::TutorialDescription::EventStep(
user_education::kHelpBubbleNextButtonClickedEvent,
kSearchBoxViewElementId)
.InSameContext());
// Step 5: Settings app.
tutorial_description.steps.emplace_back(
user_education::TutorialDescription::BubbleStep(kSettingsAppElementId)
.SetBubbleArrow(user_education::HelpBubbleArrow::kBottomLeft)
.SetBubbleBodyText(IDS_ASH_WELCOME_TOUR_SETTINGS_APP_BUBBLE_BODY_TEXT)
.SetExtendedProperties(CreateHelpBubbleExtendedProperties(
HelpBubbleId::kWelcomeTourSettingsApp))
.AddCustomNextButton(DefaultNextButtonCallback().Then(
base::BindRepeating(&WelcomeTourController::SetCurrentStep,
weak_ptr_factory_.GetMutableWeakPtr(),
welcome_tour_metrics::Step::kExploreApp)))
.InSameContext());
// Wait for "Next" button click before proceeding to the next bubble step.
tutorial_description.steps.emplace_back(
user_education::TutorialDescription::EventStep(
user_education::kHelpBubbleNextButtonClickedEvent,
kSettingsAppElementId)
.InSameContext());
// Step 6: Explore app.
tutorial_description.steps.emplace_back(
user_education::TutorialDescription::BubbleStep(kExploreAppElementId)
.SetBubbleArrow(user_education::HelpBubbleArrow::kBottomLeft)
.SetBubbleBodyText(IDS_ASH_WELCOME_TOUR_EXPLORE_APP_BUBBLE_BODY_TEXT)
.SetExtendedProperties(CreateHelpBubbleExtendedProperties(
HelpBubbleId::kWelcomeTourExploreApp))
.InSameContext());
// Step 7: Explore app window.
// Implemented in `WelcomeTourController::OnWelcomeTourEnded()`.
return tutorial_description;
}
void WelcomeTourController::OnAccessibilityControllerShutdown() {
MaybeAbortWelcomeTour(welcome_tour_metrics::AbortedReason::kShutdown);
}
void WelcomeTourController::OnAccessibilityStatusChanged() {
if (Shell::Get()->accessibility_controller()->spoken_feedback().enabled()) {
MaybeAbortWelcomeTour(
welcome_tour_metrics::AbortedReason::kChromeVoxEnabled);
}
}
void WelcomeTourController::OnActiveUserSessionChanged(
const AccountId& account_id) {
MaybeStartWelcomeTour();
}
void WelcomeTourController::OnChromeTerminating() {
session_observation_.Reset();
}
void WelcomeTourController::OnSessionStateChanged(
session_manager::SessionState session_state) {
MaybeStartWelcomeTour();
}
void WelcomeTourController::OnShellDestroying() {
MaybeAbortWelcomeTour(welcome_tour_metrics::AbortedReason::kShutdown);
}
void WelcomeTourController::OnDisplayTabletStateChanged(
display::TabletState state) {
if (state != display::TabletState::kEnteringTabletMode) {
return;
}
MaybeAbortWelcomeTour(
welcome_tour_metrics::AbortedReason::kTabletModeEnabled);
}
void WelcomeTourController::MaybeStartWelcomeTour() {
// NOTE: User education in Ash is currently only supported for the primary
// user profile. This is a self-imposed restriction.
if (!user_education_util::IsPrimaryAccountActive()) {
return;
}
// We can stop observations since we only observe sessions in order to start
// the tour when the primary user session is activated for the first time.
session_observation_.Reset();
if (!features::IsWelcomeTourForceUserEligibilityEnabled()) {
// Welcome Tour is supported for regular users only.
const auto* const session_controller = Shell::Get()->session_controller();
if (const auto user_type = session_controller->GetUserType();
user_type != user_manager::UserType::kRegular) {
welcome_tour_metrics::RecordTourPrevented(
welcome_tour_metrics::PreventedReason::kUserTypeNotRegular);
return;
}
// Welcome Tour is not supported for managed accounts.
if (session_controller->IsActiveAccountManaged()) {
welcome_tour_metrics::RecordTourPrevented(
welcome_tour_metrics::PreventedReason::kManagedAccount);
return;
}
// The cross-device proxy for whether the user is "new" or "existing" is
// untested out in the wild. For sanity, confirm that the user is also
// considered "new" locally in case the proxy check proves to be erroneous.
if (!session_controller->IsUserFirstLogin()) {
welcome_tour_metrics::RecordTourPrevented(
welcome_tour_metrics::PreventedReason::kUserNotNewLocally);
return;
}
const std::optional<bool>& is_new_user =
UserEducationController::Get()->IsNewUser(UserEducationPrivateApiKey());
// If it is not known whether the user is "new" or "existing" when this code
// is reached, the user is treated as "existing" since the Welcome Tour
// cannot be delayed and we want to err on the side of being conservative.
if (!is_new_user.has_value()) {
welcome_tour_metrics::RecordTourPrevented(
welcome_tour_metrics::PreventedReason::kUserNewnessNotAvailable);
return;
}
// Welcome Tour is not supported for "existing" users.
if (!is_new_user.value()) {
welcome_tour_metrics::RecordTourPrevented(
welcome_tour_metrics::PreventedReason::kUserNotNewCrossDevice);
return;
}
}
// We should attempt to launch the Explore app even if the Welcome Tour is
// prevented provided that (a) the user is new, and (b) the device is not in
// tablet mode. This is in keeping with existing first run behavior.
base::ScopedClosureRunner maybe_launch_explore_app_async(
display::Screen::GetScreen()->InTabletMode()
? base::DoNothing()
: base::BindOnce(&LaunchExploreAppAsync,
UserEducationPrivateApiKey()));
// Welcome Tour is not supported with ChromeVox enabled.
if (Shell::Get()->accessibility_controller()->spoken_feedback().enabled()) {
welcome_tour_metrics::RecordTourPrevented(
welcome_tour_metrics::PreventedReason::kChromeVoxEnabled);
return;
}
// Welcome Tour is not supported in tablet mode.
if (display::Screen::GetScreen()->InTabletMode()) {
welcome_tour_metrics::RecordTourPrevented(
welcome_tour_metrics::PreventedReason::kTabletModeEnabled);
return;
}
// Welcome Tour is not supported for counterfactual experiment arms.
if (features::IsWelcomeTourEnabledCounterfactually()) {
welcome_tour_metrics::RecordTourPrevented(
welcome_tour_metrics::PreventedReason::kCounterfactualExperimentArm);
return;
}
// The Welcome Tour is not being prevented, so hold off on opening the Explore
// app until the Welcome Tour is either completed or aborted.
std::ignore = maybe_launch_explore_app_async.Release();
auto* const tutorial_controller = UserEducationTutorialController::Get();
if (!tutorial_controller->IsTutorialRegistered(TutorialId::kWelcomeTour)) {
tutorial_controller->RegisterTutorial(UserEducationPrivateApiKey(),
TutorialId::kWelcomeTour,
GetTutorialDescription());
}
// NOTE: It is theoretically possible for the tutorial to outlive `this`
// controller during the destruction sequence.
tutorial_controller->StartTutorial(
UserEducationPrivateApiKey(), TutorialId::kWelcomeTour,
GetInitialElementContext(),
/*completed_callback=*/
base::BindOnce(&WelcomeTourController::OnWelcomeTourEnded,
weak_ptr_factory_.GetWeakPtr(), /*completed=*/true,
/*time_since_start=*/base::ElapsedTimer()),
/*aborted_callback=*/
base::BindOnce(&WelcomeTourController::OnWelcomeTourEnded,
weak_ptr_factory_.GetWeakPtr(), /*completed=*/false,
/*time_since_start=*/base::ElapsedTimer()));
// The attempt to start the tutorial above is guaranteed to succeed or crash.
// If this line of code is reached, the tour has indeed been started.
OnWelcomeTourStarted();
}
void WelcomeTourController::MaybeAbortWelcomeTour(
welcome_tour_metrics::AbortedReason reason) {
if (aborted_reason_ == welcome_tour_metrics::AbortedReason::kUnknown) {
aborted_reason_ = reason;
}
UserEducationTutorialController::Get()->AbortTutorial(
UserEducationPrivateApiKey(), TutorialId::kWelcomeTour);
}
void WelcomeTourController::OnWelcomeTourStarted() {
aborted_reason_ = welcome_tour_metrics::AbortedReason::kUnknown;
accelerator_handler_ = std::make_unique<WelcomeTourAcceleratorHandler>(
base::BindRepeating(&WelcomeTourController::MaybeAbortWelcomeTour,
weak_ptr_factory_.GetWeakPtr(),
welcome_tour_metrics::AbortedReason::kAccelerator));
accessibility_observation_.Observe(Shell::Get()->accessibility_controller());
notification_blocker_ = std::make_unique<WelcomeTourNotificationBlocker>();
notification_blocker_->Init();
nudge_pause_ = SystemNudgePauseManager::Get()->CreateScopedPause();
scrim_ = std::make_unique<WelcomeTourScrim>();
shell_observation_.Observe(Shell::Get());
display_observation_.Observe(display::Screen::GetScreen());
toast_pause_ = ToastManager::Get()->CreateScopedPause();
window_minimizer_ = std::make_unique<WelcomeTourWindowMinimizer>();
// NOTE: The accept button doesn't need to be explicitly handled because the
// Welcome Tour will automatically proceed to the next step once the dialog is
// closed unless it has been aborted.
WelcomeTourDialog::CreateAndShow(
/*accept_callback=*/base::BindOnce(&WelcomeTourController::SetCurrentStep,
weak_ptr_factory_.GetMutableWeakPtr(),
welcome_tour_metrics::Step::kShelf),
/*cancel_callback=*/
base::BindOnce(&WelcomeTourController::MaybeAbortWelcomeTour,
weak_ptr_factory_.GetWeakPtr(),
welcome_tour_metrics::AbortedReason::kUserDeclinedTour),
/*close_callback=*/
base::BindOnce(&WelcomeTourController::MaybeAbortWelcomeTour,
weak_ptr_factory_.GetWeakPtr(),
welcome_tour_metrics::AbortedReason::kUnknown));
SetCurrentStep(welcome_tour_metrics::Step::kDialog);
for (auto& observer : observer_list_) {
observer.OnWelcomeTourStarted();
}
}
void WelcomeTourController::OnWelcomeTourEnded(
bool completed,
base::ElapsedTimer time_since_start) {
accelerator_handler_.reset();
accessibility_observation_.Reset();
notification_blocker_.reset();
nudge_pause_.reset();
scrim_.reset();
shell_observation_.Reset();
display_observation_.Reset();
toast_pause_.reset();
window_minimizer_.reset();
if (!completed) {
welcome_tour_metrics::RecordTourAborted(aborted_reason_);
// `current_step_` may not be set in testing.
if (current_step_.has_value()) {
welcome_tour_metrics::RecordStepAborted(current_step_.value());
} else {
CHECK_IS_TEST();
}
if (auto* dialog = WelcomeTourDialog::Get()) {
// Ensure the Welcome Tour dialog is closed when the tour is aborted since
// the abort could have originated from outside of the dialog itself. Note
// that weak pointers are invalidated to avoid doing work on widget close.
if (auto* widget = dialog->GetWidget(); widget && !widget->IsClosed()) {
weak_ptr_factory_.InvalidateWeakPtrs();
widget->Close();
}
}
}
// Attempt to launch the Explore app regardless of tour completion so long as
// the device is not in tablet mode. This is in keeping with existing first
// run behavior.
if (!display::Screen::GetScreen()->InTabletMode()) {
LaunchExploreAppAsync(UserEducationPrivateApiKey());
SetCurrentStep(welcome_tour_metrics::Step::kExploreAppWindow);
}
SetCurrentStep(std::nullopt);
welcome_tour_metrics::RecordTourDuration(time_since_start.Elapsed(),
completed);
for (auto& observer : observer_list_) {
observer.OnWelcomeTourEnded();
}
}
void WelcomeTourController::SetCurrentStep(
std::optional<welcome_tour_metrics::Step> step) {
if (current_step_) {
welcome_tour_metrics::RecordStepDuration(current_step_.value(),
current_step_timer_.Elapsed());
}
if (step) {
welcome_tour_metrics::RecordStepShown(step.value());
}
current_step_ = step;
current_step_timer_ = base::ElapsedTimer();
}
} // namespace ash