chromium/chromeos/ui/frame/multitask_menu/multitask_menu_nudge_controller.cc

// Copyright 2022 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

#include "chromeos/ui/frame/multitask_menu/multitask_menu_nudge_controller.h"

#include "ash/constants/notifier_catalogs.h"
#include "base/metrics/histogram_functions.h"
#include "base/metrics/user_metrics.h"
#include "chromeos/strings/grit/chromeos_strings.h"
#include "chromeos/ui/base/nudge_util.h"
#include "components/prefs/pref_registry_simple.h"
#include "ui/aura/client/screen_position_client.h"
#include "ui/aura/window.h"
#include "ui/base/l10n/l10n_util.h"
#include "ui/compositor/layer.h"
#include "ui/display/screen.h"
#include "ui/display/tablet_state.h"
#include "ui/gfx/geometry/transform_util.h"
#include "ui/views/animation/animation_builder.h"
#include "ui/views/background.h"
#include "ui/views/controls/label.h"
#include "ui/views/highlight_border.h"
#include "ui/views/layout/box_layout_view.h"
#include "ui/views/widget/widget.h"

#if BUILDFLAG(IS_CHROMEOS_ASH)
#include "ash/constants/ash_pref_names.h"
#include "ash/constants/ash_switches.h"
#include "base/command_line.h"
#include "components/user_manager/user_manager.h"
#endif

namespace chromeos {

namespace {

constexpr base::TimeDelta kNudgeDismissTimeout = base::Seconds(6);

// The nudge will not be shown if it already been shown 3 times, or if 24 hours
// have not yet passed since it was last shown.
constexpr int kNudgeMaxShownCount = 3;
constexpr base::TimeDelta kNudgeTimeBetweenShown = base::Hours(24);

constexpr base::TimeDelta kFadeDuration = base::Milliseconds(50);

constexpr gfx::Insets kLabelInsets = gfx::Insets::VH(8, 16);
constexpr int kLabelMaxWidth = 512;

constexpr int kNudgeDistanceFromAnchor = 8;

// The max pulse size will be three times the size of the maximize/restore
// button.
constexpr float kPulseSizeMultiplier = 3.0f;
constexpr base::TimeDelta kPulseDuration = base::Seconds(2);
constexpr int kPulses = 3;

bool g_suppress_nudge_for_testing = false;

// Clock that can be overridden for testing.
base::Clock* g_clock_override = nullptr;

// May be null in tests.
MultitaskMenuNudgeController::Delegate* g_delegate_instance = nullptr;

base::Time GetTime() {
  return g_clock_override ? g_clock_override->Now() : base::Time::Now();
}

std::unique_ptr<views::Widget> CreateWidget(aura::Window* window) {
  views::Widget::InitParams params(
      views::Widget::InitParams::NATIVE_WIDGET_OWNS_WIDGET,
      views::Widget::InitParams::TYPE_POPUP);
  params.opacity = views::Widget::InitParams::WindowOpacity::kTranslucent;
  params.name = "MultitaskNudgeWidget";
  params.accept_events = false;
  params.parent = window->parent();
#if BUILDFLAG(IS_CHROMEOS_LACROS)
  // This widget must not set `use_accelerated_widget_override` b/c this
  // widget's window will be reparented to `window`.
  params.use_accelerated_widget_override = false;
#endif

  auto widget = std::make_unique<views::Widget>(std::move(params));
  const int message_id = display::Screen::GetScreen()->InTabletMode()
                             ? IDS_TABLET_MULTITASK_MENU_NUDGE_TEXT
                             : IDS_MULTITASK_MENU_NUDGE_TEXT;

  // The contents are a label with a background that has padding, background
  // color and highlight border.
  auto contents_view =
      views::Builder<views::BoxLayoutView>()
          .SetInsideBorderInsets(kLabelInsets)
          .AddChildren(
              views::Builder<views::Label>()
                  .SetHorizontalAlignment(gfx::ALIGN_CENTER)
                  .SetAutoColorReadabilityEnabled(false)
                  .SetMultiLine(true)
                  .SetMaximumWidth(kLabelMaxWidth)
                  .SetMaxLines(2)
                  .SetSubpixelRenderingEnabled(false)
                  .SetFontList(views::Label::GetDefaultFontList().Derive(
                      2, gfx::Font::FontStyle::NORMAL,
                      gfx::Font::Weight::NORMAL))
                  .SetText(l10n_util::GetStringUTF16(message_id)))
          .Build();
  const float corner_radius =
      contents_view->GetPreferredSize({}).height() / 2.0f;
  contents_view->SetBackground(views::CreateThemedRoundedRectBackground(
      ui::kColorSysSurface3, corner_radius));
  contents_view->SetBorder(std::make_unique<views::HighlightBorder>(
      corner_radius, views::HighlightBorder::Type::kHighlightBorderOnShadow));

  widget->SetContentsView(std::move(contents_view));
  return widget;
}

}  // namespace

MultitaskMenuNudgeController::Delegate::~Delegate() {
  CHECK_EQ(this, g_delegate_instance);
  g_delegate_instance = nullptr;
}

MultitaskMenuNudgeController::Delegate::Delegate() {
  CHECK_EQ(nullptr, g_delegate_instance);
  g_delegate_instance = this;
}

bool MultitaskMenuNudgeController::Delegate::IsUserNewOrGuest() const {
#if BUILDFLAG(IS_CHROMEOS_ASH)
  if (!user_manager::UserManager::IsInitialized()) {
    return false;
  }

  return user_manager::UserManager::Get()->IsCurrentUserNew() ||
         user_manager::UserManager::Get()->IsLoggedInAsGuest();
#else
  return false;
#endif
}

MultitaskMenuNudgeController::MultitaskMenuNudgeController() = default;

MultitaskMenuNudgeController::~MultitaskMenuNudgeController() {
  DismissNudge();
}

#if BUILDFLAG(IS_CHROMEOS_ASH)
// static
void MultitaskMenuNudgeController::RegisterProfilePrefs(
    PrefRegistrySimple* registry) {
  registry->RegisterIntegerPref(
      ash::prefs::kMultitaskMenuNudgeClamshellShownCount, 0);
  registry->RegisterIntegerPref(ash::prefs::kMultitaskMenuNudgeTabletShownCount,
                                0);
  registry->RegisterTimePref(ash::prefs::kMultitaskMenuNudgeClamshellLastShown,
                             base::Time());
  registry->RegisterTimePref(ash::prefs::kMultitaskMenuNudgeTabletLastShown,
                             base::Time());
}
#endif

void MultitaskMenuNudgeController::MaybeShowNudge(aura::Window* window) {
  MaybeShowNudge(window, /*anchor_view=*/nullptr);
}

void MultitaskMenuNudgeController::MaybeShowNudge(aura::Window* window,
                                                  views::View* anchor_view) {
  // Delegate could be null if the associated window was created during OOBE.
  if (!g_delegate_instance || g_delegate_instance->IsUserNewOrGuest()) {
    return;
  }

  if (g_suppress_nudge_for_testing || nudge_widget_) {
    return;
  }

#if BUILDFLAG(IS_CHROMEOS_ASH)
  if (base::CommandLine::ForCurrentProcess()->HasSwitch(
          ash::switches::kAshNoNudges)) {
    return;
  }
#endif

  // If the window is not visible, do not show the nudge.
  if (!window->IsVisible()) {
    return;
  }

  // `window` and `anchor_view` can be passed safely on clamshell because they
  // are owned by the frame which also owns `this`. They can be passed safely on
  // tablet since tablet is controlled by ash which is sync.
  g_delegate_instance->GetNudgePreferences(
      display::Screen::GetScreen()->InTabletMode(),
      base::BindOnce(&MultitaskMenuNudgeController::OnGetPreferences,
                     weak_ptr_factory_.GetWeakPtr(), window, anchor_view));
}

void MultitaskMenuNudgeController::DismissNudge() {
  clamshell_nudge_dismiss_timer_.Stop();
  weak_ptr_factory_.InvalidateWeakPtrs();

  window_ = nullptr;
  window_observation_.Reset();
  widget_observation_.Reset();

  anchor_view_ = nullptr;
  pulse_layer_.reset();
  if (nudge_widget_ && !nudge_widget_->IsClosed()) {
    nudge_widget_->GetLayer()->GetAnimator()->AbortAllAnimations();
    nudge_widget_->CloseNow();
  }
}

void MultitaskMenuNudgeController::OnMenuOpened(bool tablet_mode) {
  if (!nudge_shown_time_.is_null()) {
    base::UmaHistogramEnumeration(
        GetNudgeTimeToActionHistogramName(GetTime() - nudge_shown_time_),
        tablet_mode ? ash::NudgeCatalogName::kMultitaskMenuTablet
                    : ash::NudgeCatalogName::kMultitaskMenuClamshell);
    nudge_shown_time_ = base::Time();
  }

  // Avoid sending prefs through the cros API or recording user actions if the
  // nudge isn't shown.
  if (!nudge_widget_ || nudge_widget_->IsClosed()) {
    return;
  }

  base::RecordAction(
      base::UserMetricsAction("Nudge_Active_When_MultitaskMenu_Opened"));
  DismissNudge();

  if (g_delegate_instance) {
    g_delegate_instance->SetNudgePreferences(tablet_mode, kNudgeMaxShownCount,
                                             GetTime());
  }
}

void MultitaskMenuNudgeController::OnWindowParentChanged(aura::Window* window,
                                                         aura::Window* parent) {
  if (!parent) {
    return;
  }
  CHECK_EQ(window_, window);
  UpdateWidgetAndPulse();
}

void MultitaskMenuNudgeController::OnWindowVisibilityChanged(
    aura::Window* window,
    bool visible) {
  if (window == window_ && !visible) {
    DismissNudge();
  }
}

void MultitaskMenuNudgeController::OnWindowTargetTransformChanging(
    aura::Window* window,
    const gfx::Transform& new_transform) {
  CHECK_EQ(window_, window);
  // Prevent unintended behaviour in situations that use transforms such as
  // overview mode.
  // TODO(hewer): Decide how the cue behaves when adjusting the split view
  // bounds in tablet mode.
  DismissNudge();
}

void MultitaskMenuNudgeController::OnWindowStackingChanged(
    aura::Window* window) {
  CHECK_EQ(window_, window);

  // Stacking may change during the construction of the widget, at which
  // `nudge_widget_` would still be null.
  if (!nudge_widget_) {
    return;
  }

  // Ensure the `nudge_widget_` is always above `window_`. We dont worry about
  // the pulse layer since it is not a window, and won't get stacked on top of
  // during window activation for example. When moving across displays, it is
  // possible the window parent differs for a bit. In this case we cannot
  // restack and we need to wait for `UpdateWidgetAndPulse` to place the nudge
  // in the correct spot.
  if (window_->parent() == nudge_widget_->GetNativeWindow()->parent()) {
    window_->parent()->StackChildAbove(nudge_widget_->GetNativeWindow(),
                                       window);
  }
}

void MultitaskMenuNudgeController::OnWindowDestroying(aura::Window* window) {
  CHECK_EQ(window_, window);
  DismissNudge();
}

void MultitaskMenuNudgeController::OnWidgetBoundsChanged(
    views::Widget* widget,
    const gfx::Rect& new_bounds) {
  CHECK_EQ(window_, widget->GetNativeWindow());
  UpdateWidgetAndPulse();
}

void MultitaskMenuNudgeController::OnDisplayTabletStateChanged(
    display::TabletState state) {
  switch (state) {
    case display::TabletState::kEnteringTabletMode:
    case display::TabletState::kExitingTabletMode:
      // Nudge must be dismissed before switching modes, as each nudge is
      // different in each mode and the anchor isn't used in tablet mode.
      DismissNudge();
      break;
    case display::TabletState::kInTabletMode:
      // Entering tablet mode will call the `TabletModeMultitaskCueController`
      // constructor so no work needed.
      // TODO(b/267648014): Combine cue and nudge logic so both are activated in
      // the same place when switching modes.
      break;
    case display::TabletState::kInClamshellMode:
      // TODO(b/267648071): Find a way to make the nudge shown after finishing
      // transition to clamshell mode as anchor is not visible yet.
      break;
  }
}

void MultitaskMenuNudgeController::SetSuppressNudgeForTesting(bool val) {
  g_suppress_nudge_for_testing = val;
}

// static
void MultitaskMenuNudgeController::SetOverrideClockForTesting(
    base::Clock* test_clock) {
  g_clock_override = test_clock;
}

void MultitaskMenuNudgeController::OnGetPreferences(
    aura::Window* window,
    views::View* anchor_view,
    bool tablet_mode,
    std::optional<PrefValues> values) {
  if (!values) {
    LOG(WARNING) << "Unable to fetch preferences.";
    return;
  }

  // Tablet state has changed since we fetched preferences.
  if (tablet_mode != display::Screen::GetScreen()->InTabletMode()) {
    return;
  }

  // The nudge is already been shown for this window. This can happen in
  // lacros, where prefs are read and written to async. In ash, the prefs will
  // be updated before the next read, so this cannot happen.
  if (window_) {
    return;
  }

  // Nudge has already been shown three times. No need to educate anymore.
  if (values->show_count >= kNudgeMaxShownCount) {
    return;
  }

  // Nudge has been shown within the last 24 hours already.
  if ((GetTime() - values->last_shown_time) < kNudgeTimeBetweenShown) {
    return;
  }

  // If the anchor is passed and hidden or offscreen, we cannot show the nudge.
  if (anchor_view) {
    if (!anchor_view->IsDrawn() ||
        !display::Screen::GetScreen()
             ->GetDisplayNearestWindow(window)
             .bounds()
             .Contains(anchor_view->GetBoundsInScreen())) {
      return;
    }
  }

  window_ = window;

  nudge_widget_ = CreateWidget(window_);
  anchor_view_ = anchor_view;

  nudge_widget_->Show();

  base::UmaHistogramEnumeration(
      kNotifierFrameworkNudgeShownCountHistogram,
      tablet_mode ? ash::NudgeCatalogName::kMultitaskMenuTablet
                  : ash::NudgeCatalogName::kMultitaskMenuClamshell);
  nudge_shown_time_ = GetTime();

  // Note that order matters because in some cases, creating the widget may
  // trigger some window observations.
  window_observation_.Observe(window_.get());

  views::Widget* widget = views::Widget::GetWidgetForNativeWindow(window_);
  CHECK(widget);
  widget_observation_.Observe(widget);

  if (!tablet_mode) {
    // Create the layer which pulses on the maximize/restore button.
    pulse_layer_ = std::make_unique<ui::Layer>(ui::LAYER_SOLID_COLOR);
    pulse_layer_->SetColor(nudge_widget_->GetColorProvider()->GetColor(
        ui::kColorMultitaskMenuNudgePulse));
    window_->parent()->layer()->Add(pulse_layer_.get());
  }

  UpdateWidgetAndPulse();

  // It is possible `UpdateWidgetAndPulse` could not find a good bounds to place
  // the nudge. In that case the widget and pulse and observations would have
  // been cleaned up.
  if (!nudge_widget_) {
    return;
  }

  // Fade the education nudge in.
  ui::Layer* layer = nudge_widget_->GetLayer();
  layer->SetOpacity(0.0f);
  views::AnimationBuilder()
      .SetPreemptionStrategy(
          ui::LayerAnimator::IMMEDIATELY_ANIMATE_TO_NEW_TARGET)
      .Once()
      .SetDuration(kFadeDuration)
      .SetOpacity(layer, 1.0f, gfx::Tween::LINEAR);

  // Update the preferences.
  g_delegate_instance->SetNudgePreferences(tablet_mode, values->show_count + 1,
                                           GetTime());

  // No need to update pulse or start timer in tablet mode.
  if (!tablet_mode) {
    PerformPulseAnimation(/*pulse_count=*/0);

    clamshell_nudge_dismiss_timer_.Start(
        FROM_HERE, kNudgeDismissTimeout, this,
        &MultitaskMenuNudgeController::OnDismissTimerEnded);
  }
}

void MultitaskMenuNudgeController::OnDismissTimerEnded() {
  if (!nudge_widget_) {
    return;
  }

  ui::Layer* layer = nudge_widget_->GetLayer();
  layer->SetOpacity(1.0f);
  views::AnimationBuilder()
      .SetPreemptionStrategy(
          ui::LayerAnimator::IMMEDIATELY_ANIMATE_TO_NEW_TARGET)
      .OnEnded(base::BindOnce(&MultitaskMenuNudgeController::DismissNudge,
                              base::Unretained(this)))
      .Once()
      .SetDuration(kFadeDuration)
      .SetOpacity(layer, 0.0f, gfx::Tween::LINEAR);
}

void MultitaskMenuNudgeController::UpdateWidgetAndPulse() {
  CHECK(window_);
  CHECK(nudge_widget_);

  const bool tablet_mode = display::Screen::GetScreen()->InTabletMode();
  if (!tablet_mode) {
    CHECK(pulse_layer_);
    CHECK(anchor_view_);
  }

  // Dismiss the nudge if the window (or anchor in clamshell mode) is not
  // visible, otherwise it will be floating.
  if (!window_->IsVisible() || (!tablet_mode && !anchor_view_->IsDrawn())) {
    DismissNudge();
    return;
  }

  // Reparent the nudge and pulse if necessary.
  aura::Window* new_parent = window_->parent();
  aura::Window* nudge_window = nudge_widget_->GetNativeWindow();

  if (new_parent != nudge_window->parent()) {
    new_parent->AddChild(nudge_window);
    if (pulse_layer_) {
      new_parent->layer()->Add(pulse_layer_.get());
    }
  }

  const gfx::Size size = nudge_widget_->GetContentsView()->GetPreferredSize({});

  if (tablet_mode) {
    // The nudge is placed in the top center of the window, just below the cue.
    const int tablet_nudge_y_offset =
        g_delegate_instance->GetTabletNudgeYOffset();
    nudge_widget_->SetBounds(gfx::Rect(
        (window_->bounds().width() - size.width()) / 2 + window_->bounds().x(),
        tablet_nudge_y_offset + window_->bounds().y(), size.width(),
        size.height()));
    return;
  }

  // The nudge is placed right below the anchor.
  const gfx::Rect anchor_bounds_in_screen = anchor_view_->GetBoundsInScreen();
  gfx::Rect bounds_in_screen(
      anchor_bounds_in_screen.CenterPoint().x() - size.width() / 2,
      anchor_bounds_in_screen.bottom() + kNudgeDistanceFromAnchor, size.width(),
      size.height());
  bool adjust_to_fit = false;
  const display::Display display =
      display::Screen::GetScreen()->GetDisplayNearestView(window_);
#if BUILDFLAG(IS_CHROMEOS_LACROS)
  // Lacros always needs adjustment since the child cannot go outside the
  // parents bounds currently. See https://crbug.com/1416919.
  adjust_to_fit = true;
#elif BUILDFLAG(IS_CHROMEOS_ASH)
  // If the nudge is going to be offscreen, make sure it is within the window
  // bounds.
  adjust_to_fit = !display.work_area().Contains(bounds_in_screen);
#endif
  if (adjust_to_fit) {
    // The nudge should be within the window bounds.
    bounds_in_screen.AdjustToFit(window_->GetBoundsInScreen());
  }
  nudge_widget_->SetBounds(bounds_in_screen);

  // If setting bounds on the nudge causes it to move to another display (this
  // can happen while dragging across displays), dismiss the nudge.
  if (nudge_widget_->GetNativeWindow()->parent() != window_->parent()) {
    DismissNudge();
    return;
  }

  // The circular pulse should be a square that matches the smaller dimension of
  // `anchor_view_`. We use rounded corners to make it look like a circle.
  gfx::Rect pulse_layer_bounds = anchor_bounds_in_screen;
  gfx::Point pulse_layer_origin = pulse_layer_bounds.origin();
  aura::client::GetScreenPositionClient(nudge_window->GetRootWindow())
      ->ConvertPointFromScreen(nudge_window->parent(), &pulse_layer_origin);
  pulse_layer_bounds.set_origin(pulse_layer_origin);
  const int length =
      std::min(pulse_layer_bounds.width(), pulse_layer_bounds.height());
  pulse_layer_bounds.ClampToCenteredSize(gfx::Size(length, length));
  pulse_layer_->SetBounds(pulse_layer_bounds);
  pulse_layer_->SetRoundedCornerRadius(gfx::RoundedCornersF(length / 2.f));
}

void MultitaskMenuNudgeController::PerformPulseAnimation(int pulse_count) {
  if (pulse_count >= kPulses) {
    return;
  }

  CHECK(pulse_layer_);

  // The pulse animation scales up and fades out on top of the maximize/restore
  // button until the nudge disappears.
  const gfx::Point pivot(
      gfx::Rect(pulse_layer_->GetTargetBounds().size()).CenterPoint());
  const gfx::Transform transform =
      gfx::GetScaleTransform(pivot, kPulseSizeMultiplier);

  pulse_layer_->SetOpacity(1.0f);
  pulse_layer_->SetTransform(gfx::Transform());

  // Note that `views::AnimationBuilder::Repeatedly` works here as well, but
  // causes tests to hang.
  views::AnimationBuilder builder;
  builder
      .SetPreemptionStrategy(
          ui::LayerAnimator::IMMEDIATELY_ANIMATE_TO_NEW_TARGET)
      .OnEnded(
          base::BindOnce(&MultitaskMenuNudgeController::PerformPulseAnimation,
                         base::Unretained(this), pulse_count + 1))
      .Once()
      .SetDuration(kPulseDuration)
      .SetOpacity(pulse_layer_.get(), 0.0f, gfx::Tween::ACCEL_0_80_DECEL_80)
      .SetTransform(pulse_layer_.get(), transform,
                    gfx::Tween::ACCEL_0_40_DECEL_100);
}

}  // namespace chromeos