| // 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 "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 "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 "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(); |
| } |
| |
| // Returns true if the file referred to by `file_path` is of an image MIME type. |
| bool HasSupportedImageMimeType(const base::FilePath& file_path) { |
| std::string mime_type; |
| if (net::GetMimeTypeFromFile(file_path, &mime_type)) { |
| return blink::IsSupportedImageMimeType(mime_type); |
| } |
| return false; |
| } |
| |
| // 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(features::IsSysUiDownloadsIntegrationV2Enabled()); |
| 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: |
| // 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); |
| |
| // 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() && |
| HasSupportedImageMimeType(full_path)) { |
| 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::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 |