chromium/chrome/browser/ui/webui/ash/cloud_upload/drive_upload_handler.cc

// Copyright 2022 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/webui/ash/cloud_upload/drive_upload_handler.h"

#include "base/check_op.h"
#include "base/files/file_path.h"
#include "base/functional/callback_forward.h"
#include "base/i18n/message_formatter.h"
#include "base/metrics/histogram_functions.h"
#include "base/metrics/histogram_macros.h"
#include "base/notreached.h"
#include "base/strings/string_util.h"
#include "base/strings/utf_string_conversions.h"
#include "base/time/time.h"
#include "base/timer/timer.h"
#include "chrome/browser/ash/drive/file_system_util.h"
#include "chrome/browser/ash/file_manager/copy_or_move_io_task.h"
#include "chrome/browser/ash/file_manager/delete_io_task.h"
#include "chrome/browser/ash/file_manager/fileapi_util.h"
#include "chrome/browser/ash/file_manager/io_task.h"
#include "chrome/browser/ash/file_manager/office_file_tasks.h"
#include "chrome/browser/ash/file_manager/volume_manager.h"
#include "chrome/browser/ui/webui/ash/cloud_upload/cloud_upload_util.h"
#include "chrome/grit/generated_resources.h"
#include "chromeos/ash/components/drivefs/drivefs_host.h"
#include "ui/base/l10n/l10n_util.h"

using storage::FileSystemURL;

namespace ash::cloud_upload {
namespace {

// The maximum amount of time allowed, in seconds, between the syncing
// completion of a file and the update of its metadata with the expected (Google
// editor) alternate URL.
const int kAlternateUrlTimeout = 15;

// The polling interval, in milliseconds, for querying the uploaded file's
// alternate URL.
const int kAlternateUrlPollInterval = 200;

std::string GetTargetAppName(base::FilePath file_path) {
  const std::string extension = base::ToLowerASCII(file_path.FinalExtension());
  if (base::Contains(file_manager::file_tasks::WordGroupExtensions(),
                     extension)) {
    return l10n_util::GetStringUTF8(IDS_OFFICE_FILE_HANDLER_APP_GOOGLE_DOCS);
  }
  if (base::Contains(file_manager::file_tasks::ExcelGroupExtensions(),
                     extension)) {
    return l10n_util::GetStringUTF8(IDS_OFFICE_FILE_HANDLER_APP_GOOGLE_SHEETS);
  }
  if (base::Contains(file_manager::file_tasks::PowerPointGroupExtensions(),
                     extension)) {
    return l10n_util::GetStringUTF8(IDS_OFFICE_FILE_HANDLER_APP_GOOGLE_SLIDES);
  }
  return l10n_util::GetStringUTF8(IDS_OFFICE_FILE_HANDLER_APP_GOOGLE_DOCS);
}

}  // namespace

DriveUploadHandler::DriveUploadHandler(
    Profile* profile,
    const FileSystemURL& source_url,
    UploadCallback callback,
    base::SafeRef<CloudOpenMetrics> cloud_open_metrics)
    : profile_(profile),
      file_system_context_(
          file_manager::util::GetFileManagerFileSystemContext(profile)),
      drive_integration_service_(
          drive::DriveIntegrationServiceFactory::FindForProfile(profile)),
      upload_type_(GetUploadType(profile, source_url)),
      notification_manager_(
          base::MakeRefCounted<CloudUploadNotificationManager>(
              profile,
              l10n_util::GetStringUTF8(IDS_OFFICE_CLOUD_PROVIDER_GOOGLE_DRIVE),
              GetTargetAppName(source_url.path()),
              // TODO(b/242685536) Update when support for multi-files is added.
              /*num_files=*/1,
              upload_type_)),
      source_url_(source_url),
      callback_(std::move(callback)),
      cloud_open_metrics_(cloud_open_metrics) {
  observed_copy_task_id_ = -1;
  observed_delete_task_id_ = -1;
}

DriveUploadHandler::~DriveUploadHandler() = default;

void DriveUploadHandler::Run() {
  DCHECK(callback_);

  if (!profile_) {
    LOG(ERROR) << "No profile";
    OnEndCopy(OfficeFilesUploadResult::kOtherError);
    return;
  }

  file_manager::VolumeManager* volume_manager =
      file_manager::VolumeManager::Get(profile_);
  if (!volume_manager) {
    LOG(ERROR) << "No volume manager";
    OnEndCopy(OfficeFilesUploadResult::kOtherError);
    return;
  }
  io_task_controller_ = volume_manager->io_task_controller();
  if (!io_task_controller_) {
    LOG(ERROR) << "No task_controller";
    OnEndCopy(OfficeFilesUploadResult::kOtherError);
    return;
  }

  if (!drive_integration_service_) {
    LOG(ERROR) << "No Drive integration service";
    OnEndCopy(OfficeFilesUploadResult::kOtherError);
    return;
  }

  if (drive::util::GetDriveConnectionStatus(profile_) !=
      drive::util::ConnectionStatus::kConnected) {
    LOG(ERROR) << "No connection to Drive";
    OnEndCopy(OfficeFilesUploadResult::kNoConnection);
    return;
  }

  // Observe IO tasks updates.
  io_task_controller_observer_.Observe(io_task_controller_);

  // Observe Drive updates.
  drive::DriveIntegrationService::Observer::Observe(drive_integration_service_);
  drivefs::DriveFsHost::Observer::Observe(
      drive_integration_service_->GetDriveFsHost());

  if (!drive_integration_service_->IsMounted()) {
    LOG(ERROR) << "Google Drive is not mounted";
    OnEndCopy(OfficeFilesUploadResult::kFileSystemNotFound);
    return;
  }

  // Destination url.
  base::FilePath destination_folder_path =
      drive_integration_service_->GetMountPointPath().Append("root");
  FileSystemURL destination_folder_url = FilePathToFileSystemURL(
      profile_, file_system_context_, destination_folder_path);
  // TODO (b/243095484) Define error behavior.
  if (!destination_folder_url.is_valid()) {
    LOG(ERROR) << "Unable to generate destination folder Drive URL";
    OnEndCopy(OfficeFilesUploadResult::kFileSystemNotFound);
    return;
  }

  std::vector<FileSystemURL> source_urls{source_url_};
  // Always use a copy task. Will convert to a move upon success.
  std::unique_ptr<file_manager::io_task::IOTask> copy_task =
      std::make_unique<file_manager::io_task::CopyOrMoveIOTask>(
          file_manager::io_task::OperationType::kCopy, std::move(source_urls),
          std::move(destination_folder_url), profile_, file_system_context_,
          /*show_notification=*/false);

  observed_copy_task_id_ = io_task_controller_->Add(std::move(copy_task));
}

void DriveUploadHandler::UpdateProgressNotification() {
  // The move progress and the syncing progress arbitrarily respectively account
  // for 20% and 80% of the upload workflow.
  int progress = move_progress_ * 0.2 + sync_progress_ * 0.8;
  notification_manager_->ShowUploadProgress(progress);
}

void DriveUploadHandler::OnEndCopy(
    OfficeFilesUploadResult result_metric,
    base::expected<GURL, std::string> hosted_url) {
  if (copy_ended_) {
    // Prevent loops in case Copy IO task and Drive sync fail separately.
    return;
  }
  copy_ended_ = true;

  // Set the `end_upload_callback_` based on if the upload was successful.
  if (hosted_url.has_value()) {
    end_upload_callback_ = base::BindOnce(
        &DriveUploadHandler::OnSuccessfulUpload, weak_ptr_factory_.GetWeakPtr(),
        result_metric, hosted_url.value());
  } else {
    end_upload_callback_ = base::BindOnce(&DriveUploadHandler::OnFailedUpload,
                                          weak_ptr_factory_.GetWeakPtr(),
                                          result_metric, hosted_url.error());
  }

  // If copy to Drive was successful and intended operation is a copy, no delete
  // is required.
  if (hosted_url.has_value() && upload_type_ == UploadType::kCopy) {
    std::move(end_upload_callback_).Run();
    return;
  }

  // If destination file doesn't exist, no delete is required.
  base::FilePath rel_path;
  bool destination_file_exists =
      !observed_absolute_dest_path_.empty() &&
      drive_integration_service_->GetRelativeDrivePath(
          observed_absolute_dest_path_, &rel_path);
  if (!destination_file_exists) {
    std::move(end_upload_callback_).Run();
    return;
  }

  std::vector<FileSystemURL> file_urls;
  if (hosted_url.has_value()) {
    // If copy to Drive was successful, delete source file to convert the upload
    // to a move to Drive.
    file_urls.push_back(source_url_);
  } else {
    // If copy to Drive was unsuccessful, delete destination file to undo the
    // copy to Drive.
    FileSystemURL dest_url = FilePathToFileSystemURL(
        profile_, file_system_context_, observed_absolute_dest_path_);
    file_urls.push_back(dest_url);
  }

  std::unique_ptr<file_manager::io_task::IOTask> task =
      std::make_unique<file_manager::io_task::DeleteIOTask>(
          std::move(file_urls), file_system_context_,
          /*show_notification=*/false);
  observed_delete_task_id_ = io_task_controller_->Add(std::move(task));
}

void DriveUploadHandler::OnSuccessfulUpload(
    OfficeFilesUploadResult result_metric,
    GURL hosted_url) {
  cloud_open_metrics_->LogUploadResult(result_metric);
  // TODO (b/243095484) Define error behavior on invalid hosted URL.
  observed_relative_drive_path_.clear();
  // Stop suppressing Drive events for the observed file.
  scoped_suppress_drive_notifications_for_path_.reset();
  // Show complete notification.
  if (notification_manager_) {
    notification_manager_->MarkUploadComplete();
  }
  const OfficeTaskResult task_result = upload_type_ == UploadType::kCopy
                                           ? OfficeTaskResult::kCopied
                                           : OfficeTaskResult::kMoved;
  std::move(callback_).Run(task_result, hosted_url, upload_size_);
}

void DriveUploadHandler::OnFailedUpload(OfficeFilesUploadResult result_metric,
                                        std::string error_message) {
  cloud_open_metrics_->LogUploadResult(result_metric);
  // TODO (b/243095484) Define error behavior on invalid hosted URL.
  observed_relative_drive_path_.clear();
  // Stop suppressing Drive events for the observed file.
  scoped_suppress_drive_notifications_for_path_.reset();
  // Show error notification.
  if (notification_manager_) {
    LOG(ERROR) << "Upload to Google Drive: " << error_message;
    notification_manager_->ShowUploadError(error_message);
  }
  std::move(callback_).Run(OfficeTaskResult::kFailedToUpload, std::nullopt, 0);
}

void DriveUploadHandler::OnIOTaskStatus(
    const file_manager::io_task::ProgressStatus& status) {
  if (status.task_id == observed_copy_task_id_) {
    OnCopyStatus(status);
    return;
  }
  if (status.task_id == observed_delete_task_id_) {
    OnDeleteStatus(status);
    return;
  }
}

void DriveUploadHandler::OnCopyStatus(
    const ::file_manager::io_task::ProgressStatus& status) {
  switch (status.state) {
    case file_manager::io_task::State::kScanning:
      // TODO(crbug.com/1361915): Potentially adapt to show scanning.
    case file_manager::io_task::State::kQueued:
      return;
    case file_manager::io_task::State::kInProgress:
      if (status.total_bytes > 0) {
        upload_size_ = status.total_bytes;
        move_progress_ = 100 * status.bytes_transferred / status.total_bytes;
      }
      UpdateProgressNotification();
      if (observed_relative_drive_path_.empty()) {
        // TODO (b/242685536) Define multiple-file handling.
        DCHECK_EQ(status.sources.size(), 1u);
        DCHECK_EQ(status.outputs.size(), 1u);

        if (!drive_integration_service_) {
          LOG(ERROR) << "No Drive integration service";
          OnEndCopy(OfficeFilesUploadResult::kOtherError);
          return;
        }

        // Get the output path from the IOTaskController's ProgressStatus. The
        // destination file name is not known in advance, given that it's
        // generated from the IOTaskController which resolves potential name
        // clashes.
        observed_absolute_dest_path_ = status.outputs[0].url.path();
        drive_integration_service_->GetRelativeDrivePath(
            observed_absolute_dest_path_, &observed_relative_drive_path_);
        scoped_suppress_drive_notifications_for_path_ = std::make_unique<
            file_manager::ScopedSuppressDriveNotificationsForPath>(
            profile_, observed_relative_drive_path_);
      }
      return;
    case file_manager::io_task::State::kPaused:
      return;
    case file_manager::io_task::State::kSuccess:
      move_progress_ = 100;
      notification_manager_->SetDestinationPath(status.outputs[0].url.path());
      UpdateProgressNotification();
      DCHECK_EQ(status.outputs.size(), 1u);
      return;
    case file_manager::io_task::State::kCancelled:
      LOG(ERROR) << "Upload to Google Drive cancelled";
      if (upload_type_ == UploadType::kCopy) {
        OnEndCopy(OfficeFilesUploadResult::kCopyOperationCancelled);
      } else {
        OnEndCopy(OfficeFilesUploadResult::kMoveOperationCancelled);
      }
      return;
    case file_manager::io_task::State::kError:
      ShowIOTaskError(status);
      return;
    case file_manager::io_task::State::kNeedPassword:
      NOTREACHED_IN_MIGRATION()
          << "Encrypted file should not need password to be copied or "
             "moved. Case should not be reached.";
      return;
  }
}

void DriveUploadHandler::OnDeleteStatus(
    const ::file_manager::io_task::ProgressStatus& status) {
  switch (status.state) {
    case file_manager::io_task::State::kCancelled:
      NOTREACHED_IN_MIGRATION()
          << "Deletion of source or destination file should not have "
             "been cancelled.";
      ABSL_FALLTHROUGH_INTENDED;
    case file_manager::io_task::State::kError:
    case file_manager::io_task::State::kSuccess:
      std::move(end_upload_callback_).Run();
      return;
    default:
      return;
  }
}

void DriveUploadHandler::ShowIOTaskError(
    const file_manager::io_task::ProgressStatus& status) {
  OfficeFilesUploadResult upload_result;
  std::string error_message;
  bool copy = upload_type_ == UploadType::kCopy;

  // TODO(b/242685536) Find most relevant error in a multi-file upload when
  // support for multi-files is added.
  base::File::Error file_error =
      GetFirstTaskError(status).value_or(base::File::FILE_ERROR_FAILED);

  if (copy) {
    cloud_open_metrics_->LogCopyError(file_error);
  } else {
    cloud_open_metrics_->LogMoveError(file_error);
  }

  switch (file_error) {
    case base::File::FILE_ERROR_NO_SPACE:
      upload_result = OfficeFilesUploadResult::kCloudQuotaFull;
      // TODO(b/242685536) Use "these files" for multi-files when support for
      // multi-files is added.
      error_message = base::UTF16ToUTF8(
          base::i18n::MessageFormatter::FormatWithNumberedArgs(
              l10n_util::GetStringUTF16(
                  copy ? IDS_OFFICE_UPLOAD_ERROR_FREE_UP_SPACE_TO_COPY
                       : IDS_OFFICE_UPLOAD_ERROR_FREE_UP_SPACE_TO_MOVE),
              // TODO(b/242685536) Update when support for multi-files is added.
              1,
              l10n_util::GetStringUTF16(
                  IDS_OFFICE_CLOUD_PROVIDER_GOOGLE_DRIVE_SHORT)));
      break;
    case base::File::FILE_ERROR_NOT_FOUND:
      if (copy) {
        upload_result = OfficeFilesUploadResult::kCopyOperationError;
      } else {
        upload_result = OfficeFilesUploadResult::kMoveOperationError;
      }
      error_message = l10n_util::GetStringUTF8(
          copy ? IDS_OFFICE_UPLOAD_ERROR_FILE_NOT_EXIST_TO_COPY
               : IDS_OFFICE_UPLOAD_ERROR_FILE_NOT_EXIST_TO_MOVE);
      break;
    default:
      if (copy) {
        upload_result = OfficeFilesUploadResult::kCopyOperationError;
      } else {
        upload_result = OfficeFilesUploadResult::kMoveOperationError;
      }
      LOG(ERROR) << "IO Task error";
      error_message = GetGenericErrorMessage();
  }

  OnEndCopy(upload_result, base::unexpected(error_message));
}

void DriveUploadHandler::OnUnmounted() {}

void DriveUploadHandler::ImmediatelyUploadDone(drive::FileError error) {
  LOG_IF(ERROR, error != drive::FileError::FILE_ERROR_OK)
      << "ImmediatelyUpload failed with status: " << error;
}

void DriveUploadHandler::OnSyncingStatusUpdate(
    const drivefs::mojom::SyncingStatus& syncing_status) {
  for (const auto& item : syncing_status.item_events) {
    if (base::FilePath(item->path) != observed_relative_drive_path_) {
      continue;
    }
    if (item->state == drivefs::mojom::ItemEvent::State::kCancelledAndDeleted) {
      continue;
    }
    switch (item->state) {
      case drivefs::mojom::ItemEvent::State::kQueued: {
        // Tell Drive to upload the file now. If successful, we will receive a
        // kInProgress or kCompleted event sooner. If this fails, we ignore it.
        // The file will get uploaded eventually.
        drive_integration_service_->ImmediatelyUpload(
            observed_relative_drive_path_,
            base::BindOnce(&DriveUploadHandler::ImmediatelyUploadDone,
                           weak_ptr_factory_.GetWeakPtr()));
        return;
      }
      case drivefs::mojom::ItemEvent::State::kInProgress:
        if (item->bytes_transferred > 0) {
          sync_progress_ =
              100 * item->bytes_transferred / item->bytes_to_transfer;
        }
        UpdateProgressNotification();
        return;
      case drivefs::mojom::ItemEvent::State::kCompleted:
        sync_progress_ = 100;
        UpdateProgressNotification();
        // The file has fully synced. Start the timer for the maximum amount of
        // time we allow before the file's alternate URL is available.
        alternate_url_timeout_.Start(
            FROM_HERE, base::Seconds(kAlternateUrlTimeout),
            base::BindOnce(&DriveUploadHandler::CheckAlternateUrl,
                           weak_ptr_factory_.GetWeakPtr(), /*timed_out=*/true));
        CheckAlternateUrl(/*timed_out=*/false);
        return;
      case drivefs::mojom::ItemEvent::State::kFailed:
        LOG(ERROR) << "Drive sync error: failed";
        OnEndCopy(OfficeFilesUploadResult::kSyncError);
        return;
      case drivefs::mojom::ItemEvent::State::kCancelledAndDeleted:
        NOTREACHED_IN_MIGRATION();
        return;
      case drivefs::mojom::ItemEvent::State::kCancelledAndTrashed:
        LOG(ERROR) << "Drive sync error: cancelled and trashed";
        OnEndCopy(OfficeFilesUploadResult::kSyncCancelledAndTrashed);
        return;
    }
  }
}

void DriveUploadHandler::OnError(const drivefs::mojom::DriveError& error) {
  if (base::FilePath(error.path) != observed_relative_drive_path_) {
    return;
  }
  bool copy = upload_type_ == UploadType::kCopy;
  switch (error.type) {
    case drivefs::mojom::DriveError::Type::kCantUploadStorageFull:
    case drivefs::mojom::DriveError::Type::kCantUploadStorageFullOrganization:
    case drivefs::mojom::DriveError::Type::kCantUploadSharedDriveStorageFull:
      OnEndCopy(
          OfficeFilesUploadResult::kCloudQuotaFull,
          base::unexpected(base::UTF16ToUTF8(
              base::i18n::MessageFormatter::FormatWithNumberedArgs(
                  l10n_util::GetStringUTF16(
                      copy ? IDS_OFFICE_UPLOAD_ERROR_FREE_UP_SPACE_TO_COPY
                           : IDS_OFFICE_UPLOAD_ERROR_FREE_UP_SPACE_TO_MOVE),
                  // TODO(b/242685536) Update when support for
                  // multi-files is added.
                  1,
                  l10n_util::GetStringUTF16(
                      IDS_OFFICE_CLOUD_PROVIDER_GOOGLE_DRIVE_SHORT)))));
      break;
    case drivefs::mojom::DriveError::Type::kPinningFailedDiskFull:
      LOG(ERROR) << "Pinning failed, disk full";
      OnEndCopy(OfficeFilesUploadResult::kPinningFailedDiskFull);
      break;
  }
}

void DriveUploadHandler::OnDriveConnectionStatusChanged(
    drive::util::ConnectionStatus status) {
  if (status != drive::util::ConnectionStatus::kConnected) {
    LOG(ERROR) << "Lost connection to Drive during upload";
    OnEndCopy(OfficeFilesUploadResult::kNoConnection);
  }
}

void DriveUploadHandler::OnGetDriveMetadata(
    bool timed_out,
    drive::FileError error,
    drivefs::mojom::FileMetadataPtr metadata) {
  if (error != drive::FILE_ERROR_OK) {
    if (timed_out) {
      LOG(ERROR) << "Drive Metadata error";
      OnEndCopy(OfficeFilesUploadResult::kCloudMetadataError);
    } else {
      alternate_url_poll_timer_.Start(
          FROM_HERE, base::Milliseconds(kAlternateUrlPollInterval),
          base::BindOnce(&DriveUploadHandler::CheckAlternateUrl,
                         weak_ptr_factory_.GetWeakPtr(), /*timed_out=*/false));
    }
    return;
  }
  GURL hosted_url(metadata->alternate_url);
  if (!hosted_url.is_valid()) {
    if (timed_out) {
      LOG(ERROR) << "Invalid alternate URL - Drive editing unavailable";
      OnEndCopy(OfficeFilesUploadResult::kInvalidAlternateUrl);
    } else {
      alternate_url_poll_timer_.Start(
          FROM_HERE, base::Milliseconds(kAlternateUrlPollInterval),
          base::BindOnce(&DriveUploadHandler::CheckAlternateUrl,
                         weak_ptr_factory_.GetWeakPtr(), /*timed_out=*/false));
    }
    return;
  }

  // URLs for editing Office files in Web Drive all have a "docs.google.com"
  // host.
  if (hosted_url.host() != "docs.google.com") {
    if (timed_out) {
      if (hosted_url.host() == "drive.google.com" &&
          !file_manager::file_tasks::IsOfficeFileMimeType(
              metadata->content_mime_type)) {
        // The drive.google.com will appear if an uploaded file has an Office
        // extension but is not actually an Office file. For example, the user
        // just renamed their .mp4 to a .doc.
        LOG(ERROR) << "Non-Office file cannot be opened with Google Docs";
        OnEndCopy(OfficeFilesUploadResult::kFileNotAnOfficeFile,
                  base::unexpected(GetNotAValidDocumentErrorMessage()));
      } else {
        LOG(ERROR) << "Unexpected alternate URL - Drive editing unavailable: "
                   << hosted_url.host();
        OnEndCopy(OfficeFilesUploadResult::kUnexpectedAlternateUrlHost);
      }
    } else {
      alternate_url_poll_timer_.Start(
          FROM_HERE, base::Milliseconds(kAlternateUrlPollInterval),
          base::BindOnce(&DriveUploadHandler::CheckAlternateUrl,
                         weak_ptr_factory_.GetWeakPtr(), /*timed_out=*/false));
    }
    return;
  }

  // Success.
  alternate_url_timeout_.Stop();
  alternate_url_poll_timer_.Stop();
  OnEndCopy(OfficeFilesUploadResult::kSuccess, hosted_url);
}

void DriveUploadHandler::CheckAlternateUrl(bool timed_out) {
  if (!drive_integration_service_) {
    LOG(ERROR) << "No Drive integration service";
    OnEndCopy(OfficeFilesUploadResult::kOtherError);
    return;
  }

  drive_integration_service_->GetDriveFsInterface()->GetMetadata(
      observed_relative_drive_path_,
      base::BindOnce(&DriveUploadHandler::OnGetDriveMetadata,
                     weak_ptr_factory_.GetWeakPtr(), /*timed_out=*/timed_out));
}

}  // namespace ash::cloud_upload