chromium/chrome/browser/ui/ash/projector/pending_screencast_manager.cc

// Copyright 2021 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/projector/pending_screencast_manager.h"

#include <memory>
#include <optional>
#include <vector>

#include "ash/constants/ash_features.h"
#include "ash/projector/projector_metrics.h"
#include "ash/webui/projector_app/public/mojom/projector_types.mojom-forward.h"
#include "base/check.h"
#include "base/files/file_enumerator.h"
#include "base/files/file_path.h"
#include "base/files/file_util.h"
#include "base/functional/bind.h"
#include "base/json/json_reader.h"
#include "base/json/json_writer.h"
#include "base/strings/strcat.h"
#include "base/task/task_traits.h"
#include "base/task/thread_pool.h"
#include "chrome/browser/ash/drive/drive_integration_service.h"
#include "chrome/browser/profiles/profile.h"
#include "chrome/browser/profiles/profile_manager.h"
#include "chrome/browser/ui/ash/projector/projector_utils.h"
#include "chromeos/ash/components/drivefs/mojom/drivefs.mojom.h"
#include "content/public/browser/browser_task_traits.h"
#include "content/public/browser/browser_thread.h"
#include "net/base/url_util.h"

namespace {

constexpr char kOpenUrlBase[] = "https://drive.google.com/open";
constexpr char kDriveRequestContentHintsKey[] = "contentHints";
constexpr char kDriveRequestIndexableTextKey[] = "indexableText";

// The metadata might not be ready as the file gets uploaded. On projector app
// side, we fetch newly uploaded screencasts with 2s delay, and it works fine,
// so put 3s here to allow Drive to populate the metadata.
constexpr base::TimeDelta kDriveGetMetadataDelay = base::Seconds(3);

bool IsWebmOrProjectorFile(const base::FilePath& path) {
  return IsMediaFile(path) || IsMetadataFile(path);
}

// "Absolute path" is the DriveFS absolute path of `drive_relative_path` on
// local file system, for example: absolute_path =
// "/{$drivefs_mounted_point}/root/{$drive_relative_path}";
base::FilePath GetLocalAbsolutePath(const base::FilePath& drivefs_mounted_point,
                                    const base::FilePath& drive_relative_path) {
  base::FilePath root("/");
  base::FilePath absolute_path(drivefs_mounted_point);
  root.AppendRelativePath(drive_relative_path, &absolute_path);
  return absolute_path;
}

// Returns the Drive server side id from |url| e.g.
// https://drive.google.com/open?id=[ID].
std::optional<std::string> GetIdFromDriveUrl(const GURL& url) {
  const std::string& spec = url.spec();
  if (!base::StartsWith(spec, kOpenUrlBase,
                        base::CompareCase::INSENSITIVE_ASCII)) {
    return std::nullopt;
  }
  std::string id;
  if (!net::GetValueForKeyInQuery(url, "id", &id)) {
    return std::nullopt;
  }
  return id;
}

// Retrieves the file id from `metadata` and runs the `get_file_id_callback`
// callback.
void ParseFileIdOnGetMetaData(
    PendingScreencastManager::OnGetFileIdCallback get_file_id_callback,
    const base::FilePath& local_file_path,
    drive::FileError error,
    drivefs::mojom::FileMetadataPtr metadata) {
  std::string file_id;
  // TODO(b/232282526): Add metric to track how often we get metadata failed.
  if (error != drive::FileError::FILE_ERROR_OK || !metadata) {
    LOG(ERROR) << "Get Drive File metadata failed";
  } else if (metadata->alternate_url.empty()) {
    LOG(ERROR) << "No alternate_url found in file metadata";
  } else {
    // TODO(b/221078840): Use the file id directly when it is available in
    // `metadata`.
    std::optional<std::string> parsed_file_id =
        GetIdFromDriveUrl(GURL(metadata->alternate_url));
    if (parsed_file_id.has_value()) {
      file_id = parsed_file_id.value();
    } else {
      LOG(ERROR) << "Could not get file id from alternate url";
    }
  }

  std::move(get_file_id_callback).Run(local_file_path, file_id);
}

// Gets the absolute path for `drive_relative_path` and gets Drive metadata for
// the given file path. To execute the `callback`, we need to know the server
// side file id, which could be learned from metadata.
void GetDriveFileMetadata(
    const base::FilePath& drive_relative_path,
    PendingScreencastManager::OnGetFileIdCallback callback) {
  DCHECK(content::BrowserThread::CurrentlyOn(content::BrowserThread::UI));
  auto* drive_integration_service =
      ProjectorDriveFsProvider::GetActiveDriveIntegrationService();
  if (!drive_integration_service) {
    return;
  }
  const base::FilePath local_path = GetLocalAbsolutePath(
      drive_integration_service->GetMountPointPath(), drive_relative_path);

  // drive::DriveIntegrationService::GetMetadata should only be called on UI
  // thread.
  drive_integration_service->GetMetadata(
      local_path, base::BindOnce(&ParseFileIdOnGetMetaData, std::move(callback),
                                 local_path));
}

// Reads the screencast metadata file from `metadata_file_local_path`. A sample
// file content:
// {
//   "captionLanguage":"en",
//   "captions":[
//     {
//      "endOffset":1260,
//      "hypothesisParts:[],
//      "startOffset":760,
//      "text":"abcd",
//     }
//   ],
//   "tableOfContent":[]
// }
// Returns the indexable text concated by all "text" fields content.
std::string GetIndexableText(const base::FilePath& metadata_file_local_path) {
  DCHECK(!content::BrowserThread::CurrentlyOn(content::BrowserThread::UI));
  std::string indexable_text = "";

  // Reads the Json content in `metadata_file_local_path` to `dict_value`:
  std::string file_content;
  if (!base::ReadFileToString(metadata_file_local_path, &file_content)) {
    return indexable_text;
  }

  std::optional<base::Value> value(base::JSONReader::Read(file_content));
  if (!value) {
    return indexable_text;
  }

  const base::Value::Dict* dict_value = value.value().GetIfDict();
  if (!dict_value) {
    return indexable_text;
  }

  // Concats all captions' text:
  const auto* captions = dict_value->FindList("captions");
  if (!captions) {
    return indexable_text;
  }

  for (const auto& caption : *captions) {
    const base::Value::Dict* caption_dict = caption.GetIfDict();
    if (!caption_dict) {
      continue;
    }
    const std::string* text = caption_dict->FindString("text");
    if (text && !text->empty()) {
      base::StrAppend(&indexable_text, {" ", *text});
    }
  }
  return indexable_text;
}

// Returns the request body, which looks like:
// {
//   "contentHints":
//     {
//      "indexableText":"abcd",
//     }
// }
const std::string BuildRequestBody(
    const base::FilePath& metadata_file_local_path) {
  DCHECK(!content::BrowserThread::CurrentlyOn(content::BrowserThread::UI));
  const std::string indexable_text = GetIndexableText(metadata_file_local_path);
  if (indexable_text.empty()) {
    return std::string();
  }

  // Builds request body:
  base::Value::Dict root;
  base::Value::Dict contentHints;
  contentHints.Set(kDriveRequestIndexableTextKey, indexable_text);
  root.Set(kDriveRequestContentHintsKey, std::move(contentHints));

  std::string request_body;
  base::JSONWriter::Write(std::move(root), &request_body);

  return request_body;
}

// Returns a valid pending screencast from `container_absolute_path`.  A valid
// screencast should have 1 media file and 1 metadata file.
std::optional<ash::PendingScreencastContainer> GetPendingScreencastContainer(
    const base::FilePath& container_dir,
    const base::FilePath& drivefs_mounted_point,
    bool upload_failed) {
  DCHECK(!content::BrowserThread::CurrentlyOn(content::BrowserThread::UI));
  const base::FilePath container_absolute_path =
      GetLocalAbsolutePath(drivefs_mounted_point, container_dir);
  if (!base::PathExists(container_absolute_path)) {
    return std::nullopt;
  }

  int64_t total_size_in_bytes = 0;
  int media_file_count = 0;
  int metadata_file_count = 0;

  base::Time created_time;
  std::string media_name;

  base::FileEnumerator files(container_absolute_path, /*recursive=*/false,
                             base::FileEnumerator::FILES);

  // Calculates the size of media file and metadata file, and the created time
  // of media.
  for (base::FilePath path = files.Next(); !path.empty(); path = files.Next()) {
    if (IsMetadataFile(path)) {
      total_size_in_bytes += files.GetInfo().GetSize();
      metadata_file_count++;
    } else if (IsMediaFile(path)) {
      base::File::Info info;
      if (!base::GetFileInfo(path, &info)) {
        continue;
      }
      created_time = info.creation_time;
      total_size_in_bytes += files.GetInfo().GetSize();
      media_name = path.BaseName().RemoveExtension().value();
      media_file_count++;
    }

    // Return null if the screencast is not valid.
    if (media_file_count > 1 || metadata_file_count > 1) {
      return std::nullopt;
    }
  }

  // Return null if the screencast is not valid.
  if (media_file_count != 1 || metadata_file_count != 1) {
    return std::nullopt;
  }

  ash::PendingScreencastContainer pending_screencast{container_dir};
  pending_screencast.SetTotalSizeInBytes(total_size_in_bytes);
  pending_screencast.SetName(media_name);
  pending_screencast.SetCreatedTime(created_time);
  pending_screencast.set_upload_failed(upload_failed);

  return pending_screencast;
}

// The `pending_webm_or_projector_events` are new uploading ".webm" or
// ".projector" files' events. The `error_syncing_file` are ".webm" or
// ".projector" files which failed to upload. Checks whether these files are
// valid screencast files. Calculates the upload progress or error state and
// returns valid pending or error screencasts.
ash::PendingScreencastContainerSet ProcessAndGenerateNewScreencasts(
    const std::vector<drivefs::mojom::ItemEvent>&
        pending_webm_or_projector_events,
    const std::set<base::FilePath>& error_syncing_file,
    const base::FilePath drivefs_mounted_point) {
  DCHECK(!content::BrowserThread::CurrentlyOn(content::BrowserThread::UI));
  // The valid screencasts set.
  ash::PendingScreencastContainerSet screencasts;

  if (!base::PathExists(drivefs_mounted_point) ||
      (pending_webm_or_projector_events.empty() &&
       error_syncing_file.empty())) {
    return screencasts;
  }

  // A map of container directory path to pending screencast. Each screencast
  // has a unique container directory path in DriveFS.
  std::map<base::FilePath, ash::PendingScreencastContainer>
      container_to_screencasts;

  // Creates error screencasts from `error_syncing_file`:
  for (const auto& upload_failed_file : error_syncing_file) {
    const base::FilePath container_dir = upload_failed_file.DirName();
    auto new_screencast = GetPendingScreencastContainer(
        container_dir, drivefs_mounted_point, /*upload_failed=*/true);
    if (new_screencast) {
      container_to_screencasts[container_dir] = new_screencast.value();
    }
  }

  // Creates uploading screencasts from `pending_webm_or_projector_events`:

  // The `pending_event.path` is the file path in drive. It looks like
  // "/root/{folder path in drive}/{file name}".
  for (const auto& pending_event : pending_webm_or_projector_events) {
    base::FilePath event_file = base::FilePath(pending_event.path);
    // `container_dir` is the parent folder of `pending_event.path` in drive. It
    // looks like "/root/{folder path in drive}".
    const base::FilePath container_dir = event_file.DirName();

    // During this loop, items of multiple events might be under the same
    // folder.
    auto iter = container_to_screencasts.find(container_dir);
    if (iter != container_to_screencasts.end()) {
      ash::PendingScreencastContainer& entry = iter->second;
      // Calculates remaining untranferred bytes of a screencast by adding up
      // its transferred bytes of its files. `pending_event.bytes_to_transfer`
      // is the total bytes of current file.
      // TODO(b/209854146) Not all files appear in
      // `pending_webm_or_projector_events.bytes_transferred`. The missing files
      // might be uploaded or not uploaded. To get an accurate
      // `bytes_transferred`, use DriveIntegrationService::GetMetadata().
      if (!entry.pending_screencast().upload_failed) {
        entry.SetTotalBytesTransferred(entry.bytes_transferred() +
                                       pending_event.bytes_transferred);
      }

      // Skips getting the size of a folder if it has been validated before.
      continue;
    }

    auto new_screencast = GetPendingScreencastContainer(
        container_dir, drivefs_mounted_point, /*upload_failed=*/false);

    if (new_screencast) {
      new_screencast->SetTotalBytesTransferred(pending_event.bytes_transferred);
      container_to_screencasts[container_dir] = new_screencast.value();
    }
  }

  for (const auto& pair : container_to_screencasts) {
    screencasts.insert(pair.second);
  }

  return screencasts;
}

}  // namespace

// Using base::Unretained for callback is safe since the
// PendingScreencastManager owns the `drive_helper_`.
PendingScreencastManager::PendingScreencastManager(
    PendingScreencastChangeCallback pending_screencast_change_callback)
    : pending_screencast_change_callback_(pending_screencast_change_callback),
      blocking_task_runner_(base::ThreadPool::CreateSequencedTaskRunner(
          {base::MayBlock(), base::TaskPriority::BEST_EFFORT,
           base::TaskShutdownBehavior::SKIP_ON_SHUTDOWN})),
      drive_helper_(base::BindRepeating(
          &PendingScreencastManager::MaybeSwitchDriveFsObservation,
          base::Unretained(this))) {}

PendingScreencastManager::~PendingScreencastManager() = default;

void PendingScreencastManager::OnUnmounted() {
  if (!pending_screencast_cache_.empty()) {
    pending_screencast_cache_.clear();
    // Since DriveFS is unmounted, screencasts stop uploading. Notifies pending
    // screencast status has changed.
    pending_screencast_change_callback_.Run(pending_screencast_cache_);
    last_pending_screencast_change_tick_ = base::TimeTicks();
  }
  error_syncing_files_.clear();
}

// Generates new pending upload screencasts list base on `error_syncing_files_`
// and files from drivefs::mojom::SyncingStatus.
//
// When file in error_syncing_files_ complete uploading, remove from
// `error_syncing_files_` so failed screencasts will be removed from pending
// screencast list.
// TODO(b/200343894): OnSyncingStatusUpdate() gets called for both upload and
// download event. Find a way to filter out the upload event.
void PendingScreencastManager::OnSyncingStatusUpdate(
    const drivefs::mojom::SyncingStatus& status) {
  if (!ProjectorDriveFsProvider::IsDriveFsMounted()) {
    return;
  }
  std::vector<drivefs::mojom::ItemEvent> pending_webm_or_projector_events;
  for (const auto& event : status.item_events) {
    const base::FilePath event_file = base::FilePath(event->path);

    if (event->state == drivefs::mojom::ItemEvent::State::kCompleted) {
      OnFileSyncedCompletely(event_file);
    }

    bool pending =
        event->state == drivefs::mojom::ItemEvent::State::kQueued ||
        event->state == drivefs::mojom::ItemEvent::State::kInProgress;
    // Filters pending ".webm" or ".projector".
    if (!pending || !IsWebmOrProjectorFile(event_file)) {
      continue;
    }

    // We might have received the same event with "kCompleted" state multiple
    // times. The `syncing_metadata_files_` is used to watch the first
    // "kCompleted" state for a file so that we could only update indexable text
    // once.
    if (ash::features::IsProjectorUpdateIndexableTextEnabled() &&
        IsMetadataFile(event_file)) {
      syncing_metadata_files_.emplace(event_file);
    }
    pending_webm_or_projector_events.emplace_back(*event.get());
  }

  // If the `pending_webm_or_projector_events`, `error_syncing_files_` and
  // `pending_screencast_cache_` are empty, return early because the syncing may
  // be triggered by files that are not related to Projector.
  if (pending_webm_or_projector_events.empty() &&
      error_syncing_files_.empty() && pending_screencast_cache_.empty()) {
    return;
  }

  // The `task` is a blocking I/O operation while `reply` runs on current
  // thread.
  // TODO(b/223668878) OnSyncingStatusUpdate might get called multiple times
  // within 1s. Add a repeat timer to trigger this task for less frequency.
  blocking_task_runner_->PostTaskAndReplyWithResult(
      FROM_HERE,
      base::BindOnce(ProcessAndGenerateNewScreencasts,
                     std::move(pending_webm_or_projector_events),
                     error_syncing_files_,
                     ProjectorDriveFsProvider::GetDriveFsMountPointPath()),
      base::BindOnce(
          &PendingScreencastManager::OnProcessAndGenerateNewScreencastsFinished,
          weak_ptr_factory_.GetWeakPtr(),
          /*task_start_tick=*/base::TimeTicks::Now()));
}

// Observes the Drive OnError event and add the related files to
// `error_syncing_files_`. The validation of a screencast happens in
// OnSyncingStatusUpdate because the drivefs::mojom::SyncingStatus contains the
// info about the file completed uploaded or not and other files status for the
// same screencast.
void PendingScreencastManager::OnError(
    const drivefs::mojom::DriveError& error) {
  base::FilePath error_file = base::FilePath(error.path);
  // mojom::DriveError::Type has 2 types: kCantUploadStorageFull and
  // kPinningFailedDiskFull. Only handle kCantUploadStorageFull so far.
  if (error.type != drivefs::mojom::DriveError::Type::kCantUploadStorageFull ||
      !IsWebmOrProjectorFile(error_file)) {
    return;
  }
  error_syncing_files_.insert(error_file);
}

const ash::PendingScreencastContainerSet&
PendingScreencastManager::GetPendingScreencasts() const {
  return pending_screencast_cache_;
}

void PendingScreencastManager::SetOnGetFileIdCallbackForTest(
    OnGetFileIdCallback callback) {
  on_get_file_id_callback_ = std::move(callback);
}

void PendingScreencastManager::SetOnGetRequestBodyCallbackForTest(
    OnGetRequestBodyCallback callback) {
  on_get_request_body_ = std::move(callback);
}

void PendingScreencastManager::SetProjectorXhrSenderForTest(
    std::unique_ptr<ash::ProjectorXhrSender> xhr_sender) {
  xhr_sender_ = std::move(xhr_sender);
}

void PendingScreencastManager::MaybeSwitchDriveFsObservation() {
  drive::DriveIntegrationService* const service =
      ProjectorDriveFsProvider::GetActiveDriveIntegrationService();
  if (!service) {
    return;
  }

  drivefs::DriveFsHost* const host = service->GetDriveFsHost();
  if (!host || GetHost() == host) {
    return;
  }

  pending_screencast_cache_.clear();
  error_syncing_files_.clear();

  Observe(host);
}

void PendingScreencastManager::ToggleFileSyncingNotificationForPaths(
    const std::vector<base::FilePath>& paths,
    bool suppress) {
  auto* drivefs_integration =
      ProjectorDriveFsProvider::GetActiveDriveIntegrationService();
  if (!drivefs_integration) {
    return;
  }

  Profile* profile = ProfileManager::GetActiveUserProfile();
  for (const auto& path : paths) {
    base::FilePath drive_path;
    drivefs_integration->GetRelativeDrivePath(path, &drive_path);
    if (suppress) {
      paths_notifications_suppressors_[drive_path] = std::make_unique<
          file_manager::ScopedSuppressDriveNotificationsForPath>(profile,
                                                                 drive_path);
    } else {
      paths_notifications_suppressors_.erase(drive_path);
    }
  }
}

void PendingScreencastManager::OnAppActiveStatusChanged(bool is_active) {
  Profile* profile = ProfileManager::GetActiveUserProfile();
  for (auto& [path, suppressor] : paths_notifications_suppressors_) {
    if (is_active) {
      if (!suppressor) {
        // Suppresses notification on app active.
        suppressor = std::make_unique<
            file_manager::ScopedSuppressDriveNotificationsForPath>(profile,
                                                                   path);
      }
    } else {
      // Resumes notification on app inactive.
      suppressor.reset();
    }
  }
}

void PendingScreencastManager::OnProcessAndGenerateNewScreencastsFinished(
    const base::TimeTicks task_start_tick,
    const ash::PendingScreencastContainerSet& screencasts) {
  const base::TimeTicks now = base::TimeTicks::Now();
  ash::RecordPendingScreencastBatchIOTaskDuration(now - task_start_tick);

  // Returns if pending screencasts didn't change.
  if (screencasts == pending_screencast_cache_) {
    return;
  }
  pending_screencast_cache_ = screencasts;

  // Notifies pending screencast status changed.
  pending_screencast_change_callback_.Run(pending_screencast_cache_);
  if (!last_pending_screencast_change_tick_.is_null()) {
    ash::RecordPendingScreencastChangeInterval(
        now - last_pending_screencast_change_tick_);
  }
  // Resets `last_pending_screencast_change_tick_` to null. We don't track time
  // delta between finish uploading and new uploading started.
  last_pending_screencast_change_tick_ =
      pending_screencast_cache_.empty() ? base::TimeTicks() : now;
}

void PendingScreencastManager::OnFileSyncedCompletely(
    const base::FilePath& event_file) {
  // Clean up the system notification suppression for `event_file`.
  paths_notifications_suppressors_.erase(event_file);

  // If observes a error uploaded file is now successfully uploaded, removes
  // it from `error_syncing_files_`:
  error_syncing_files_.erase(event_file);
  if (ash::features::IsProjectorUpdateIndexableTextEnabled()) {
    // If observes a ".projector" file is now successfully uploaded, updates
    // the indexable text and remove it from `syncing_metadata_files_`.
    const auto iter = syncing_metadata_files_.find(event_file);
    if (iter != syncing_metadata_files_.end()) {
      auto on_get_file_id_callback =
          on_get_file_id_callback_
              ? std::move(on_get_file_id_callback_)
              : base::BindOnce(&PendingScreencastManager::OnGetFileId,
                               weak_ptr_factory_.GetWeakPtr());

      // Posts a delayed task to get Drive metadata because the metadata might
      // not be polulated as the file get uploaded. This task has a long chain
      // of callbacks. The calling order is: GetDriveFileMetadata() ->
      // ParseFileIdOnGetMetaData() -> on_get_file_id_callback.
      content::GetUIThreadTaskRunner({})->PostDelayedTask(
          FROM_HERE,
          base::BindOnce(&GetDriveFileMetadata, event_file,
                         std::move(on_get_file_id_callback)),
          kDriveGetMetadataDelay);
      syncing_metadata_files_.erase(iter);
    }
  }
}

void PendingScreencastManager::OnGetFileId(
    const base::FilePath& local_file_path,
    const std::string& file_id) {
  if (file_id.empty()) {
    return;
  }

  base::ThreadPool::PostTaskAndReplyWithResult(
      FROM_HERE, {base::MayBlock()},
      base::BindOnce(&BuildRequestBody, local_file_path),
      on_get_request_body_
          ? base::BindOnce(std::move(on_get_request_body_), file_id)
          : base::BindOnce(&PendingScreencastManager::SendDrivePatchRequest,
                           weak_ptr_factory_.GetWeakPtr(), file_id));
}

void PendingScreencastManager::SendDrivePatchRequest(
    const std::string& file_id,
    const std::string& request_body) {
  DCHECK(!file_id.empty());
  if (request_body.empty()) {
    return;
  }

  if (!xhr_sender_) {
    xhr_sender_ = std::make_unique<ash::ProjectorXhrSender>(
        ash::ProjectorAppClient::Get()->GetUrlLoaderFactory());
  }

  // TODO(b/288457397): Pass the primary account email after email become
  // required to send request with OAuth token.
  xhr_sender_->Send(
      GURL(base::StrCat({ash::kDriveV3BaseUrl, file_id})),
      ash::projector::mojom::RequestType::kPatch, request_body,
      /*use_credentials=*/false,
      /*use_api_key=*/false,
      base::BindOnce([](ash::projector::mojom::XhrResponsePtr xhr_response) {
        if (xhr_response->response_code !=
            ash::projector::mojom::XhrResponseCode::kSuccess) {
          LOG(ERROR) << "Failed to send Drive patch request for file."
                     << " Error: " << xhr_response->response_code;
        }
      }));
}