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

#include <functional>
#include <optional>
#include <utility>

#include "ash/constants/ash_features.h"
#include "ash/resources/vector_icons/vector_icons.h"
#include "ash/strings/grit/ash_strings.h"
#include "base/files/file_path.h"
#include "base/functional/bind.h"
#include "base/functional/callback_helpers.h"
#include "base/logging.h"
#include "base/ranges/algorithm.h"
#include "base/strings/string_number_conversions.h"
#include "base/strings/string_util.h"
#include "chrome/app/vector_icons/vector_icons.h"
#include "chrome/browser/ash/crosapi/download_status_updater_ash.h"
#include "chrome/browser/ash/file_manager/open_util.h"
#include "chrome/browser/ui/ash/download_status/display_client.h"
#include "chrome/browser/ui/ash/download_status/display_metadata.h"
#include "chrome/browser/ui/ash/download_status/holding_space_display_client.h"
#include "chrome/browser/ui/ash/download_status/notification_display_client.h"
#include "chrome/browser/ui/ash/system_web_apps/system_web_app_ui_utils.h"
#include "chrome/grit/generated_resources.h"
#include "chromeos/crosapi/mojom/download_controller.mojom.h"
#include "chromeos/crosapi/mojom/download_status_updater.mojom.h"
#include "net/base/mime_util.h"
#include "third_party/blink/public/common/mime_util/mime_util.h"
#include "ui/base/clipboard/clipboard_buffer.h"
#include "ui/base/clipboard/file_info.h"
#include "ui/base/clipboard/scoped_clipboard_writer.h"
#include "ui/gfx/image/image_skia.h"

namespace ash::download_status {

namespace {

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

// Indicates an unknown total bytes count of `crosapi::mojom::DownloadStatus`.
constexpr int64_t kUnknownTotalBytes = 0;

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

// Returns true if `download_status` provides sufficient data to display the
// associated download update.
bool CanDisplay(const crosapi::mojom::DownloadStatus& download_status) {
  const std::optional<base::FilePath>& file_path = download_status.full_path;
  return file_path.has_value() && !file_path->empty();
}

// Returns valid icons from `download_status` if any.
// NOTE: Returns a non-null only if both dark and light mode icons are valid.
crosapi::mojom::DownloadStatusIconsPtr GetIcons(
    const crosapi::mojom::DownloadStatus& download_status) {
  auto is_image_valid = [](const gfx::ImageSkia& image) {
    return !image.size().IsEmpty();
  };

  const crosapi::mojom::DownloadStatusIconsPtr& icons = download_status.icons;
  return icons && is_image_valid(icons->dark_mode) &&
                 is_image_valid(icons->light_mode)
             ? icons.Clone()
             : nullptr;
}

std::string GetPrintString(const std::optional<int64_t>& data) {
  return data.has_value() ? base::NumberToString(data.value()) : "null";
}

// Returns the progress indicated by `download_status`.
Progress GetProgress(const crosapi::mojom::DownloadStatus& download_status) {
  std::optional<int64_t> received_bytes;
  std::optional<int64_t> total_bytes;
  bool visible = false;

  if (const crosapi::mojom::DownloadProgressPtr& progress_ptr =
          download_status.progress) {
    received_bytes = progress_ptr->received_bytes;
    total_bytes = progress_ptr->total_bytes;
    visible = progress_ptr->visible;
  }

  // `received_bytes` and `total_bytes` could be invalid. Correct these numbers
  // if necessary. NOTE: `total_bytes` could be negative but `Progress` expects
  // a non-negative if `updated_total_bytes` has a value.
  std::optional<int64_t> updated_received_bytes = received_bytes;
  std::optional<int64_t> updated_total_bytes = total_bytes;

  if (received_bytes && received_bytes < 0) {
    LOG(ERROR) << "The received bytes count is invalid: expected a non "
                  "negative value; the actual value is "
               << GetPrintString(received_bytes);
    updated_received_bytes = std::nullopt;
  }

  if (total_bytes && total_bytes < kUnknownTotalBytes) {
    LOG(ERROR) << "The total bytes count is invalid: expected to be a non "
                  "negative value or 0 that indicates an unknown total bytes "
                  "count; the actual value is "
               << GetPrintString(total_bytes);
  }

  // Use `std::nullopt` to indicate an indeterminate total bytes count.
  if (updated_total_bytes <= kUnknownTotalBytes) {
    updated_total_bytes = std::nullopt;
  }

  const bool is_determinate = updated_received_bytes && updated_total_bytes;

  if (is_determinate && received_bytes > total_bytes) {
    LOG(ERROR) << "For a download that is determinate, its received bytes "
                  "count should not be greater than the total bytes count; the "
                  "actual received bytes count is "
               << GetPrintString(received_bytes)
               << " and the actual total bytes count is "
               << GetPrintString(total_bytes);
  }

  const bool complete =
      download_status.state == crosapi::mojom::DownloadState::kComplete;

  if (complete) {
    updated_received_bytes = updated_total_bytes =
        base::ranges::max({updated_received_bytes, updated_total_bytes,
                           std::optional<int64_t>(0)});
  } else if (is_determinate && updated_received_bytes > updated_total_bytes) {
    updated_total_bytes = updated_received_bytes;
  }

  return Progress(updated_received_bytes, updated_total_bytes, complete,
                  !visible);
}

// Returns the text to display for the download specified by `download_status`.
std::optional<std::u16string> GetText(
    const crosapi::mojom::DownloadStatus& download_status) {
  CHECK(CanDisplay(download_status));

  // By default, text is generated from the full path.
  std::reference_wrapper<const base::FilePath> file_path =
      *download_status.full_path;

  // Generate text from the target file path if:
  // 1. The associated download is in progress.
  // 2. The target file path exists.
  if (download_status.state == crosapi::mojom::DownloadState::kInProgress &&
      download_status.target_file_path) {
    file_path = *download_status.target_file_path;
  }

  return file_path.get().BaseName().LossyDisplayName();
}

// Opens the download file specified by `file_path` under the file system
// associated with `profile`.
void OpenFile(Profile* profile, const base::FilePath& file_path) {
  if (file_path.empty()) {
    LOG(ERROR) << "Tried to open a file with an empty path.";
    return;
  }

  // TODO(http://b/316368295): Track successful file openings as a metric.
  platform_util::OpenItem(profile, file_path,
                          platform_util::OpenItemType::OPEN_FILE,
                          /*callback=*/base::DoNothing());
}

// Shows the download file specified by `file_path` in the folder under the file
// system associated with `profile`.
void ShowInFolder(Profile* profile, const base::FilePath& file_path) {
  if (file_path.empty()) {
    LOG(ERROR) << "Tried to show a file in folder with an empty path.";
    return;
  }

  file_manager::util::ShowItemInFolder(profile, file_path,
                                       /*callback=*/base::DoNothing());
}

}  // namespace

DisplayManager::DisplayManager(
    Profile* profile,
    crosapi::DownloadStatusUpdaterAsh* download_status_updater)
    : profile_(profile), download_status_updater_(download_status_updater) {
  CHECK(download_status_updater_);

  CHECK(profile_);
  profile_observation_.Observe(profile_);

  clients_.push_back(std::make_unique<HoldingSpaceDisplayClient>(profile));
  clients_.push_back(std::make_unique<NotificationDisplayClient>(profile));
}

DisplayManager::~DisplayManager() = default;

void DisplayManager::Update(
    const crosapi::mojom::DownloadStatus& download_status) {
  switch (download_status.state) {
    case crosapi::mojom::DownloadState::kCancelled:
    case crosapi::mojom::DownloadState::kInterrupted:
      Remove(download_status.guid);
      return;
    case crosapi::mojom::DownloadState::kComplete:
    case crosapi::mojom::DownloadState::kInProgress: {
      if (!CanDisplay(download_status)) {
        // TODO(http://b/308192833): Add a metric to record the case where a
        // displayed download is removed because it cannot be displayed.
        Remove(download_status.guid);
        return;
      }
      const download_status::DisplayMetadata display_metadata =
          CalculateDisplayMetadata(download_status);
      for (auto& client : clients_) {
        client->AddOrUpdate(download_status.guid, display_metadata);
      }
      return;
    }
    case crosapi::mojom::DownloadState::kUnknown:
      return;
  }
}

void DisplayManager::OnProfileWillBeDestroyed(Profile* profile) {
  profile_observation_.Reset();
  profile_ = nullptr;
}

DisplayMetadata DisplayManager::CalculateDisplayMetadata(
    const crosapi::mojom::DownloadStatus& download_status) {
  CHECK(CanDisplay(download_status));

  DisplayMetadata display_metadata;

  std::vector<CommandInfo> command_infos;
  if (download_status.cancellable.value_or(false)) {
    command_infos.emplace_back(
        base::BindRepeating(&DisplayManager::PerformCommand,
                            weak_ptr_factory_.GetWeakPtr(),
                            CommandType::kCancel, download_status.guid),
        &kCancelIcon, IDS_ASH_DOWNLOAD_COMMAND_TEXT_CANCEL,
        CommandType::kCancel);
  }
  if (download_status.pausable.value_or(false)) {
    command_infos.emplace_back(
        base::BindRepeating(&DisplayManager::PerformCommand,
                            weak_ptr_factory_.GetWeakPtr(), CommandType::kPause,
                            download_status.guid),
        &kPauseIcon, IDS_ASH_DOWNLOAD_COMMAND_TEXT_PAUSE, CommandType::kPause);
  }
  if (download_status.resumable.value_or(false)) {
    command_infos.emplace_back(
        base::BindRepeating(&DisplayManager::PerformCommand,
                            weak_ptr_factory_.GetWeakPtr(),
                            CommandType::kResume, download_status.guid),
        &kResumeIcon, IDS_ASH_DOWNLOAD_COMMAND_TEXT_RESUME,
        CommandType::kResume);
  }
  const base::FilePath& full_path = *download_status.full_path;
  switch (download_status.state) {
    case crosapi::mojom::DownloadState::kComplete: {
      const base::FilePath::StringType ext = full_path.Extension();
      std::string mime_type;
      const bool has_mime_type = ext.empty()
                                     ? false
                                     : net::GetWellKnownMimeTypeFromExtension(
                                           ext.substr(1), &mime_type);

      // NOTE: `kOpenFile` is not shown so it doesn't require an icon/text_id.
      command_infos.emplace_back(
          base::BindRepeating(&DisplayManager::PerformCommand,
                              weak_ptr_factory_.GetWeakPtr(),
                              CommandType::kOpenFile, full_path),
          /*icon=*/nullptr, /*text_id=*/-1, CommandType::kOpenFile);

      std::optional<std::pair<CommandType, /*text_id=*/int>>
          media_app_command_metadata;

      if (mime_type == "application/pdf") {
        media_app_command_metadata =
            std::make_pair(CommandType::kEditWithMediaApp,
                           IDS_DOWNLOAD_NOTIFICATION_LABEL_OPEN_AND_EDIT);
      } else if (base::StartsWith(mime_type, "audio/",
                                  base::CompareCase::SENSITIVE) ||
                 base::StartsWith(mime_type, "video/",
                                  base::CompareCase::SENSITIVE)) {
        media_app_command_metadata =
            std::make_pair(CommandType::kOpenWithMediaApp,
                           IDS_DOWNLOAD_NOTIFICATION_LABEL_OPEN);
      }

      if (media_app_command_metadata) {
        command_infos.emplace_back(
            base::BindRepeating(&DisplayManager::PerformCommand,
                                weak_ptr_factory_.GetWeakPtr(),
                                media_app_command_metadata->first, full_path),
            /*icon=*/nullptr, media_app_command_metadata->second,
            media_app_command_metadata->first);
      }

      // NOTE: The `kShowInFolder` button does not have an icon.
      command_infos.emplace_back(
          base::BindRepeating(&DisplayManager::PerformCommand,
                              weak_ptr_factory_.GetWeakPtr(),
                              CommandType::kShowInFolder, full_path),
          /*icon=*/nullptr, IDS_ASH_DOWNLOAD_COMMAND_TEXT_SHOW_IN_FOLDER,
          CommandType::kShowInFolder);

      // Add a command to copy the download file to clipboard if:
      // 1. `download_status` has a valid image; AND
      // 2. The download file is an image.
      // NOTE: The `kCopyToClipboard` button does not require an icon.
      if (const gfx::ImageSkia& image = download_status.image;
          !image.isNull() && !image.size().IsEmpty() && has_mime_type &&
          blink::IsSupportedImageMimeType(mime_type)) {
        command_infos.emplace_back(
            base::BindRepeating(&DisplayManager::PerformCommand,
                                weak_ptr_factory_.GetWeakPtr(),
                                CommandType::kCopyToClipboard, full_path),
            /*icon=*/nullptr, IDS_ASH_DOWNLOAD_COMMAND_TEXT_COPY_TO_CLIPBOARD,
            CommandType::kCopyToClipboard);
      }
      break;
    }
    case crosapi::mojom::DownloadState::kInProgress:
      // NOTE: `kShowInBrowser` is not shown so doesn't require an icon/text_id.
      command_infos.emplace_back(
          base::BindRepeating(
              &DisplayManager::PerformCommand, weak_ptr_factory_.GetWeakPtr(),
              CommandType::kShowInBrowser, download_status.guid),
          /*icon=*/nullptr, /*text_id=*/-1, CommandType::kShowInBrowser);

      if (!download_status.cancellable.value_or(false) &&
          !download_status.pausable.value_or(false) &&
          !download_status.resumable.value_or(false)) {
        command_infos.emplace_back(
            base::BindRepeating(
                &DisplayManager::PerformCommand, weak_ptr_factory_.GetWeakPtr(),
                CommandType::kViewDetailsInBrowser, download_status.guid),
            &kOpenInBrowserIcon,
            IDS_ASH_DOWNLOAD_COMMAND_TEXT_VIEW_DETAILS_IN_BROWSER,
            CommandType::kViewDetailsInBrowser);
      }
      break;
    case crosapi::mojom::DownloadState::kCancelled:
    case crosapi::mojom::DownloadState::kInterrupted:
    case crosapi::mojom::DownloadState::kUnknown:
      break;
  }
  display_metadata.command_infos = std::move(command_infos);

  display_metadata.file_path = full_path;
  display_metadata.icons = GetIcons(download_status);
  display_metadata.image = download_status.image;
  display_metadata.progress = GetProgress(download_status);
  display_metadata.secondary_text = download_status.status_text;
  display_metadata.text = GetText(download_status);

  return display_metadata;
}

void DisplayManager::PerformCommand(
    CommandType command,
    const std::variant</*guid=*/std::string, base::FilePath>& param) {
  switch (command) {
    case CommandType::kCancel:
      download_status_updater_->Cancel(/*guid=*/std::get<std::string>(param),
                                       /*callback=*/base::DoNothing());
      break;
    case CommandType::kCopyToClipboard: {
      ui::ScopedClipboardWriter scw(ui::ClipboardBuffer::kCopyPaste);
      scw.WriteFilenames(ui::FileInfosToURIList(
          /*filenames=*/{ui::FileInfo(std::get<base::FilePath>(param),
                                      /*display_name=*/base::FilePath())}));
      break;
    }
    case CommandType::kEditWithMediaApp:
    case CommandType::kOpenWithMediaApp: {
      SystemAppLaunchParams app_launch_params;
      app_launch_params.launch_paths.push_back(std::get<base::FilePath>(param));
      LaunchSystemWebAppAsync(profile_, SystemWebAppType::MEDIA,
                              app_launch_params);
      break;
    }
    case CommandType::kOpenFile:
      OpenFile(profile_, std::get<base::FilePath>(param));
      break;
    case CommandType::kPause:
      download_status_updater_->Pause(/*guid=*/std::get<std::string>(param),
                                      /*callback=*/base::DoNothing());
      break;
    case CommandType::kResume:
      download_status_updater_->Resume(/*guid=*/std::get<std::string>(param),
                                       /*callback=*/base::DoNothing());
      break;
    case CommandType::kShowInBrowser:
      download_status_updater_->ShowInBrowser(
          /*guid=*/std::get<std::string>(param),
          /*callback=*/base::DoNothing());
      break;
    case CommandType::kShowInFolder:
      ShowInFolder(profile_, std::get<base::FilePath>(param));
      break;
    case CommandType::kViewDetailsInBrowser:
      download_status_updater_->ShowInBrowser(
          /*guid=*/std::get<std::string>(param),
          /*callback=*/base::DoNothing());
      break;
  }
}

void DisplayManager::Remove(const std::string& guid) {
  for (auto& client : clients_) {
    client->Remove(guid);
  }
}

}  // namespace ash::download_status