chromium/chrome/browser/ui/ash/download_status/notification_display_client.cc

// 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 "chrome/browser/ui/ash/download_status/notification_display_client.h"

#include <optional>
#include <utility>
#include <vector>

#include "ash/constants/ash_features.h"
#include "ash/constants/notifier_catalogs.h"
#include "ash/public/cpp/system_notification_builder.h"
#include "base/check.h"
#include "base/containers/contains.h"
#include "base/functional/bind.h"
#include "base/functional/callback.h"
#include "base/functional/callback_helpers.h"
#include "base/memory/scoped_refptr.h"
#include "base/memory/weak_ptr.h"
#include "base/metrics/user_metrics.h"
#include "base/strings/strcat.h"
#include "chrome/browser/ash/profiles/profile_helper.h"
#include "chrome/browser/notifications/notification_display_service.h"
#include "chrome/browser/notifications/notification_handler.h"
#include "chrome/browser/profiles/profile.h"
#include "chrome/browser/ui/ash/download_status/display_metadata.h"
#include "chrome/grit/generated_resources.h"
#include "chromeos/crosapi/mojom/download_status_updater.mojom.h"
#include "components/account_id/account_id.h"
#include "components/user_manager/user.h"
#include "components/vector_icons/vector_icons.h"
#include "skia/ext/image_operations.h"
#include "third_party/skia/include/core/SkBitmap.h"
#include "ui/base/l10n/l10n_util.h"
#include "ui/base/models/image_model.h"
#include "ui/base/resource/resource_scale_factor.h"
#include "ui/gfx/geometry/rect.h"
#include "ui/gfx/geometry/size.h"
#include "ui/gfx/image/image.h"
#include "ui/gfx/image/image_skia.h"
#include "ui/gfx/skbitmap_operations.h"
#include "ui/message_center/public/cpp/notification.h"
#include "ui/message_center/public/cpp/notification_delegate.h"
#include "ui/message_center/public/cpp/notification_types.h"
#include "ui/message_center/public/cpp/notifier_id.h"
#include "url/gurl.h"

namespace ash::download_status {

namespace {

// Constants -------------------------------------------------------------------

// A notification image's preferred size.
constexpr gfx::Size kNotificationImagePreferredSize(/*width=*/360,
                                                    /*height=*/240);

constexpr char kNotificationNotifierId[] =
    "chrome://downloads/notification/id-notifier";

constexpr char kNotificationOrigin[] = "chrome://downloads";

// DownloadNotificationDelegate ------------------------------------------------

class DownloadNotificationDelegate
    : public message_center::NotificationDelegate {
 public:
  DownloadNotificationDelegate(
      std::vector<base::RepeatingClosure> button_click_callbacks,
      base::RepeatingClosure body_click_callback,
      base::RepeatingClosure closed_by_user_callback)
      : button_click_callbacks_(std::move(button_click_callbacks)),
        body_click_callback_(std::move(body_click_callback)),
        closed_by_user_callback_(std::move(closed_by_user_callback)) {}
  DownloadNotificationDelegate(const DownloadNotificationDelegate&) = delete;
  DownloadNotificationDelegate& operator=(const DownloadNotificationDelegate&) =
      delete;

 private:
  // message_center::NotificationDelegate:
  ~DownloadNotificationDelegate() override = default;

  void Click(const std::optional<int>& button_index,
             const std::optional<std::u16string>& reply) override {
    if (button_index >= 0 && button_index < button_click_callbacks_.size()) {
      button_click_callbacks_[*button_index].Run();
      return;
    }

    if (!button_index) {
      body_click_callback_.Run();
    }
  }

  void Close(bool by_user) override {
    if (by_user) {
      closed_by_user_callback_.Run();
    }
  }

  // Callbacks for handling button click events, listed in the order of their
  // corresponding buttons.
  const std::vector<base::RepeatingClosure> button_click_callbacks_;

  // Runs when the notification body is clicked.
  const base::RepeatingClosure body_click_callback_;

  // Runs when the observed notification is closed by user.
  const base::RepeatingClosure closed_by_user_callback_;
};

// Helpers ---------------------------------------------------------------------

// Calculates the progress value accepted by the notification progress bar.
// Returns a `std::nullopt` if the notification progress bar should be hidden.
std::optional<int> CalculateProgressValue(const Progress& progress) {
  // When `progress` is hidden, the notification's progress bar does not show.
  if (progress.hidden()) {
    return std::nullopt;
  }

  const std::optional<int64_t>& received_bytes = progress.received_bytes();
  const std::optional<int64_t>& total_bytes = progress.total_bytes();

  // NOTE: `total_bytes` could be zero. Therefore, check the equality of
  // `received_bytes` and `total_bytes` before division. In addition, the
  // equality of `received_bytes` and `total_bytes` does not necessarily mean
  // that `complete` is true.
  if (progress.complete() ||
      (received_bytes && received_bytes == total_bytes)) {
    return 100;
  }

  if (received_bytes >= 0 && total_bytes > 0) {
    return *received_bytes * 100.f / *total_bytes;
  }

  // Indicate an indeterminate progress bar.
  return -1;
}

const char* GetMetricString(CommandType command) {
  switch (command) {
    case CommandType::kCancel:
      return "DownloadNotificationV2.Button_Cancel";
    case CommandType::kCopyToClipboard:
      return "DownloadNotificationV2.Button_CopyToClipboard";
    case CommandType::kEditWithMediaApp:
      return "DownloadNotificationV2.Button_EditWithMediaApp";
    case CommandType::kOpenFile:
      return "DownloadNotificationV2.Click_Completed";
    case CommandType::kOpenWithMediaApp:
      return "DownloadNotificationV2.Button_OpenWithMediaApp";
    case CommandType::kPause:
      return "DownloadNotificationV2.Button_Pause";
    case CommandType::kResume:
      return "DownloadNotificationV2.Button_Resume";
    case CommandType::kShowInBrowser:
      return "DownloadNotificationV2.Click_InProgress";
    case CommandType::kShowInFolder:
      return "DownloadNotificationV2.Button_ShowInFolder";
    case CommandType::kViewDetailsInBrowser:
      return "DownloadNotificationV2.Button_ViewDetailsInBrowser";
  }
}

// Returns true if the execution of `command` is triggered by a click on a
// notification body.
bool IsBodyClickCommandType(CommandType command) {
  switch (command) {
    case CommandType::kOpenFile:
    case CommandType::kShowInBrowser:
      return true;
    case CommandType::kCancel:
    case CommandType::kCopyToClipboard:
    case CommandType::kEditWithMediaApp:
    case CommandType::kOpenWithMediaApp:
    case CommandType::kPause:
    case CommandType::kResume:
    case CommandType::kShowInFolder:
    case CommandType::kViewDetailsInBrowser:
      return false;
  }
}

// Returns true if the execution of `command` is triggered by a click on a
// notification button.
bool IsButtonClickCommandType(CommandType command) {
  switch (command) {
    case CommandType::kCancel:
    case CommandType::kCopyToClipboard:
    case CommandType::kEditWithMediaApp:
    case CommandType::kOpenWithMediaApp:
    case CommandType::kPause:
    case CommandType::kResume:
    case CommandType::kShowInFolder:
    case CommandType::kViewDetailsInBrowser:
      return true;
    case CommandType::kOpenFile:
    case CommandType::kShowInBrowser:
      return false;
  }
}

void RecordCommand(CommandType command) {
  base::RecordAction(base::UserMetricsAction(GetMetricString(command)));
}

// Returns the callback that runs when the notification body associated with
// `display_metadata` is clicked.
base::RepeatingClosure GetNotificationBodyClickCallback(
    Profile* profile,
    const DisplayMetadata& display_metadata) {
  for (const auto& command : display_metadata.command_infos) {
    if (const CommandType type = command.type; IsBodyClickCommandType(type)) {
      return command.command_callback.Then(
          base::BindRepeating(&RecordCommand, type));
    }
  }

  LOG(ERROR) << "Failed to find a notification body click callback";
  return base::DoNothing();
}

// NOTE: This function returns a non-empty string indicating the notification
// text, but does not guarantee the presence of a notification.
std::string GetNotificationIdFromGuid(const std::string& guid) {
  return base::StrCat({kNotificationNotifierId, "/", guid});
}

// Returns a notification image from `original_image`. This function should be
// called only when the image of `original_image` is not null nor empty.
// NOTE: This function avoids using image skia operations to prevent unnecessary
// retention of original image data.
gfx::Image GetNotificationImage(const gfx::ImageSkia& original_image) {
  CHECK(!original_image.isNull());
  CHECK(!original_image.size().IsEmpty());

  const float target_aspect_ratio =
      static_cast<float>(kNotificationImagePreferredSize.width()) /
      kNotificationImagePreferredSize.height();
  const float original_aspect_ratio =
      static_cast<float>(original_image.width()) / original_image.height();

  // Get the largest rect from `original_image` that has `target_aspect_ratio`.
  gfx::Rect source_rect;
  if (original_aspect_ratio > target_aspect_ratio) {
    const float width = original_image.height() * target_aspect_ratio;
    source_rect = gfx::Rect(/*x=*/(original_image.width() - width) / 2,
                            /*y=*/0, width, original_image.height());
  } else {
    const float height = original_image.width() / target_aspect_ratio;
    source_rect =
        gfx::Rect(/*x=*/0, /*y=*/(original_image.height() - height) / 2,
                  original_image.width(), height);
  }
  const SkBitmap cropped_bitmap = SkBitmapOperations::CreateTiledBitmap(
      *original_image.bitmap(), source_rect.x(), source_rect.y(),
      source_rect.width(), source_rect.height());

  // Find the largest supported scale factor for the returned image without
  // upscaling `original_image`.
  gfx::Size scaled_preferred_size = kNotificationImagePreferredSize;
  float largest_scale = 1.f;
  for (const auto& scale_factor : ui::GetSupportedResourceScaleFactors()) {
    const float scale = ui::GetScaleForResourceScaleFactor(scale_factor);
    if (scale <= 1.f) {
      continue;
    }

    if (const gfx::Size scaled_size =
            gfx::ScaleToCeiledSize(kNotificationImagePreferredSize, scale);
        gfx::Rect(original_image.size()).Contains(gfx::Rect(scaled_size))) {
      largest_scale = scale;
      scaled_preferred_size = scaled_size;
    }
  }

  const SkBitmap resized_bitmap = skia::ImageOperations::Resize(
      cropped_bitmap, skia::ImageOperations::RESIZE_LANCZOS3,
      scaled_preferred_size.width(), scaled_preferred_size.height());

  return gfx::Image(
      gfx::ImageSkia::CreateFromBitmap(resized_bitmap, largest_scale));
}

}  // namespace

NotificationDisplayClient::NotificationDisplayClient(Profile* profile)
    : DisplayClient(profile) {}

NotificationDisplayClient::~NotificationDisplayClient() = default;

void NotificationDisplayClient::AddOrUpdate(
    const std::string& guid,
    const DisplayMetadata& display_metadata) {
  // Do not show the notification if it has been closed by user.
  if (base::Contains(notifications_closed_by_user_guids_, guid)) {
    return;
  }

  // Get button infos from `display_metadata`.
  std::vector<base::RepeatingClosure> button_click_callbacks;
  std::vector<message_center::ButtonInfo> buttons;
  for (const auto& command_info : display_metadata.command_infos) {
    if (const CommandType type = command_info.type;
        IsButtonClickCommandType(type)) {
      button_click_callbacks.push_back(command_info.command_callback.Then(
          base::BindRepeating(&RecordCommand, type)));
      buttons.emplace_back(l10n_util::GetStringUTF16(command_info.text_id));
    }
  }

  message_center::RichNotificationData rich_notification_data;
  rich_notification_data.buttons = std::move(buttons);
  rich_notification_data.fullscreen_visibility =
      message_center::FullscreenVisibility::OVER_USER;
  rich_notification_data.should_make_spoken_feedback_for_popup_updates = false;
  rich_notification_data.vector_small_image =
      &vector_icons::kNotificationDownloadIcon;

  const Progress& progress = display_metadata.progress;
  if (const std::optional<int> progress_value =
          CalculateProgressValue(progress)) {
    rich_notification_data.progress = *progress_value;
    rich_notification_data.progress_status =
        display_metadata.secondary_text.value_or(std::u16string());
  }

  message_center::Notification notification =
      SystemNotificationBuilder()
          .SetDelegate(base::MakeRefCounted<DownloadNotificationDelegate>(
              std::move(button_click_callbacks),
              GetNotificationBodyClickCallback(profile(), display_metadata),
              base::BindRepeating(
                  &NotificationDisplayClient::OnNotificationClosedByUser,
                  weak_ptr_factory_.GetWeakPtr(), guid)))
          .SetDisplaySource(l10n_util::GetStringUTF16(
              IDS_DOWNLOAD_NOTIFICATION_DISPLAY_SOURCE))
          .SetId(GetNotificationIdFromGuid(guid))
          .SetMessage(
              progress.hidden()
                  ? display_metadata.secondary_text.value_or(std::u16string())
                  : std::u16string())
          .SetNotifierId(message_center::NotifierId(
              message_center::NotifierType::SYSTEM_COMPONENT,
              kNotificationNotifierId,
              NotificationCatalogName::kDownloadNotification))
          .SetOptionalFields(std::move(rich_notification_data))
          .SetOriginUrl(GURL(kNotificationOrigin))
          .SetTitle(display_metadata.text.value_or(std::u16string()))
          .SetType(progress.hidden()
                       ? message_center::NOTIFICATION_TYPE_SIMPLE
                       : message_center::NOTIFICATION_TYPE_PROGRESS)
          .Build(/*keep_timestamp=*/false);

  if (const gfx::ImageSkia& image = display_metadata.image;
      !image.isNull() && !image.size().IsEmpty()) {
    notification.SetImage(GetNotificationImage(image));
    notification.set_image_path(display_metadata.file_path);
  }

  NotificationDisplayService::GetForProfile(profile())->Display(
      NotificationHandler::Type::TRANSIENT, std::move(notification),
      /*metadata=*/nullptr);

  if (progress.complete()) {
    // The download associated with `guid` completes. We no longer anticipate
    // receiving download updates. Therefore, remove `guid` from the collection.
    notifications_closed_by_user_guids_.erase(guid);
  }
}

void NotificationDisplayClient::Remove(const std::string& guid) {
  // The download associated with `guid` is removed. We no longer anticipate
  // receiving download updates. Therefore, remove `guid` from the collection.
  notifications_closed_by_user_guids_.erase(guid);

  NotificationDisplayService::GetForProfile(profile())->Close(
      NotificationHandler::Type::TRANSIENT, GetNotificationIdFromGuid(guid));
}

void NotificationDisplayClient::OnNotificationClosedByUser(
    const std::string& guid) {
  notifications_closed_by_user_guids_.insert(guid);
}

}  // namespace ash::download_status