chromium/chrome/browser/ui/ash/holding_space/holding_space_keyed_service.cc

// Copyright 2020 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/holding_space/holding_space_keyed_service.h"

#include <set>

#include "ash/constants/ash_features.h"
#include "ash/public/cpp/holding_space/holding_space_controller.h"
#include "ash/public/cpp/holding_space/holding_space_file.h"
#include "ash/public/cpp/holding_space/holding_space_item.h"
#include "ash/public/cpp/holding_space/holding_space_metrics.h"
#include "ash/public/cpp/holding_space/holding_space_prefs.h"
#include "base/containers/adapters.h"
#include "base/files/file_path.h"
#include "base/functional/callback_helpers.h"
#include "base/ranges/algorithm.h"
#include "base/strings/string_util.h"
#include "chrome/browser/ash/drive/drive_integration_service.h"
#include "chrome/browser/browser_process.h"
#include "chrome/browser/profiles/profile.h"
#include "chrome/browser/ui/ash/holding_space/holding_space_downloads_delegate.h"
#include "chrome/browser/ui/ash/holding_space/holding_space_file_system_delegate.h"
#include "chrome/browser/ui/ash/holding_space/holding_space_keyed_service_delegate.h"
#include "chrome/browser/ui/ash/holding_space/holding_space_metrics_delegate.h"
#include "chrome/browser/ui/ash/holding_space/holding_space_persistence_delegate.h"
#include "chrome/browser/ui/ash/holding_space/holding_space_suggestions_delegate.h"
#include "chrome/browser/ui/ash/holding_space/holding_space_util.h"
#include "components/account_id/account_id.h"
#include "storage/browser/file_system/file_system_url.h"
#include "storage/common/file_system/file_system_types.h"

namespace ash {

namespace {

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

// TODO(crbug.com/40150129): Track alternative type in `HoldingSpaceItem`.
// Returns a holding space item other than the one provided which is backed by
// the same file path in the specified `model`.
std::optional<const HoldingSpaceItem*> GetAlternativeHoldingSpaceItem(
    const HoldingSpaceModel& model,
    const HoldingSpaceItem* item) {
  for (const auto& candidate_item : model.items()) {
    if (candidate_item.get() == item)
      continue;
    if (candidate_item->file().file_path == item->file().file_path) {
      return candidate_item.get();
    }
  }
  return std::nullopt;
}

// Returns the singleton profile manager for the browser process.
ProfileManager* GetProfileManager() {
  return g_browser_process->profile_manager();
}

// Records the time from the first availability of the holding space feature
// to the time of the first item being added into holding space.
void RecordTimeFromFirstAvailabilityToFirstAdd(Profile* profile) {
  base::Time time_of_first_availability =
      holding_space_prefs::GetTimeOfFirstAvailability(profile->GetPrefs())
          .value();
  base::Time time_of_first_add =
      holding_space_prefs::GetTimeOfFirstAdd(profile->GetPrefs()).value();
  holding_space_metrics::RecordTimeFromFirstAvailabilityToFirstAdd(
      time_of_first_add - time_of_first_availability);
}

// Records the time from the first entry to the first pin into holding space.
// Note that this time may be zero if the user pinned their first file before
// having ever entered holding space.
void RecordTimeFromFirstEntryToFirstPin(Profile* profile) {
  base::Time time_of_first_pin =
      holding_space_prefs::GetTimeOfFirstPin(profile->GetPrefs()).value();
  base::Time time_of_first_entry =
      holding_space_prefs::GetTimeOfFirstEntry(profile->GetPrefs())
          .value_or(time_of_first_pin);
  holding_space_metrics::RecordTimeFromFirstEntryToFirstPin(
      time_of_first_pin - time_of_first_entry);
}

}  // namespace

// HoldingSpaceKeyedService ----------------------------------------------------

HoldingSpaceKeyedService::HoldingSpaceKeyedService(Profile* profile,
                                                   const AccountId& account_id)
    : profile_(profile),
      account_id_(account_id),
      holding_space_client_(profile),
      thumbnail_loader_(profile) {
  // Mark when the holding space feature first became available. If this is not
  // the first time that holding space became available, this will no-op.
  holding_space_prefs::MarkTimeOfFirstAvailability(profile_->GetPrefs());

  ProfileManager* const profile_manager = GetProfileManager();
  if (!profile_manager)  // May be `nullptr` in tests.
    return;

  // The associated profile may not be ready yet. If it is, we can immediately
  // proceed with profile dependent initialization.
  if (profile_manager->IsValidProfile(profile)) {
    OnProfileReady();
    return;
  }

  // Otherwise we need to wait for the profile to be added.
  profile_manager_observer_.Observe(profile_manager);
}

HoldingSpaceKeyedService::~HoldingSpaceKeyedService() {
  if (chromeos::PowerManagerClient::Get())
    chromeos::PowerManagerClient::Get()->RemoveObserver(this);

  if (HoldingSpaceController::Get()) {  // May be `nullptr` in tests.
    HoldingSpaceController::Get()->RegisterClientAndModelForUser(
        account_id_, /*client=*/nullptr, /*model=*/nullptr);
  }
}

// static
void HoldingSpaceKeyedService::RegisterProfilePrefs(
    user_prefs::PrefRegistrySyncable* registry) {
  // TODO(crbug.com/40150129): Move to `ash::holding_space_prefs`.
  HoldingSpacePersistenceDelegate::RegisterProfilePrefs(registry);
}

void HoldingSpaceKeyedService::BindReceiver(
    mojo::PendingReceiver<crosapi::mojom::HoldingSpaceService> receiver) {
  receivers_.Add(this, std::move(receiver));
}

void HoldingSpaceKeyedService::AddPrintedPdf(
    const base::FilePath& printed_pdf_path,
    bool from_incognito_profile) {
  AddItemOfType(HoldingSpaceItem::Type::kPrintedPdf, printed_pdf_path);
}

void HoldingSpaceKeyedService::AddPinnedFiles(
    const std::vector<storage::FileSystemURL>& file_system_urls,
    holding_space_metrics::EventSource event_source) {
  if (!IsInitialized()) {
    return;
  }

  std::vector<std::unique_ptr<HoldingSpaceItem>> items;
  std::vector<const HoldingSpaceItem*> items_to_record;
  for (const storage::FileSystemURL& file_system_url : file_system_urls) {
    if (ContainsPinnedFile(file_system_url))
      continue;

    items.push_back(HoldingSpaceItem::CreateFileBackedItem(
        HoldingSpaceItem::Type::kPinnedFile,
        HoldingSpaceFile(file_system_url.path(),
                         holding_space_util::ResolveFileSystemType(
                             profile_, file_system_url.ToGURL()),
                         file_system_url.ToGURL()),
        base::BindOnce(&holding_space_util::ResolveImage, &thumbnail_loader_)));

    // When pinning an item which already exists in holding space, the pin
    // action should be recorded on the alternative item backed by the same file
    // path if such an item exists. Otherwise the only type of holding space
    // item pinned will be thought to be `kPinnedFile`.
    items_to_record.push_back(
        GetAlternativeHoldingSpaceItem(holding_space_model_, items.back().get())
            .value_or(items.back().get()));

    if (file_system_url.type() == storage::kFileSystemTypeDriveFs)
      MakeDriveItemAvailableOffline(file_system_url);
  }

  DCHECK_EQ(items.size(), items_to_record.size());
  if (items.empty())
    return;

  // Mark when the first pin to holding space occurred. If this is not the first
  // pin to holding space, this will no-op. If this is the first pin, record the
  // amount of time from first entry to first pin into holding space.
  if (holding_space_prefs::MarkTimeOfFirstPin(profile_->GetPrefs()))
    RecordTimeFromFirstEntryToFirstPin(profile_);

  holding_space_metrics::RecordItemAction(
      items_to_record, holding_space_metrics::ItemAction::kPin, event_source);

  AddItems(std::move(items), /*allow_duplicates=*/false);
}

void HoldingSpaceKeyedService::RemovePinnedFiles(
    const std::vector<storage::FileSystemURL>& file_system_urls,
    holding_space_metrics::EventSource event_source) {
  if (!IsInitialized()) {
    return;
  }

  std::set<std::string> items;
  std::vector<const HoldingSpaceItem*> items_to_record;
  for (const storage::FileSystemURL& file_system_url : file_system_urls) {
    const HoldingSpaceItem* item = holding_space_model_.GetItem(
        HoldingSpaceItem::Type::kPinnedFile, file_system_url.path());
    if (!item)
      continue;

    items.emplace(item->id());

    // When removing a pinned item, the unpin action should be recorded on the
    // alternative item backed by the same file path if such an item exists.
    // This will give more insight as to what types of items are being unpinned
    // than would otherwise be known if only `kPinnedFile` was recorded.
    items_to_record.push_back(
        GetAlternativeHoldingSpaceItem(holding_space_model_, item)
            .value_or(item));
  }

  DCHECK_EQ(items.size(), items_to_record.size());
  if (items.empty())
    return;

  holding_space_metrics::RecordItemAction(
      items_to_record, holding_space_metrics::ItemAction::kUnpin, event_source);

  holding_space_model_.RemoveItems(items);
}

bool HoldingSpaceKeyedService::ContainsPinnedFile(
    const storage::FileSystemURL& file_system_url) const {
  return holding_space_model_.ContainsItem(HoldingSpaceItem::Type::kPinnedFile,
                                           file_system_url.path());
}

std::vector<GURL> HoldingSpaceKeyedService::GetPinnedFiles() const {
  std::vector<GURL> pinned_files;
  for (const auto& item : holding_space_model_.items()) {
    if (item->type() == HoldingSpaceItem::Type::kPinnedFile)
      pinned_files.push_back(item->file().file_system_url);
  }
  return pinned_files;
}

void HoldingSpaceKeyedService::RefreshSuggestions() {
  if (suggestions_delegate_) {
    suggestions_delegate_->RefreshSuggestions();
  }
}

void HoldingSpaceKeyedService::RemoveSuggestions(
    const std::vector<base::FilePath>& absolute_file_paths) {
  if (suggestions_delegate_) {
    suggestions_delegate_->RemoveSuggestions(absolute_file_paths);
  }
}

void HoldingSpaceKeyedService::SetSuggestions(
    const std::vector<std::pair<HoldingSpaceItem::Type, base::FilePath>>&
        suggestions) {
  if (!IsInitialized()) {
    return;
  }

  std::set<std::string> item_ids_to_remove;

  // Gather `existing_suggestions`. Note that suggestions are reversed in the
  // holding space model to account for the fact that items are presented in
  // reverse-chronological order.
  std::vector<const HoldingSpaceItem*> existing_suggestions;
  for (const auto& item : base::Reversed(holding_space_model_.items())) {
    if (HoldingSpaceItem::IsSuggestionType(item->type())) {
      existing_suggestions.emplace_back(item.get());
      item_ids_to_remove.insert(item->id());
    }
  }

  // No-op if `existing_suggestions` are unchanged.
  if (base::ranges::equal(existing_suggestions, suggestions, /*pred=*/{},
                          [](const HoldingSpaceItem* item) {
                            return std::make_pair(item->type(),
                                                  item->file().file_path);
                          })) {
    return;
  }

  // Construct `items_to_add` from `suggestions`. Note that any pre-existing
  // items which would ideally be recycled are replaced due to the fact that the
  // holding space model doesn't currently support reordering.
  std::vector<std::unique_ptr<HoldingSpaceItem>> items_to_add;
  for (const auto& [type, file_path] : base::Reversed(suggestions)) {
    std::unique_ptr<HoldingSpaceItem> item;
    if (auto existing_item =
            base::ranges::find_if(existing_suggestions,
                                  [&](const HoldingSpaceItem* item) {
                                    return item->type() == type &&
                                           item->file().file_path == file_path;
                                  });
        existing_item != existing_suggestions.end() &&
        !(*existing_item)->IsInitialized()) {
      // Reuse the existing uninitialized file suggestion item to avoid
      // resolving the suggested file's URL. Because `*existing_item` is
      // uninitialized, its removal does not incur visual changes.
      item = holding_space_model_.TakeItem((*existing_item)->id());
      item_ids_to_remove.erase(item->id());
    } else {
      item = CreateItemOfType(
          type, file_path,
          /*progress=*/HoldingSpaceProgress(),
          /*placeholder_image_skia_resolver=*/base::NullCallback());
    }

    if (item)
      items_to_add.push_back(std::move(item));
  }

  // Add new items before removing old items to prevent UI from transitioning to
  // an empty state if the model is only temporarily becoming empty.
  AddItems(std::move(items_to_add), /*allow_duplicates=*/true);
  holding_space_model_.RemoveItems(item_ids_to_remove);
}

const std::string& HoldingSpaceKeyedService::AddItem(
    std::unique_ptr<HoldingSpaceItem> item) {
  if (!IsInitialized()) {
    return base::EmptyString();
  }

  std::vector<std::unique_ptr<HoldingSpaceItem>> items;
  items.push_back(std::move(item));
  return AddItems(std::move(items), /*allow_duplicates=*/false).at(0);
}

const std::string& HoldingSpaceKeyedService::AddItemOfType(
    HoldingSpaceItem::Type type,
    const base::FilePath& file_path,
    const HoldingSpaceProgress& progress,
    HoldingSpaceImage::PlaceholderImageSkiaResolver
        placeholder_image_skia_resolver) {
  if (!IsInitialized()) {
    return base::EmptyString();
  }

  std::unique_ptr<HoldingSpaceItem> item = CreateItemOfType(
      type, file_path, progress, placeholder_image_skia_resolver);
  if (!item)
    return base::EmptyString();

  return AddItem(std::move(item));
}

bool HoldingSpaceKeyedService::ContainsItem(const std::string& id) const {
  return holding_space_model_.GetItem(id) != nullptr;
}

std::unique_ptr<HoldingSpaceModel::ScopedItemUpdate>
HoldingSpaceKeyedService::UpdateItem(const std::string& id) {
  return IsInitialized() ? holding_space_model_.UpdateItem(id) : nullptr;
}

void HoldingSpaceKeyedService::RemoveAll() {
  if (IsInitialized()) {
    holding_space_model_.RemoveAll();
  }
}

void HoldingSpaceKeyedService::RemoveItem(const std::string& id) {
  if (IsInitialized()) {
    holding_space_model_.RemoveItem(id);
  }
}

std::optional<holding_space_metrics::ItemLaunchFailureReason>
HoldingSpaceKeyedService::OpenItemWhenComplete(const HoldingSpaceItem* item) {
  // Currently it is only possible to open download type items when complete.
  if (HoldingSpaceItem::IsDownloadType(item->type()) && downloads_delegate_) {
    return downloads_delegate_->OpenWhenComplete(item);
  }
  return holding_space_metrics::ItemLaunchFailureReason::kNoHandlerForItemType;
}

void HoldingSpaceKeyedService::Shutdown() {
  ShutdownDelegates();
}

void HoldingSpaceKeyedService::OnProfileAdded(Profile* profile) {
  if (profile == profile_) {
    DCHECK(profile_manager_observer_.IsObserving());
    profile_manager_observer_.Reset();
    OnProfileReady();
  }
}

void HoldingSpaceKeyedService::OnProfileReady() {
  // Record user preferences at start up.
  PrefService* const prefs = profile_->GetPrefs();
  holding_space_metrics::RecordUserPreferences({
      .previews_enabled = holding_space_prefs::IsPreviewsEnabled(prefs),
      .suggestions_expanded = holding_space_prefs::IsSuggestionsExpanded(prefs),
  });

  // Observe suspend status - the delegates will be shutdown during suspend.
  if (chromeos::PowerManagerClient::Get())
    chromeos::PowerManagerClient::Get()->AddObserver(this);

  InitializeDelegates();

  if (HoldingSpaceController::Get()) {  // May be `nullptr` in tests.
    HoldingSpaceController::Get()->RegisterClientAndModelForUser(
        account_id_, &holding_space_client_, &holding_space_model_);
  }
}

void HoldingSpaceKeyedService::SuspendImminent(
    power_manager::SuspendImminent::Reason reason) {
  // Shutdown all delegates and clear the model when device suspends - some
  // volumes may get unmounted during suspend, and may thus incorrectly get
  // detected as deleted when device suspends - shutting down delegates during
  // suspend avoids this issue, as it also disables file removal detection.
  ShutdownDelegates();

  // Clear the model as it will get restored from persistence when
  // delegates are re-initialized after suspend.
  holding_space_model_.RemoveAll();
}

void HoldingSpaceKeyedService::SuspendDone(base::TimeDelta sleep_duration) {
  InitializeDelegates();
}

std::vector<std::reference_wrapper<const std::string>>
HoldingSpaceKeyedService::AddItems(
    std::vector<std::unique_ptr<HoldingSpaceItem>> items,
    bool allow_duplicates) {
  std::vector<std::reference_wrapper<const std::string>> result;
  std::vector<std::unique_ptr<HoldingSpaceItem>> items_to_add;

  for (auto& item : items) {
    // Ignore any `items` that already exist in the `holding_space_model_` if
    // `allow_duplicates` is false.
    if (!allow_duplicates && holding_space_model_.ContainsItem(
                                 item->type(), item->file().file_path)) {
      result.push_back(std::cref(base::EmptyString()));
      continue;
    }
    result.push_back(std::cref(item->id()));
    items_to_add.push_back(std::move(item));
  }

  if (!items_to_add.empty()) {
    // Mark the time when the user's first item was added to holding space. Note
    // that true is returned iff this is in fact the user's first add and, if
    // so, the time it took for the user to add their first item should be
    // recorded.
    if (holding_space_prefs::MarkTimeOfFirstAdd(profile_->GetPrefs())) {
      RecordTimeFromFirstAvailabilityToFirstAdd(profile_);
    }
    holding_space_model_.AddItems(std::move(items_to_add));
  }

  return result;
}

void HoldingSpaceKeyedService::InitializeDelegates() {
  // Bail out if delegates have already been initialized - delegates are
  // shutdown on suspend, and re-initialized once suspend completes. If
  // holding space keyed service starts observing suspend state after
  // `SuspendImminent()` is sent out, original delegates may still be around.
  if (!delegates_.empty()) {
    return;
  }

  // The `HoldingSpaceDownloadsDelegate` monitors the status of downloads.
  auto downloads_delegate = std::make_unique<HoldingSpaceDownloadsDelegate>(
      this, &holding_space_model_);
  downloads_delegate_ = downloads_delegate.get();
  delegates_.push_back(std::move(downloads_delegate));

  // The `HoldingSpaceFileSystemDelegate` monitors the file system for changes.
  delegates_.push_back(std::make_unique<HoldingSpaceFileSystemDelegate>(
      this, &holding_space_model_));

  // The `HoldingSpaceMetricsDelegate` records metrics.
  delegates_.push_back(std::make_unique<HoldingSpaceMetricsDelegate>(
      this, &holding_space_model_));

  // The `HoldingSpacePersistenceDelegate` manages holding space persistence.
  delegates_.push_back(std::make_unique<HoldingSpacePersistenceDelegate>(
      this, &holding_space_model_, &thumbnail_loader_,
      /*persistence_restored_callback=*/
      base::BindOnce(&HoldingSpaceKeyedService::OnPersistenceRestored,
                     weak_factory_.GetWeakPtr())));

  // The `HoldingSpaceSuggestionsDelegate` manages file suggestions (i.e. the
  // files predicted to be used).
  if (features::IsHoldingSpaceSuggestionsEnabled()) {
    auto suggestions_delegate =
        std::make_unique<HoldingSpaceSuggestionsDelegate>(
            this, &holding_space_model_);
    suggestions_delegate_ = suggestions_delegate.get();
    delegates_.push_back(std::move(suggestions_delegate));
  }

  // Initialize all delegates only after they have been added to our collection.
  // Delegates should not fire their respective callbacks during construction
  // but once they have been initialized they are free to do so.
  for (auto& delegate : delegates_)
    delegate->Init();
}

void HoldingSpaceKeyedService::ShutdownDelegates() {
  downloads_delegate_ = nullptr;
  suggestions_delegate_ = nullptr;
  delegates_.clear();
}

void HoldingSpaceKeyedService::OnPersistenceRestored(
    std::vector<std::unique_ptr<HoldingSpaceItem>> restored_items) {
  AddItems(std::move(restored_items), /*allow_duplicates=*/false);
  for (auto& delegate : delegates_)
    delegate->NotifyPersistenceRestored();
}

void HoldingSpaceKeyedService::MakeDriveItemAvailableOffline(
    const storage::FileSystemURL& file_system_url) {
  auto* drive_service =
      drive::DriveIntegrationServiceFactory::GetForProfile(profile_);

  bool drive_fs_mounted = drive_service && drive_service->IsMounted();
  if (!drive_fs_mounted)
    return;

  if (!drive_service->GetDriveFsInterface())
    return;

  base::FilePath path;
  if (drive_service->GetRelativeDrivePath(file_system_url.path(), &path)) {
    drive_service->GetDriveFsInterface()->SetPinned(path, true,
                                                    base::DoNothing());
  }
}

bool HoldingSpaceKeyedService::IsInitialized() const {
  return delegates_.size() &&
         base::ranges::none_of(
             delegates_,
             &HoldingSpaceKeyedServiceDelegate::is_restoring_persistence);
}

std::unique_ptr<HoldingSpaceItem> HoldingSpaceKeyedService::CreateItemOfType(
    HoldingSpaceItem::Type type,
    const base::FilePath& file_path,
    const HoldingSpaceProgress& progress,
    HoldingSpaceImage::PlaceholderImageSkiaResolver
        placeholder_image_skia_resolver) {
  const GURL file_system_url =
      holding_space_util::ResolveFileSystemUrl(profile_, file_path);
  if (file_system_url.is_empty())
    return nullptr;

  return HoldingSpaceItem::CreateFileBackedItem(
      type,
      HoldingSpaceFile(
          file_path,
          holding_space_util::ResolveFileSystemType(profile_, file_system_url),
          file_system_url),
      progress,
      base::BindOnce(
          &holding_space_util::ResolveImageWithPlaceholderImageSkiaResolver,
          &thumbnail_loader_, placeholder_image_skia_resolver));
}

}  // namespace ash