// 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/system/toast/anchored_nudge_manager_impl.h"
#include <algorithm>
#include <memory>
#include <string>
#include "ash/public/cpp/ash_view_ids.h"
#include "ash/public/cpp/system/anchored_nudge_data.h"
#include "ash/public/cpp/system/scoped_nudge_pause.h"
#include "ash/session/session_controller_impl.h"
#include "ash/shell.h"
#include "ash/system/toast/anchored_nudge.h"
#include "base/containers/contains.h"
#include "base/memory/raw_ptr.h"
#include "base/metrics/histogram_functions.h"
#include "chromeos/ui/base/nudge_util.h"
#include "ui/compositor/layer.h"
#include "ui/compositor/layer_animation_observer.h"
#include "ui/compositor/scoped_layer_animation_settings.h"
#include "ui/events/types/event_type.h"
#include "ui/views/bubble/bubble_border.h"
#include "ui/views/bubble/bubble_dialog_delegate_view.h"
#include "ui/views/controls/button/label_button.h"
#include "ui/views/view.h"
#include "ui/views/view_observer.h"
#include "ui/views/view_utils.h"
#include "ui/views/widget/widget.h"
namespace ash {
namespace {
// Returns the `base::TimeDelta` constant based on a `NudgeDuration` enum value.
base::TimeDelta GetNudgeDuration(NudgeDuration duration) {
switch (duration) {
case NudgeDuration::kDefaultDuration:
return AnchoredNudgeManagerImpl::kNudgeDefaultDuration;
case NudgeDuration::kMediumDuration:
return AnchoredNudgeManagerImpl::kNudgeMediumDuration;
case NudgeDuration::kLongDuration:
return AnchoredNudgeManagerImpl::kNudgeLongDuration;
}
}
// An implicit animation observer that tracks the nudge widget's hide animation.
// Once the animation is complete the nudge widget will be destroyed.
class HideAnimationObserver : public ui::ImplicitAnimationObserver {
public:
// TODO(b/296948349): Pass the nudge id instead of a pointer to the nudge
// and access it through a new `GetNudge(id)` function.
HideAnimationObserver(AnchoredNudge* anchored_nudge)
: anchored_nudge_(anchored_nudge) {}
HideAnimationObserver(const HideAnimationObserver&) = delete;
HideAnimationObserver& operator=(const HideAnimationObserver&) = delete;
~HideAnimationObserver() override { StopObservingImplicitAnimations(); }
// ui::ImplicitAnimationObserver:
void OnImplicitAnimationsCompleted() override {
if (!anchored_nudge_) {
return;
}
// Return early if the nudge widget has already been closed.
auto* nudge_widget = anchored_nudge_->GetWidget();
if (!nudge_widget || nudge_widget->IsClosed()) {
return;
}
// `this` and other observers cleanup occurs on `OnWidgetDestroying()`.
nudge_widget->CloseNow();
}
private:
// Owned by the views hierarchy.
raw_ptr<AnchoredNudge> anchored_nudge_;
};
} // namespace
// Owns a `base::OneShotTimer` that can be paused and resumed.
class AnchoredNudgeManagerImpl::PausableTimer {
public:
PausableTimer() = default;
PausableTimer(const PausableTimer&) = delete;
PausableTimer& operator=(const PausableTimer&) = delete;
~PausableTimer() = default;
void Start(base::TimeDelta duration, base::RepeatingClosure task) {
DCHECK(!timer_.IsRunning());
task_ = task;
remaining_duration_ = duration;
time_last_started_ = base::TimeTicks::Now();
timer_.Start(FROM_HERE, remaining_duration_, task_);
}
void Pause() {
if (timer_.IsRunning()) {
timer_.Stop();
remaining_duration_ -= base::TimeTicks::Now() - time_last_started_;
}
}
void Resume() {
time_last_started_ = base::TimeTicks::Now();
timer_.Start(FROM_HERE, remaining_duration_, task_);
}
void Stop() {
remaining_duration_ = base::Seconds(0);
task_.Reset();
timer_.Stop();
}
private:
base::OneShotTimer timer_;
base::RepeatingClosure task_;
base::TimeDelta remaining_duration_;
base::TimeTicks time_last_started_;
};
// A view observer that is used to close the nudge's widget whenever its
// `anchor_view` is deleted.
class AnchoredNudgeManagerImpl::AnchorViewObserver
: public views::ViewObserver {
public:
// TODO(b/296948349): Pass the nudge id instead of a pointer to the nudge
// and access it through a new `GetNudge(id)` function.
AnchorViewObserver(AnchoredNudge* anchored_nudge,
views::View* anchor_view,
AnchoredNudgeManagerImpl* anchored_nudge_manager)
: anchored_nudge_(anchored_nudge),
anchor_view_(anchor_view),
anchored_nudge_manager_(anchored_nudge_manager) {
anchor_view_->AddObserver(this);
}
AnchorViewObserver(const AnchorViewObserver&) = delete;
AnchorViewObserver& operator=(const AnchorViewObserver&) = delete;
~AnchorViewObserver() override {
if (anchor_view_) {
anchor_view_->RemoveObserver(this);
}
}
// ViewObserver:
void OnViewIsDeleting(views::View* observed_view) override {
HandleAnchorViewIsDeletingOrHiding(observed_view);
}
// ViewObserver:
void OnViewVisibilityChanged(views::View* observed_view,
views::View* starting_view) override {
if (!observed_view->GetVisible()) {
HandleAnchorViewIsDeletingOrHiding(observed_view);
}
}
void HandleAnchorViewIsDeletingOrHiding(views::View* observed_view) {
CHECK_EQ(anchor_view_, observed_view);
const std::string id = anchored_nudge_->id();
// Make sure the nudge bubble no longer observes the anchor view.
anchored_nudge_->SetAnchorView(nullptr);
anchor_view_->RemoveObserver(this);
anchor_view_ = nullptr;
anchored_nudge_ = nullptr;
anchored_nudge_manager_->Cancel(id);
}
private:
// Owned by the views hierarchy.
raw_ptr<AnchoredNudge> anchored_nudge_;
raw_ptr<views::View> anchor_view_;
// `AnchorViewObserver` is guaranteed to not outlive
// `anchored_nudge_manager_`, which is owned by `Shell`.
raw_ptr<AnchoredNudgeManagerImpl> anchored_nudge_manager_;
};
// A widget observer that is used to close the nudge's widget whenever its
// `anchor_view` widget is hiding. `AnchorViewObserver` handles the
// `OnViewIsDeleting` event to close the nudge when `anchor_view` is deleted.
class AnchoredNudgeManagerImpl::AnchorViewWidgetObserver
: public views::WidgetObserver {
public:
// TODO(b/296948349): Pass the nudge id instead of a pointer to the nudge
// and access it through a new `GetNudge(id)` function.
AnchorViewWidgetObserver(AnchoredNudge* anchored_nudge,
views::View* anchor_view,
AnchoredNudgeManagerImpl* anchored_nudge_manager)
: anchored_nudge_(anchored_nudge),
anchor_view_(anchor_view),
anchored_nudge_manager_(anchored_nudge_manager) {
DCHECK(anchor_view->GetWidget());
active_widget_ = anchor_view->GetWidget();
active_widget_->AddObserver(this);
}
AnchorViewWidgetObserver(const AnchorViewWidgetObserver&) = delete;
AnchorViewWidgetObserver& operator=(const AnchorViewWidgetObserver&) = delete;
~AnchorViewWidgetObserver() override {
if (active_widget_) {
active_widget_->RemoveObserver(this);
}
}
// WidgetObserver:
void OnWidgetVisibilityChanged(views::Widget* widget, bool visible) override {
if (!visible) {
CloseNudge();
}
}
void OnWidgetDestroying(views::Widget* widget) override {
widget->RemoveObserver(this);
active_widget_ = nullptr;
anchor_view_ = nullptr;
anchored_nudge_ = nullptr;
}
private:
void CloseNudge() {
const std::string id = anchored_nudge_->id();
// Make sure the nudge bubble no longer observes the anchor view.
anchored_nudge_->SetAnchorView(nullptr);
anchor_view_->GetWidget()->RemoveObserver(this);
active_widget_ = nullptr;
anchor_view_ = nullptr;
anchored_nudge_ = nullptr;
anchored_nudge_manager_->Cancel(id);
}
// Owned by the views hierarchy.
raw_ptr<AnchoredNudge> anchored_nudge_;
raw_ptr<views::View> anchor_view_;
raw_ptr<views::Widget> active_widget_;
// `AnchorViewWidgetObserver` is guaranteed to not outlive
// `anchored_nudge_manager_`, which is owned by `Shell`.
raw_ptr<AnchoredNudgeManagerImpl> anchored_nudge_manager_;
};
// A widget observer that is used to clean up the cached objects related to a
// nudge when its widget is destroying.
class AnchoredNudgeManagerImpl::NudgeWidgetObserver
: public views::WidgetObserver {
public:
// TODO(b/296948349): Pass the nudge id instead of a pointer to the nudge
// and access it through a new `GetNudge(id)` function.
NudgeWidgetObserver(AnchoredNudge* anchored_nudge,
AnchoredNudgeManagerImpl* anchored_nudge_manager)
: anchored_nudge_(anchored_nudge),
anchored_nudge_manager_(anchored_nudge_manager) {
DCHECK(anchored_nudge->GetWidget());
anchored_nudge->GetWidget()->AddObserver(this);
}
NudgeWidgetObserver(const NudgeWidgetObserver&) = delete;
NudgeWidgetObserver& operator=(const NudgeWidgetObserver&) = delete;
~NudgeWidgetObserver() override {
if (anchored_nudge_ && anchored_nudge_->GetWidget()) {
anchored_nudge_->GetWidget()->RemoveObserver(this);
}
}
// WidgetObserver:
void OnWidgetDestroying(views::Widget* widget) override {
widget->RemoveObserver(this);
anchored_nudge_manager_->HandleNudgeWidgetDestroying(anchored_nudge_->id());
}
private:
// Owned by the views hierarchy.
raw_ptr<AnchoredNudge> anchored_nudge_;
// `NudgeWidgetObserver` is guaranteed to not outlive
// `anchored_nudge_manager_`, which is owned by `Shell`.
raw_ptr<AnchoredNudgeManagerImpl> anchored_nudge_manager_;
};
AnchoredNudgeManagerImpl::AnchoredNudgeManagerImpl() {
Shell::Get()->session_controller()->AddObserver(this);
}
AnchoredNudgeManagerImpl::~AnchoredNudgeManagerImpl() {
CloseAllNudges();
Shell::Get()->session_controller()->RemoveObserver(this);
}
void AnchoredNudgeManagerImpl::Show(AnchoredNudgeData& nudge_data) {
std::string id = nudge_data.id;
CHECK(!id.empty());
// If `pause_counter_` is greater than 0, no nudges should be shown.
if (pause_counter_ > 0) {
return;
}
views::View* anchor_view = nudge_data.GetAnchorView();
// Nudges with an anchor view won't show if their `anchor_view` was deleted,
// it is not visible or does not have a widget.
if (nudge_data.is_anchored() && (!anchor_view || !anchor_view->GetVisible() ||
!anchor_view->GetWidget())) {
return;
}
// If `id` is already in use, close the nudge without triggering its hide
// animation so it can be immediately replaced.
if (base::Contains(shown_nudges_, id)) {
auto* nudge_widget = shown_nudges_[id]->GetWidget();
if (nudge_widget && !nudge_widget->IsClosed()) {
// Cache cleanup occurs on nudge's `OnWidgetDestroying()`.
nudge_widget->CloseNow();
}
}
// Chain callbacks with `Cancel()` so nudge is dismissed on button pressed.
// TODO(b/285023559): Add `ChainedCancelCallback` class so we don't have to
// manually modify the provided callbacks.
if (!nudge_data.primary_button_text.empty()) {
nudge_data.primary_button_callback = ChainCancelCallback(
nudge_data.primary_button_callback, nudge_data.catalog_name, id,
/*primary_button=*/true);
}
if (!nudge_data.secondary_button_text.empty()) {
nudge_data.secondary_button_callback = ChainCancelCallback(
nudge_data.secondary_button_callback, nudge_data.catalog_name, id,
/*primary_button=*/false);
}
nudge_data.close_button_callback = base::BindRepeating(
&AnchoredNudgeManagerImpl::Cancel, base::Unretained(this), id);
auto anchored_nudge = std::make_unique<AnchoredNudge>(
nudge_data, /*hover_or_focus_changed_callback=*/
base::BindRepeating(
&AnchoredNudgeManagerImpl::PauseOrResumeDismissTimer,
// Unretained is safe because `this` outlives any anchored nudge, as
// they are all deleted on the manager's destructor.
base::Unretained(this), id));
auto* anchored_nudge_ptr = anchored_nudge.get();
shown_nudges_[id] = anchored_nudge_ptr;
auto* anchored_nudge_widget =
views::BubbleDialogDelegate::CreateBubble(std::move(anchored_nudge));
// The widget is not activated so the nudge does not steal focus.
anchored_nudge_widget->ShowInactive();
RecordNudgeShown(nudge_data.catalog_name);
nudge_widget_observers_[id] = std::make_unique<NudgeWidgetObserver>(
anchored_nudge_ptr, /*anchored_nudge_manager=*/this);
if (anchor_view) {
anchor_view_observers_[id] = std::make_unique<AnchorViewObserver>(
anchored_nudge_ptr, anchor_view, /*anchored_nudge_manager=*/this);
anchor_view_widget_observers_[id] =
std::make_unique<AnchorViewWidgetObserver>(
anchored_nudge_ptr, anchor_view, /*anchored_nudge_manager=*/this);
}
// Nudge duration will be updated from default to medium if the nudge has a
// button or its body text has `kLongBodyTextLength` or more characters.
if (nudge_data.duration == NudgeDuration::kDefaultDuration &&
(nudge_data.body_text.length() >= kLongBodyTextLength ||
!nudge_data.primary_button_text.empty())) {
nudge_data.duration = NudgeDuration::kMediumDuration;
}
dismiss_timers_[id].Start(
GetNudgeDuration(nudge_data.duration),
base::BindRepeating(&AnchoredNudgeManagerImpl::Cancel,
base::Unretained(this), id));
}
void AnchoredNudgeManagerImpl::Cancel(const std::string& id) {
// Return early if the nudge is not cached in `shown_nudges_`, or the nudge
// hide animation is already being observed.
if (!base::Contains(shown_nudges_, id) ||
base::Contains(hide_animation_observers_, id)) {
return;
}
auto* anchored_nudge = shown_nudges_[id].get();
auto* nudge_widget = anchored_nudge->GetWidget();
// Return early if the nudge widget has been closed.
if (!nudge_widget || nudge_widget->IsClosed()) {
return;
}
// Observe hide animation to close the nudge widget on animation completed.
hide_animation_observers_[id] =
std::make_unique<HideAnimationObserver>(anchored_nudge);
ui::ScopedLayerAnimationSettings animation_settings(
nudge_widget->GetLayer()->GetAnimator());
animation_settings.AddObserver(hide_animation_observers_[id].get());
// Trigger the nudge widget hide animation. Widget is properly closed on
// `OnImplicitAnimationsCompleted()`.
nudge_widget->Hide();
}
void AnchoredNudgeManagerImpl::MaybeRecordNudgeAction(
NudgeCatalogName catalog_name) {
auto& nudge_registry = GetNudgeRegistry();
auto it = std::find_if(
std::begin(nudge_registry), std::end(nudge_registry),
[catalog_name](
const std::pair<NudgeCatalogName, base::TimeTicks> registry_entry) {
return catalog_name == registry_entry.first;
});
// Don't record "TimeToAction" metric if the nudge hasn't been shown before.
if (it == std::end(nudge_registry)) {
return;
}
base::UmaHistogramEnumeration(chromeos::GetNudgeTimeToActionHistogramName(
base::TimeTicks::Now() - it->second),
catalog_name);
nudge_registry.erase(it);
}
std::unique_ptr<ScopedNudgePause>
AnchoredNudgeManagerImpl::CreateScopedPause() {
return std::make_unique<ScopedNudgePause>();
}
void AnchoredNudgeManagerImpl::HandleNudgeWidgetDestroying(
const std::string& id) {
// TODO(b/296948349): Handle all observers in a single struct so they can be
// destroyed together.
dismiss_timers_.erase(id);
if (anchor_view_observers_[id]) {
anchor_view_observers_.erase(id);
}
if (anchor_view_widget_observers_[id]) {
anchor_view_widget_observers_.erase(id);
}
hide_animation_observers_.erase(id);
nudge_widget_observers_.erase(id);
shown_nudges_.erase(id);
}
void AnchoredNudgeManagerImpl::PauseOrResumeDismissTimer(const std::string& id,
bool pause) {
if (pause) {
dismiss_timers_[id].Pause();
} else {
dismiss_timers_[id].Resume();
}
}
void AnchoredNudgeManagerImpl::OnSessionStateChanged(
session_manager::SessionState state) {
CloseAllNudges();
}
// TODO(b/311526868): Replace instances of `base::Contains()` and
// `shown_nudges_[id]` with logic that only performs a single lookup.
// TODO(b/296948349): Replace this with a new `GetNudge(id)` function as this
// does not accurately reflect is a nudge is shown or not.
bool AnchoredNudgeManagerImpl::IsNudgeShown(const std::string& id) {
return base::Contains(shown_nudges_, id);
}
const std::u16string& AnchoredNudgeManagerImpl::GetNudgeBodyTextForTest(
const std::string& id) {
CHECK(base::Contains(shown_nudges_, id));
return views::AsViewClass<views::Label>(
shown_nudges_[id]->GetViewByID(VIEW_ID_SYSTEM_NUDGE_BODY_LABEL))
->GetText();
}
views::View* AnchoredNudgeManagerImpl::GetNudgeAnchorViewForTest(
const std::string& id) {
CHECK(base::Contains(shown_nudges_, id));
return shown_nudges_[id]->GetAnchorView();
}
views::LabelButton* AnchoredNudgeManagerImpl::GetNudgePrimaryButtonForTest(
const std::string& id) {
CHECK(base::Contains(shown_nudges_, id));
return views::AsViewClass<views::LabelButton>(
shown_nudges_[id]->GetViewByID(VIEW_ID_SYSTEM_NUDGE_PRIMARY_BUTTON));
}
views::LabelButton* AnchoredNudgeManagerImpl::GetNudgeSecondaryButtonForTest(
const std::string& id) {
CHECK(base::Contains(shown_nudges_, id));
return views::AsViewClass<views::LabelButton>(
shown_nudges_[id]->GetViewByID(VIEW_ID_SYSTEM_NUDGE_SECONDARY_BUTTON));
}
AnchoredNudge* AnchoredNudgeManagerImpl::GetShownNudgeForTest(
const std::string& id) {
return base::Contains(shown_nudges_, id) ? shown_nudges_[id] : nullptr;
}
NudgeCatalogName AnchoredNudgeManagerImpl::GetNudgeCatalogNameForTest(
const std::string& id) {
CHECK(base::Contains(shown_nudges_, id));
return shown_nudges_[id]->catalog_name();
}
AnchoredNudge* AnchoredNudgeManagerImpl::GetNudgeIfShown(
const std::string& nudge_id) const {
const auto iter = shown_nudges_.find(nudge_id);
return iter != shown_nudges_.end() ? iter->second.get() : nullptr;
}
void AnchoredNudgeManagerImpl::ResetNudgeRegistryForTesting() {
GetNudgeRegistry().clear();
}
// static
std::vector<std::pair<NudgeCatalogName, base::TimeTicks>>&
AnchoredNudgeManagerImpl::GetNudgeRegistry() {
static auto nudge_registry =
std::vector<std::pair<NudgeCatalogName, base::TimeTicks>>();
return nudge_registry;
}
void AnchoredNudgeManagerImpl::RecordNudgeShown(NudgeCatalogName catalog_name) {
base::UmaHistogramEnumeration(
chromeos::kNotifierFrameworkNudgeShownCountHistogram, catalog_name);
// Record nudge shown time in the nudge registry.
auto& nudge_registry = GetNudgeRegistry();
auto it = std::find_if(
std::begin(nudge_registry), std::end(nudge_registry),
[catalog_name](
const std::pair<NudgeCatalogName, base::TimeTicks> registry_entry) {
return catalog_name == registry_entry.first;
});
if (it == std::end(nudge_registry)) {
nudge_registry.emplace_back(catalog_name, base::TimeTicks::Now());
} else {
it->second = base::TimeTicks::Now();
}
}
void AnchoredNudgeManagerImpl::RecordButtonPressed(
NudgeCatalogName catalog_name,
bool is_primary_button) {
base::UmaHistogramEnumeration(
is_primary_button ? "Ash.NotifierFramework.Nudge.PrimaryButtonPressed"
: "Ash.NotifierFramework.Nudge.SecondaryButtonPressed",
catalog_name);
}
void AnchoredNudgeManagerImpl::CloseAllNudges() {
// A while-loop over the original list is used to avoid race conditions that
// could occur by copying the list, making it possible to iterate through an
// item that might not exist in `shown_nudges_` anymore.
while (!shown_nudges_.empty()) {
auto* nudge_widget = shown_nudges_.begin()->second->GetWidget();
if (nudge_widget && !nudge_widget->IsClosed()) {
// Cache cleanup occurs on nudge's `OnWidgetDestroying()`.
nudge_widget->CloseNow();
}
}
}
base::RepeatingClosure AnchoredNudgeManagerImpl::ChainCancelCallback(
base::RepeatingClosure callback,
NudgeCatalogName catalog_name,
const std::string& id,
bool is_primary_button) {
return std::move(callback)
.Then(base::BindRepeating(&AnchoredNudgeManagerImpl::Cancel,
base::Unretained(this), id))
.Then(base::BindRepeating(&AnchoredNudgeManagerImpl::RecordButtonPressed,
base::Unretained(this), catalog_name,
is_primary_button));
}
void AnchoredNudgeManagerImpl::Pause() {
++pause_counter_;
// Immediately close all nudges.
CloseAllNudges();
}
void AnchoredNudgeManagerImpl::Resume() {
CHECK_GT(pause_counter_, 0);
--pause_counter_;
}
} // namespace ash