| // Copyright 2021 The Chromium Authors. All rights reserved. |
| // 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 <map> |
| #include <vector> |
| |
| #include "ash/components/drivefs/mojom/drivefs.mojom.h" |
| #include "ash/constants/ash_features.h" |
| #include "ash/projector/projector_metrics.h" |
| #include "ash/webui/projector_app/public/cpp/projector_app_constants.h" |
| #include "base/bind.h" |
| #include "base/callback_helpers.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/json/json_reader.h" |
| #include "base/json/json_writer.h" |
| #include "base/strings/strcat.h" |
| #include "base/task/bind_post_task.h" |
| #include "base/task/task_traits.h" |
| #include "base/task/thread_pool.h" |
| #include "chrome/browser/ash/drive/drive_integration_service.h" |
| #include "content/public/browser/browser_task_traits.h" |
| #include "content/public/browser/browser_thread.h" |
| #include "net/base/url_util.h" |
| #include "third_party/abseil-cpp/absl/types/optional.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 path.MatchesExtension(ash::kProjectorMediaFileExtension) || |
| path.MatchesExtension(ash::kProjectorMetadataFileExtension); |
| } |
| |
| // "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]. |
| absl::optional<std::string> GetIdFromDriveUrl(const GURL& url) { |
| const std::string& spec = url.spec(); |
| if (!base::StartsWith(spec, kOpenUrlBase, |
| base::CompareCase::INSENSITIVE_ASCII)) { |
| return absl::nullopt; |
| } |
| std::string id; |
| if (!net::GetValueForKeyInQuery(url, "id", &id)) |
| return absl::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`. |
| absl::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; |
| |
| absl::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->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::DictionaryValue root; |
| base::Value::Dict contentHints; |
| contentHints.Set(kDriveRequestIndexableTextKey, indexable_text); |
| root.SetKey(kDriveRequestContentHintsKey, |
| base::Value(std::move(contentHints))); |
| |
| std::string request_body; |
| base::JSONWriter::Write(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. |
| absl::optional<ash::PendingScreencast> GetPendingScreencast( |
| 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 absl::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 (path.MatchesExtension(ash::kProjectorMetadataFileExtension)) { |
| total_size_in_bytes += files.GetInfo().GetSize(); |
| metadata_file_count++; |
| } else if (path.MatchesExtension(ash::kProjectorMediaFileExtension)) { |
| 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 absl::nullopt; |
| } |
| |
| // Return null if the screencast is not valid. |
| if (media_file_count != 1 || metadata_file_count != 1) |
| return absl::nullopt; |
| |
| ash::PendingScreencast pending_screencast{container_dir}; |
| pending_screencast.created_time = created_time; |
| pending_screencast.name = media_name; |
| pending_screencast.total_size_in_bytes = total_size_in_bytes; |
| pending_screencast.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::PendingScreencastSet 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::PendingScreencastSet 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::PendingScreencast> 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 = GetPendingScreencast( |
| 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()) { |
| // 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 (!iter->second.upload_failed) |
| iter->second.bytes_transferred += pending_event.bytes_transferred; |
| |
| // Skips getting the size of a folder if it has been validated before. |
| continue; |
| } |
| |
| auto new_screencast = GetPendingScreencast( |
| container_dir, drivefs_mounted_point, /*upload_failed=*/false); |
| |
| if (new_screencast) { |
| new_screencast->bytes_transferred = 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() && |
| event_file.MatchesExtension(ash::kProjectorMetadataFileExtension)) { |
| syncing_metadata_files_.emplace(event_file); |
| } |
| pending_webm_or_projector_events.push_back( |
| drivefs::mojom::ItemEvent(*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::PendingScreencastSet& |
| PendingScreencastManager::GetPendingScreencasts() const { |
| return pending_screencast_cache_; |
| } |
| |
| bool PendingScreencastManager::IsDriveFsObservationObservingSource( |
| drivefs::DriveFsHost* source) const { |
| return drivefs_observation_.IsObservingSource(source); |
| } |
| |
| 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() { |
| auto* drivefs_integration = |
| ProjectorDriveFsProvider::GetActiveDriveIntegrationService(); |
| if (!drivefs_integration) |
| return; |
| |
| auto* drivefs_host = drivefs_integration->GetDriveFsHost(); |
| if (!drivefs_host || drivefs_observation_.IsObservingSource(drivefs_host)) |
| return; |
| |
| pending_screencast_cache_.clear(); |
| error_syncing_files_.clear(); |
| |
| // Reset if observing DriveFsHost of other profile. |
| if (drivefs_observation_.IsObserving()) |
| drivefs_observation_.Reset(); |
| drivefs_observation_.Observe(drivefs_host); |
| } |
| |
| void PendingScreencastManager::OnProcessAndGenerateNewScreencastsFinished( |
| const base::TimeTicks task_start_tick, |
| const ash::PendingScreencastSet& 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) { |
| // 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()); |
| } |
| |
| xhr_sender_->Send( |
| GURL(base::StrCat({ash::kDriveV3BaseUrl, file_id})), |
| ash::kRequestMethodPatch, request_body, |
| /*use_credentials=*/false, |
| base::BindOnce([](bool success, const std::string& response_body, |
| const std::string& error) { |
| if (!success) { |
| LOG(ERROR) << "Failed to send Drive patch request for file." |
| << " Error: " << error; |
| } |
| })); |
| } |