chromium/chrome/browser/ash/growth/show_nudge_action_performer.cc

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

#include "chrome/browser/ash/growth/show_nudge_action_performer.h"

#include <optional>

#include "ash/constants/ash_features.h"
#include "ash/constants/notifier_catalogs.h"
#include "ash/public/cpp/system/anchored_nudge_data.h"
#include "ash/root_window_controller.h"
#include "ash/shelf/hotseat_widget.h"
#include "ash/shelf/shelf_app_button.h"
#include "ash/shelf/shelf_view.h"
#include "ash/shell.h"
#include "ash/system/toast/anchored_nudge_manager_impl.h"
#include "base/check_is_test.h"
#include "base/logging.h"
#include "base/notreached.h"
#include "chrome/browser/ash/growth/campaigns_manager_session.h"
#include "chrome/browser/ash/growth/metrics.h"
#include "chromeos/ash/components/growth/campaigns_constants.h"
#include "chromeos/ash/components/growth/campaigns_logger.h"
#include "chromeos/ash/components/growth/campaigns_manager.h"
#include "chromeos/ash/components/growth/campaigns_model.h"
#include "chromeos/ash/components/growth/growth_metrics.h"
#include "chromeos/ui/base/chromeos_ui_constants.h"
#include "ui/aura/window.h"
#include "ui/views/bubble/bubble_border.h"
#include "ui/views/view.h"
#include "ui/views/widget/widget.h"

namespace {

// Nudge payload paths.
constexpr char kNudgeTitlePath[] = "title";
constexpr char kNudgeBodyPath[] = "body";
constexpr char kImagePath[] = "image";
constexpr char kDurationPath[] = "duration";
constexpr char kClearEventsPath[] = "clearEvents";
constexpr char kLogCrOSEventsPath[] = "shouldLogCrOSEvents";
constexpr char kPrimaryButtonPath[] = "primaryButton";
constexpr char kSecondaryButtonPath[] = "secondaryButton";
constexpr char kLabelPath[] = "label";
constexpr char kActionPath[] = "action";
constexpr char kMarkDismissedPath[] = "shouldMarkDismissed";
constexpr char kArrowPath[] = "arrow";
constexpr char kAnchorPath[] = "anchor";

// Nudge ID.
constexpr char kGrowthNudgeId[] = "growth_campaign_nudge";

constexpr base::TimeDelta kCancelDelay = base::Milliseconds(100);

// These values are deserialized from Growth Campaign, so entries should not
// be renumbered and numeric values should never be reused.
enum class NudgeDuration {
  kDefaultDuration,
  kMediumDuration,
  kLongDuration,
  kMaxValue = kLongDuration
};

ash::NudgeDuration ConvertDuration(NudgeDuration duration) {
  switch (duration) {
    case NudgeDuration::kDefaultDuration:
      return ash::NudgeDuration::kDefaultDuration;
    case NudgeDuration::kMediumDuration:
      return ash::NudgeDuration::kMediumDuration;
    case NudgeDuration::kLongDuration:
      return ash::NudgeDuration::kLongDuration;
  }
}

// These values are deserialized from Growth Campaign, so entries should not
// be renumbered and numeric values should never be reused.
enum class Arrow {
  kTopLeft,
  kTopRight,
  kBottomLeft,
  kBottomRight,
  kLeftTop,
  kRightTop,
  kLeftBottom,
  kRightBottom,
  kTopCenter,
  kBottomCenter,
  kLeftCenter,
  kRightCenter,
  kNone,
  kFloat,
  kMaxValue = kFloat
};

views::BubbleBorder::Arrow ConvertArrow(Arrow arrow) {
  switch (arrow) {
    case Arrow::kTopLeft:
      return views::BubbleBorder::Arrow::TOP_LEFT;
    case Arrow::kTopRight:
      return views::BubbleBorder::Arrow::TOP_RIGHT;
    case Arrow::kBottomLeft:
      return views::BubbleBorder::Arrow::BOTTOM_LEFT;
    case Arrow::kBottomRight:
      return views::BubbleBorder::Arrow::BOTTOM_RIGHT;
    case Arrow::kLeftTop:
      return views::BubbleBorder::Arrow::LEFT_TOP;
    case Arrow::kRightTop:
      return views::BubbleBorder::Arrow::RIGHT_TOP;
    case Arrow::kLeftBottom:
      return views::BubbleBorder::Arrow::LEFT_BOTTOM;
    case Arrow::kRightBottom:
      return views::BubbleBorder::Arrow::RIGHT_BOTTOM;
    case Arrow::kTopCenter:
      return views::BubbleBorder::Arrow::TOP_CENTER;
    case Arrow::kBottomCenter:
      return views::BubbleBorder::Arrow::BOTTOM_CENTER;
    case Arrow::kLeftCenter:
      return views::BubbleBorder::Arrow::LEFT_CENTER;
    case Arrow::kRightCenter:
      return views::BubbleBorder::Arrow::RIGHT_CENTER;
    case Arrow::kNone:
      return views::BubbleBorder::Arrow::NONE;
    case Arrow::kFloat:
      return views::BubbleBorder::Arrow::FLOAT;
  }
}

const std::string* GetNudgeTitle(const NudgePayload* nudge_payload) {
  CHECK(nudge_payload);
  return nudge_payload->FindString(kNudgeTitlePath);
}

const std::string* GetNudgeBody(const NudgePayload* nudge_payload) {
  CHECK(nudge_payload);
  return nudge_payload->FindString(kNudgeBodyPath);
}

void MaybeSetImageData(const base::Value::Dict* image_value,
                       ash::AnchoredNudgeData& nudge_data) {
  if (!image_value) {
    return;
  }

  auto image_model = growth::ImageModel(image_value).GetImageModel();
  if (!image_model) {
    // No image model matched the image payload.
    growth::RecordCampaignsManagerError(
        growth::CampaignsManagerError::kNudgePayloadInvalidImage);

    return;
  }

  nudge_data.image_model = image_model.value();
}

// Return the top level window widget.
views::Widget* GetTriggeringWindowWidget() {
  auto* session = CampaignsManagerSession::Get();
  if (!session) {
    CHECK_IS_TEST();
    return nullptr;
  }

  auto* window = session->GetOpenedWindow();
  if (!window) {
    growth::RecordCampaignsManagerError(
        growth::CampaignsManagerError::kNoOpendedWindowToAnchor);
    CAMPAIGNS_LOG(ERROR) << "Error: No app window";
    return nullptr;
  }

  auto* widget =
      views::Widget::GetWidgetForNativeWindow(window->GetToplevelWindow());
  if (!widget) {
    growth::RecordCampaignsManagerError(
        growth::CampaignsManagerError::kNoOpendedWindowWidgetToAnchor);
    CAMPAIGNS_LOG(ERROR) << "Error: widget not found";
    return nullptr;
  }

  return widget;
}

views::View* GetWindowCaptionButtonContainer() {
  // Currently, nudge can only be triggered by app opened, so it is safe to
  // assume that the triggering window is the window to anchor on. If we adding
  // other triggering UI element, we need to revisit this decision.
  auto* targeting_window_widget = GetTriggeringWindowWidget();
  if (!targeting_window_widget) {
    return nullptr;
  }

  auto* root_view = targeting_window_widget->GetRootView();
  if (!root_view) {
    growth::RecordCampaignsManagerError(
        growth::CampaignsManagerError::kNoRootViewToGetAnchorView);
    CAMPAIGNS_LOG(ERROR) << "Error: root view not found";
    return nullptr;
  }

  return root_view->GetViewByID(
      chromeos::ViewID::VIEW_ID_CAPTION_BUTTON_CONTAINER);
}

views::Widget* GetAnchorWidget() {
  // Currently the anchor widget is the triggering window widget.
  return GetTriggeringWindowWidget();
}

bool IsAnchorOnCaptionButtonContainer(
    std::optional<growth::WindowAnchorType> app_window_anchor_type) {
  return app_window_anchor_type &&
         app_window_anchor_type.value() ==
             growth::WindowAnchorType::kCaptionButtonContainer;
}

bool IsAnchorOnWindowBounds(
    std::optional<growth::WindowAnchorType> app_window_anchor_type) {
  return app_window_anchor_type && app_window_anchor_type.value() ==
                                       growth::WindowAnchorType::kWindowBounds;
}

std::optional<growth::Anchor> GetAnchorConfig(
    const base::Value::Dict* nudge_payload) {
  const auto* anchor_dict = nudge_payload->FindDict(kAnchorPath);
  if (!anchor_dict) {
    // No anchor specified. Anchor on the default position.
    return std::nullopt;
  }

  return std::make_optional<growth::Anchor>(anchor_dict);
}

// Get the anchor view.
// Returns:
// 1. nullptr if no anchor payload specified. Nudge will anchor at the default
//    position.
// 2. The targeted anchor view if available.
// 3. nullopt if the anchor view is not found. Skip showing nudge in this case.
std::optional<views::View*> GetAnchorView(const NudgePayload* nudge_payload) {
  auto anchor = GetAnchorConfig(nudge_payload);
  if (!anchor) {
    return nullptr;
  }

  auto app_window_anchor_type = anchor->GetActiveAppWindowAnchorType();
  if (app_window_anchor_type &&
      IsAnchorOnCaptionButtonContainer(app_window_anchor_type)) {
    auto* anchor_view = GetWindowCaptionButtonContainer();
    if (!anchor_view) {
      // Can't find the targeted view. Return nullopt and skip showing nudge.
      return std::nullopt;
    }

    return anchor_view;
  }

  auto* shelf_app_button_id = anchor->GetShelfAppButtonId();
  if (shelf_app_button_id) {
    auto* anchor_view =
        ash::Shell::GetPrimaryRootWindowController()
            ->shelf()
            ->hotseat_widget()
            ->GetShelfView()
            ->GetShelfAppButton(ash::ShelfID(*shelf_app_button_id));
    if (!anchor_view) {
      // Can't find the targeted view. Return nullopt and skip showing nudge.
      return std::nullopt;
    }

    return anchor_view;
  }

  // No anchor specified. Anchor on the default position.
  return nullptr;
}

}  // namespace

ShowNudgeActionPerformer::ShowNudgeActionPerformer() = default;

ShowNudgeActionPerformer::~ShowNudgeActionPerformer() {
  triggering_widget_ = nullptr;
}

void ShowNudgeActionPerformer::Run(int campaign_id,
                                   std::optional<int> group_id,
                                   const base::Value::Dict* action_params,
                                   growth::ActionPerformer::Callback callback) {
  if (!ShowNudge(campaign_id, group_id, action_params)) {
    // TODO: b/331953307 - callback with concrete failure result reason.
    std::move(callback).Run(growth::ActionResult::kFailure,
                            growth::ActionResultReason::kParsingActionFailed);
    return;
  }
  std::move(callback).Run(growth::ActionResult::kSuccess,
                          /*action_result_reason=*/std::nullopt);
}

growth::ActionType ShowNudgeActionPerformer::ActionType() const {
  return growth::ActionType::kShowNudge;
}

bool ShowNudgeActionPerformer::ShowNudge(int campaign_id,
                                         std::optional<int> group_id,
                                         const NudgePayload* nudge_payload) {
  if (!nudge_payload) {
    return false;
  }

  auto* body_text = GetNudgeBody(nudge_payload);
  if (!body_text) {
    growth::RecordCampaignsManagerError(
        growth::CampaignsManagerError::kNudgePayloadMissingBody);
    return false;
  }

  std::u16string nudge_body = base::UTF8ToUTF16(*body_text);

  auto nudge_data = ash::AnchoredNudgeData(
      kGrowthNudgeId, ash::NudgeCatalogName::kGrowthCampaignNudge, nudge_body,
      /*anchor_view=*/nullptr);

  // Set arrow.
  auto arrow_value =
      nudge_payload->FindInt(kArrowPath).value_or(int(Arrow::kBottomRight));
  if (arrow_value >= 0 && arrow_value <= static_cast<int>(Arrow::kMaxValue)) {
    nudge_data.arrow = ConvertArrow(static_cast<Arrow>(arrow_value));
  }

  auto anchor = GetAnchorConfig(nudge_payload);
  if (anchor &&
      IsAnchorOnWindowBounds(anchor->GetActiveAppWindowAnchorType())) {
    if (!ash::features::IsGrowthCampaignsShowNudgeInsideWindowBoundsEnabled()) {
      // Not showing the nudge that anchors on bounds but the feature is
      // disabled.
      return false;
    }

    auto* anchor_widget = GetAnchorWidget();
    if (!anchor_widget) {
      // No targeted anchor widget found. Skip showing nudge.
      growth::RecordCampaignsManagerError(
          growth::CampaignsManagerError::kNudgeAnchorWidgetNotFound);
      CAMPAIGNS_LOG(ERROR)
          << "Targeted anchor widget is not found. Skip showing nudge.";
      return false;
    }

    // The arrow type will determine the nudge position in the widget, although
    // we do not draw the arrow. Only the two bottom corners are supported.
    switch (nudge_data.arrow) {
      case views::BubbleBorder::Arrow::BOTTOM_LEFT:
      case views::BubbleBorder::Arrow::LEFT_BOTTOM:
      case views::BubbleBorder::Arrow::BOTTOM_RIGHT:
      case views::BubbleBorder::Arrow::RIGHT_BOTTOM:
        break;
      default:
        // Other arrows are not supported. Skip showing nudge.
        growth::RecordCampaignsManagerError(
            growth::CampaignsManagerError::kNudgeAnchorPositionNotSupported);
        CAMPAIGNS_LOG(ERROR)
            << "Position is not supported. Skip showing nudge.";
        return false;
    }

    nudge_data.anchor_widget = anchor_widget;
  } else {
    auto anchor_view = GetAnchorView(nudge_payload);
    if (!anchor_view) {
      // No targeted anchor view found. Skip showing nudge.
      growth::RecordCampaignsManagerError(
          growth::CampaignsManagerError::kNudgeAnchorViewNotFound);
      CAMPAIGNS_LOG(ERROR)
          << "Targeted anchor view is not found. Skip showing nudge.";
      return false;
    }

    nudge_data.SetAnchorView(anchor_view.value());
    if (!ash::features::IsGrowthCampaignsShowNudgeInDefaultParentEnabled() &&
        anchor_view.value()) {
      if (anchor && IsAnchorOnCaptionButtonContainer(
                        anchor->GetActiveAppWindowAnchorType())) {
        nudge_data.set_anchor_view_as_parent = true;
      }
    }
  }

  auto* title = GetNudgeTitle(nudge_payload);
  if (title && !title->empty()) {
    nudge_data.title_text = base::UTF8ToUTF16(*title);
  }

  // Set duration.
  auto duration_value = nudge_payload->FindInt(kDurationPath)
                            .value_or(int(NudgeDuration::kDefaultDuration));

  if (duration_value >= 0 &&
      duration_value <= static_cast<int>(NudgeDuration::kMaxValue)) {
    nudge_data.duration =
        ConvertDuration(static_cast<NudgeDuration>(duration_value));
  }

  // Default value of `should_log_cros_events` is false if this is not
  // configurated.
  const auto log_cros_events = nudge_payload->FindBool(kLogCrOSEventsPath);
  bool should_log_cros_events = log_cros_events.value_or(false);

  // Add buttons if available.
  MaybeSetButtonData(campaign_id, group_id,
                     nudge_payload->FindDict(kPrimaryButtonPath), nudge_data,
                     /*is_primary=*/true, should_log_cros_events);
  MaybeSetButtonData(campaign_id, group_id,
                     nudge_payload->FindDict(kSecondaryButtonPath), nudge_data,
                     /*is_primary=*/false, should_log_cros_events);

  // Set image data if available.
  MaybeSetImageData(nudge_payload->FindDict(kImagePath), nudge_data);

  // Set nudge dismiss callback.
  nudge_data.dismiss_callback =
      base::BindRepeating(&ShowNudgeActionPerformer::OnNudgeDismissed,
                          weak_ptr_factory_.GetWeakPtr(), campaign_id, group_id,
                          should_log_cros_events);

  // Shell may not be initialized in test.
  if (ash::Shell::HasInstance()) {
    ash::Shell::Get()->anchored_nudge_manager()->Show(nudge_data);

    if (ash::features::IsGrowthCampaignsCloseNudgeWhenTargetInactivated()) {
      auto* nudge =
          ash::Shell::Get()->anchored_nudge_manager()->GetNudgeIfShown(
              kGrowthNudgeId);
      if (nudge) {
        auto* nudge_widget = nudge->GetWidget();
        if (nudge_widget) {
          nudge_widget_scoped_observation_.Observe(nudge_widget);
        }
      }

      triggering_widget_ = GetTriggeringWindowWidget();
      if (triggering_widget_) {
        scoped_observation_.Observe(triggering_widget_);
      }
    }
  }

  // TODO: b/331045558 - Add close button callback.
  NotifyReadyToLogImpression(campaign_id, group_id, should_log_cros_events);

  const base::Value::List* clear_events =
      nudge_payload->FindList(kClearEventsPath);
  if (clear_events) {
    auto* campaigns_manager = growth::CampaignsManager::Get();
    CHECK(campaigns_manager);

    for (const auto& clear_event : *clear_events) {
      if (clear_event.is_string()) {
        campaigns_manager->ClearEvent(clear_event.GetString());
      }
    }
  }

  return true;
}

void ShowNudgeActionPerformer::MaybeSetButtonData(
    int campaign_id,
    std::optional<int> group_id,
    const base::Value::Dict* button_dict,
    ash::AnchoredNudgeData& nudge_data,
    bool is_primary,
    bool should_log_cros_events) {
  if (!button_dict) {
    return;
  }

  const auto* button_text_value = button_dict->FindString(kLabelPath);
  const auto* action = button_dict->FindDict(kActionPath);
  if (!button_text_value || button_text_value->empty() || !action) {
    return;
  }

  // Default value of `should_mark_dismissed` is false if this is not
  // configurated.
  const auto mark_dismissed = button_dict->FindBool(kMarkDismissedPath);
  bool should_mark_dismissed = mark_dismissed.value_or(false);

  auto button_text = base::UTF8ToUTF16(*button_text_value);
  auto callback = base::BindRepeating(
      &ShowNudgeActionPerformer::OnNudgeButtonClicked,
      weak_ptr_factory_.GetWeakPtr(), campaign_id, group_id,
      is_primary ? CampaignButtonId::kPrimary : CampaignButtonId::kSecondary,
      action, should_mark_dismissed, should_log_cros_events);
  if (is_primary) {
    nudge_data.primary_button_text = button_text;
    nudge_data.primary_button_callback = callback;
  } else {
    nudge_data.secondary_button_text = button_text;
    nudge_data.secondary_button_callback = callback;
  }
}

void ShowNudgeActionPerformer::OnNudgeButtonClicked(
    int campaign_id,
    std::optional<int> group_id,
    CampaignButtonId button_id,
    const base::Value::Dict* action_dict,
    bool should_mark_dismissed,
    bool should_log_cros_events) {
  NotifyButtonPressed(campaign_id, group_id, button_id, should_mark_dismissed,
                      should_log_cros_events);

  if (!action_dict) {
    return;
  }

  auto action = growth::Action(action_dict);
  auto action_type = action.GetActionType();
  if (!action_type) {
    return;
  }

  if (action_type.value() == growth::ActionType::kDismiss) {
    CancelNudge();

    // TODO(b/329671682): Log metrics.
    return;
  }

  auto* campaigns_manager = growth::CampaignsManager::Get();
  CHECK(campaigns_manager);

  campaigns_manager->PerformAction(campaign_id, group_id, &action);
}

void ShowNudgeActionPerformer::OnNudgeDismissed(int campaign_id,
                                                std::optional<int> group_id,
                                                bool should_log_cros_events) {
  // Dismissed automatically or by clicking on the X button. In this case, we
  // don't mark the nudge as dismissed and will resurface if impression
  // conditions met.
  NotifyDismissed(campaign_id, group_id, /*should_mark_dismissed=*/false,
                  should_log_cros_events);
}

void ShowNudgeActionPerformer::OnWidgetVisibilityChanged(views::Widget* widget,
                                                         bool visible) {
  if (!visible) {
    CancelNudge();
  }
}

void ShowNudgeActionPerformer::OnWidgetDestroying(views::Widget* widget) {
  CancelNudge();
}

void ShowNudgeActionPerformer::OnWidgetActivationChanged(views::Widget* widget,
                                                         bool active) {
  const auto* nudge =
      ash::Shell::Get()->anchored_nudge_manager()->GetNudgeIfShown(
          kGrowthNudgeId);
  if (nudge && widget == nudge->GetWidget()) {
    // Mark nudge activation state.
    is_nudge_active_ = active;
    return;
  }

  if (!active) {
    // Targeted app window widget is inactive, cancel the nudge in a delayed
    // task to make sure the `is_nudge_active` state is set before using.
    base::SequencedTaskRunner::GetCurrentDefault()->PostDelayedTask(
        FROM_HERE,
        base::BindOnce(&ShowNudgeActionPerformer::MaybeCancelNudge,
                       weak_ptr_factory_.GetWeakPtr()),
        kCancelDelay);
  }
}

void ShowNudgeActionPerformer::MaybeCancelNudge() {
  if (is_nudge_active_) {
    // The active widget is nudge. Skip canceling nudge.
    return;
  }

  CancelNudge();
}

void ShowNudgeActionPerformer::CancelNudge() {
  if (ash::features::IsGrowthCampaignsCloseNudgeWhenTargetInactivated()) {
    if (triggering_widget_) {
      scoped_observation_.Reset();
      triggering_widget_ = nullptr;
    }
    is_nudge_active_ = false;
    nudge_widget_scoped_observation_.Reset();
  }

  ash::Shell::Get()->anchored_nudge_manager()->Cancel(kGrowthNudgeId);
}