chromium/chrome/browser/ash/app_list/search/files/file_result.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/ash/app_list/search/files/file_result.h"

#include <string>
#include <utility>
#include <vector>

#include "ash/public/cpp/app_list/app_list_types.h"
#include "ash/public/cpp/image_util.h"
#include "ash/public/cpp/style/dark_light_mode_controller.h"
#include "base/files/file.h"
#include "base/files/file_path.h"
#include "base/files/file_util.h"
#include "base/metrics/histogram_functions.h"
#include "base/metrics/histogram_macros.h"
#include "base/strings/string_util.h"
#include "base/strings/utf_string_conversions.h"
#include "chrome/browser/ash/app_list/search/common/icon_constants.h"
#include "chrome/browser/ash/app_list/search/files/file_title.h"
#include "chrome/browser/ash/app_list/search/search_features.h"
#include "chrome/browser/ash/file_manager/fileapi_util.h"
#include "chrome/browser/ash/file_manager/path_util.h"
#include "chrome/browser/platform_util.h"
#include "chrome/browser/ui/ash/thumbnail_loader/thumbnail_loader.h"
#include "chromeos/ash/components/string_matching/fuzzy_tokenized_string_match.h"
#include "chromeos/ash/components/string_matching/tokenized_string.h"
#include "chromeos/ash/components/string_matching/tokenized_string_match.h"
#include "chromeos/ui/base/file_icon_util.h"
#include "content/public/browser/browser_thread.h"
#include "net/base/mime_util.h"
#include "storage/browser/file_system/file_system_context.h"
#include "third_party/skia/include/core/SkBitmap.h"
#include "ui/gfx/geometry/size.h"
#include "ui/gfx/image/image_skia.h"
#include "ui/gfx/image/image_skia_operations.h"

namespace app_list {

namespace {

using ::ash::string_matching::FuzzyTokenizedStringMatch;
using ::ash::string_matching::TokenizedString;
using ::ash::string_matching::TokenizedStringMatch;

// The default relevance returned by CalculateRelevance.
constexpr double kDefaultRelevance = 0.5;

// Parameters for FuzzyTokenizedStringMatch.
constexpr bool kUseWeightedRatio = false;

// Flag to enable/disable diacritics stripping
constexpr bool kStripDiacritics = true;

// Flag to enable/disable acronym matcher.
constexpr bool kUseAcronymMatcher = true;

// The maximum penalty applied to a relevance by PenalizeRelevanceByAccessTime,
// which will multiply the relevance by a number in [`kMaxPenalty`, 1].
constexpr double kMaxPenalty = 0.6;

// The steepness of the penalty curve of PenalizeRelevanceByAccessTime. Larger
// values make the penalty increase faster as the last access time of the file
// increases. A value of 0.0029 results in a penalty multiplier of ~0.63 for a 1
// month old file.
// Note that files which have all been modified recently may end up with the
// same penalty, since this coefficient is not large enough to differentiate
// between them.
constexpr double kPenaltyCoeff = 0.0029;

constexpr int64_t kMillisPerDay = 1000 * 60 * 60 * 24;

gfx::Size GetIconSizeForDisplayType(ash::SearchResultDisplayType display_type) {
  switch (display_type) {
    case ash::SearchResultDisplayType::kList:
      return gfx::Size(kThumbnailDimension, kThumbnailDimension);
    case ash::SearchResultDisplayType::kImage:
      return gfx::Size(kImageSearchWidth, kImageSearchHeight);
    case ash::SearchResultDisplayType::kContinue:
    case ash::SearchResultDisplayType::kNone:
    case ash::SearchResultDisplayType::kAnswerCard:
    case ash::SearchResultDisplayType::kRecentApps:
    case ash::SearchResultDisplayType::kLast:
      NOTREACHED_IN_MIGRATION();
      return gfx::Size();
  }
}

// Generates base::File::Info for the result at `file_path`.
// Performs blocking File IO, so should not be run on UI thread.
base::File::Info GetFileInfo(base::FilePath file_path) {
  CHECK(!content::BrowserThread::CurrentlyOn(content::BrowserThread::UI))
      << "FileIO attempted on UI thread.";

  base::File::Info info;
  if (!base::GetFileInfo(file_path, &info)) {
    return base::File::Info();
  }

  return info;
}

void LogRelevance(ChromeSearchResult::ResultType result_type,
                  const double relevance) {
  // Relevance scores are between 0 and 1, so we scale to 0 to 100 for logging.
  DCHECK((relevance >= 0) && (relevance <= 1));
  const int scaled_relevance = floor(100 * relevance);
  switch (result_type) {
    case FileResult::ResultType::kFileSearch:
      UMA_HISTOGRAM_EXACT_LINEAR("Apps.AppList.FileSearchProvider.Relevance",
                                 scaled_relevance, /*exclusive_max=*/101);
      break;
    case FileResult::ResultType::kDriveSearch:
      UMA_HISTOGRAM_EXACT_LINEAR("Apps.AppList.DriveSearchProvider.Relevance",
                                 scaled_relevance, /*exclusive_max=*/101);
      break;
    case FileResult::ResultType::kZeroStateFile:
      UMA_HISTOGRAM_EXACT_LINEAR("Apps.AppList.ZeroStateFileProvider.Relevance",
                                 scaled_relevance, /*exclusive_max=*/101);
      break;
    case FileResult::ResultType::kZeroStateDrive:
      UMA_HISTOGRAM_EXACT_LINEAR(
          "Apps.AppList.ZeroStateDriveProvider.Relevance", scaled_relevance,
          /*exclusive_max=*/101);
      break;
    case FileResult::ResultType::kImageSearch:
      // TODO(b/260646344): add UMA metric
      break;
    default:
      NOTREACHED_IN_MIGRATION();
  }
}

}  // namespace

FileResult::FileResult(const std::string& id,
                       const base::FilePath& filepath,
                       const std::optional<std::u16string>& details,
                       ResultType result_type,
                       DisplayType display_type,
                       float relevance,
                       const std::u16string& query,
                       Type type,
                       Profile* profile,
                       ash::ThumbnailLoader* thumbnail_loader)
    : filepath_(filepath),
      type_(type),
      profile_(profile),
      thumbnail_loader_(thumbnail_loader) {
  DCHECK(profile);
  set_id(id);
  SetCategory(Category::kFiles);
  SetDisplayType(display_type);

  set_relevance(relevance);
  if (display_type == DisplayType::kList) {
    // Chip and list results overlap, and only list results are fully launched.
    // So we only log metrics for list results.
    LogRelevance(result_type, relevance);
  }

  SetResultType(result_type);
  switch (result_type) {
    case ResultType::kZeroStateDrive:
      SetMetricsType(ash::ZERO_STATE_DRIVE);
      break;
    case ResultType::kZeroStateFile:
      SetMetricsType(ash::ZERO_STATE_FILE);
      break;
    case ResultType::kFileSearch:
      SetMetricsType(ash::FILE_SEARCH);
      break;
    case ResultType::kDriveSearch:
      SetMetricsType(ash::DRIVE_SEARCH);
      break;
    case ResultType::kImageSearch:
      SetMetricsType(ash::IMAGE_SEARCH);
      break;
    default:
      NOTREACHED_IN_MIGRATION();
  }

  SetTitle(GetFileTitle(filepath));

  if (details)
    SetDetails(details.value());

  // Initialize the file metadata.
  SetFilePath(filepath_);
  if (result_type == ash::AppListSearchResultType::kImageSearch) {
    SetDisplayableFilePath(
        file_manager::util::GetDisplayablePath(profile_, filepath_)
            .value_or(filepath_));
    SetMetadataLoaderCallback(base::BindRepeating(&GetFileInfo, filepath_));
  }

  if (display_type == DisplayType::kContinue) {
    UpdateChipIcon();
  } else {
    thumbnail_image_ = std::make_unique<ash::HoldingSpaceImage>(
        GetIconSizeForDisplayType(display_type), filepath_,
        base::BindRepeating(&FileResult::RequestThumbnail,
                            weak_factory_.GetWeakPtr()),
        base::BindRepeating(&FileResult::GetPlaceholderImage,
                            base::Unretained(this)));
    thumbnail_image_update_sub_ =
        thumbnail_image_->AddImageSkiaChangedCallback(base::BindRepeating(
            &FileResult::UpdateThumbnailIcon, base::Unretained(this)));
    UpdateThumbnailIcon();
  }

  if (auto* dark_light_mode_controller = ash::DarkLightModeController::Get())
    dark_light_mode_controller->AddObserver(this);
}

FileResult::~FileResult() {
  if (auto* dark_light_mode_controller = ash::DarkLightModeController::Get())
    dark_light_mode_controller->RemoveObserver(this);
}

void FileResult::Open(int event_flags) {
  switch (type_) {
    case Type::kFile:
      platform_util::OpenItem(profile_, filepath_,
                              platform_util::OpenItemType::OPEN_FILE,
                              platform_util::OpenOperationCallback());
      break;
    case Type::kDirectory:
    case Type::kSharedDirectory:
      platform_util::OpenItem(profile_, filepath_,
                              platform_util::OpenItemType::OPEN_FOLDER,
                              platform_util::OpenOperationCallback());
      break;
  }
}

std::optional<std::string> FileResult::DriveId() const {
  return drive_id_;
}

std::optional<GURL> FileResult::url() const {
  return url_;
}

// static
double FileResult::CalculateRelevance(
    const std::optional<TokenizedString>& query,
    const base::FilePath& filepath,
    const std::optional<base::Time>& last_accessed) {
  const std::u16string raw_title = GetFileTitle(filepath);
  const TokenizedString title(raw_title, TokenizedString::Mode::kWords);

  const bool use_default_relevance =
      !query || query.value().text().empty() || title.text().empty();
  UMA_HISTOGRAM_BOOLEAN("Apps.AppList.FileResult.DefaultRelevanceUsed",
                        use_default_relevance);
  if (use_default_relevance)
    return kDefaultRelevance;
  double relevance;
  if (search_features::IsLauncherFuzzyMatchAcrossProvidersEnabled()) {
    FuzzyTokenizedStringMatch fuzzy_match;
    relevance = fuzzy_match.Relevance(query.value(), title, kUseWeightedRatio,
                                      kStripDiacritics, kUseAcronymMatcher);
  } else {
    TokenizedStringMatch match;
    relevance = match.Calculate(query.value(), title);
  }
  if (!last_accessed) {
    return relevance;
  }

  // Apply a gaussian penalty based on the time delta. `time_delta` is converted
  // into millisecond fractions of a day for numerical stability.
  const double time_delta =
      static_cast<double>(
          (base::Time::Now() - last_accessed.value()).InMilliseconds()) /
      kMillisPerDay;
  const double penalty =
      kMaxPenalty +
      (1.0 - kMaxPenalty) * std::exp(-kPenaltyCoeff * time_delta * time_delta);
  DCHECK(penalty > 0.0 && penalty <= 1.0);
  return relevance * penalty;
}

void FileResult::RequestThumbnail(
    const base::FilePath& file_path,
    const gfx::Size& size,
    ash::HoldingSpaceImage::BitmapCallback callback) {
  if (!thumbnail_loader_) {
    std::move(callback).Run(nullptr, base::File::FILE_ERROR_FAILED);
    return;
  }
  thumbnail_loader_->Load({file_path, size}, std::move(callback));
}

void FileResult::OnColorModeChanged(bool dark_mode_enabled) {
  if (display_type() == DisplayType::kContinue) {
    UpdateChipIcon();
  }
}

void FileResult::UpdateThumbnailIcon() {
  const int dimension = GetIconSizeForDisplayType(display_type()).width();
  const auto shape = display_type() == DisplayType::kList
                         ? ash::SearchResultIconShape::kCircle
                         : ash::SearchResultIconShape::kRoundedRectangle;
  SetIcon(
      IconInfo(ui::ImageModel::FromImageSkia(thumbnail_image_->GetImageSkia()),
               dimension, shape, thumbnail_image_->UsingPlaceholder()));
}

gfx::ImageSkia FileResult::GetPlaceholderImage(
    const base::FilePath& file_path,
    const gfx::Size& size,
    const std::optional<bool>& dark_background,
    const std::optional<bool>& is_folder) {
  // Do not set the default chromeos icon to the image search result.
  if (display_type() == DisplayType::kImage) {
    return ash::image_util::CreateEmptyImage(size);
  }

  gfx::ImageSkia icon_image;
  switch (type_) {
    case Type::kFile:
      icon_image = chromeos::GetIconForPath(
          file_path, dark_background.value_or(true), kSystemIconDimension);
      break;
    case Type::kDirectory:
      icon_image = chromeos::GetIconFromType(chromeos::IconType::kFolder,
                                             dark_background.value_or(true),
                                             kSystemIconDimension);
      break;
    case Type::kSharedDirectory:
      icon_image = chromeos::GetIconFromType(chromeos::IconType::kFolderShared,
                                             dark_background.value_or(true),
                                             kSystemIconDimension);
      break;
  }
  return gfx::ImageSkiaOperations::CreateSuperimposedImage(
      ash::image_util::CreateEmptyImage(size), icon_image);
}

void FileResult::UpdateChipIcon() {
  // DarkLightModeController might be nullptr in tests.
  auto* dark_light_mode_controller = ash::DarkLightModeController::Get();
  const bool dark_background = dark_light_mode_controller &&
                               dark_light_mode_controller->IsDarkModeEnabled();

  SetChipIcon(chromeos::GetChipIconForPath(filepath_, dark_background));
}

::std::ostream& operator<<(::std::ostream& os, const FileResult& result) {
  return os << "{" << result.title() << ", " << result.relevance() << "}";
}

}  // namespace app_list