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

#include <optional>
#include <string>
#include <utility>

#include "ash/public/cpp/app_list/vector_icons/vector_icons.h"
#include "ash/public/cpp/style/dark_light_mode_controller.h"
#include "base/strings/string_util.h"
#include "chrome/browser/ash/app_list/app_list_controller_delegate.h"
#include "chrome/browser/ash/app_list/search/common/icon_constants.h"
#include "chrome/browser/ash/app_list/search/common/search_result_util.h"
#include "chrome/browser/ash/app_list/search/omnibox/omnibox_util.h"
#include "chrome/browser/bitmap_fetcher/bitmap_fetcher.h"
#include "chrome/browser/chromeos/launcher_search/search_util.h"
#include "chrome/browser/profiles/profile.h"
#include "chromeos/crosapi/mojom/launcher_search.mojom.h"
#include "components/omnibox/browser/vector_icons.h"
#include "extensions/common/image_util.h"
#include "services/network/public/cpp/shared_url_loader_factory.h"
#include "ui/base/window_open_disposition_utils.h"
#include "ui/gfx/color_palette.h"
#include "ui/gfx/image/image_skia_operations.h"
#include "ui/gfx/paint_vector_icon.h"
#include "url/gurl.h"

namespace app_list {
namespace {

using Tag = ash::SearchResultTag;
using TextItem = ash::SearchResultTextItem;
using TextType = ash::SearchResultTextItemType;
using CrosApiSearchResult = crosapi::mojom::SearchResult;

constexpr char kOmniboxAnswerSchema[] = "omnibox_answer://";

ChromeSearchResult::IconInfo CreateAnswerIconInfo(
    const gfx::VectorIcon& vector_icon) {
  const auto icon = gfx::ImageSkiaOperations::CreateImageWithCircleBackground(
      kAnswerCardIconDimension / 2, gfx::kGoogleBlue300,
      gfx::CreateVectorIcon(vector_icon, gfx::kGoogleGrey900));
  return ChromeSearchResult::IconInfo(ui::ImageModel::FromImageSkia(icon),
                                      kAnswerCardIconDimension);
}

// Convert from our Mojo answer type to the corresponding Omnibox icon.
const gfx::VectorIcon& AnswerTypeToVectorIcon(
    CrosApiSearchResult::AnswerType type) {
  switch (type) {
    case CrosApiSearchResult::AnswerType::kCurrency:
      return omnibox::kAnswerCurrencyIcon;
    case CrosApiSearchResult::AnswerType::kDictionary:
      return omnibox::kAnswerDictionaryIcon;
    case CrosApiSearchResult::AnswerType::kFinance:
      return omnibox::kAnswerFinanceIcon;
    case CrosApiSearchResult::AnswerType::kSunrise:
      return omnibox::kAnswerSunriseIcon;
    case CrosApiSearchResult::AnswerType::kTranslation:
      return omnibox::kAnswerTranslationIcon;
    case CrosApiSearchResult::AnswerType::kWhenIs:
      return omnibox::kAnswerWhenIsIcon;
    default:
      return omnibox::kAnswerDefaultIcon;
  }
}

// Tries to extract the temperature and temperature units from
// SuggestionAnswer::TextFields. For example, a text field containing "26°C" is
// converted into the pair ("26", "°C"), and a text field containing "-5°F" is
// converted into the pair ("-5", "°F").
std::optional<std::pair<std::u16string, std::u16string>> GetTemperature(
    const std::optional<std::u16string>& text) {
  if (!text.has_value() || text->empty())
    return std::nullopt;

  size_t digits_end = text->find_last_of(u"0123456789");
  if (digits_end == std::u16string::npos)
    return std::nullopt;

  size_t unit_start = digits_end + 1;
  return std::make_pair(text->substr(0, unit_start), text->substr(unit_start));
}

// Converts the given text into a TextItem and appends it to the supplied
// vector.
void AppendTextItem(const std::u16string& line,
                    const CrosApiSearchResult::TextType type,
                    std::vector<TextItem>& text_vector) {
  if (line.empty()) {
    return;
  }

  if (!text_vector.empty()) {
    text_vector.push_back(CreateStringTextItem(u" "));
  }

  TextItem text_item(TextType::kString);
  text_item.SetText(line);
  text_item.SetTextTags(TagsForText(line, type));
  text_vector.push_back(text_item);
}

std::u16string ComputeAccessibleName(
    const std::vector<std::vector<TextItem>>& text_vectors) {
  std::vector<std::u16string> text;
  for (const auto& text_vector : text_vectors) {
    if (!text_vector.empty()) {
      text.push_back(StringFromTextVector(text_vector));
    }
  }
  return base::JoinString(text, u", ");
}

}  // namespace

OmniboxAnswerResult::OmniboxAnswerResult(
    Profile* profile,
    AppListControllerDelegate* list_controller,
    crosapi::mojom::SearchResultPtr search_result,
    const std::u16string& query)
    : profile_(profile),
      list_controller_(list_controller),
      search_result_(std::move(search_result)),
      query_(query),
      contents_(search_result_->contents.value_or(u"")),
      additional_contents_(search_result_->additional_contents.value_or(u"")),
      description_(search_result_->description.value_or(u"")),
      additional_description_(
          search_result_->additional_description.value_or(u"")) {
  SetDisplayType(DisplayType::kAnswerCard);
  SetResultType(ResultType::kOmnibox);
  SetCategory(Category::kSearchAndAssistant);

  // mojo::StructPtr DCHECKs non-null on dereference.
  DCHECK(search_result_->stripped_destination_url.has_value());
  set_id(kOmniboxAnswerSchema +
         search_result_->stripped_destination_url->spec());

  SetMetricsType(IsCalculatorResult() ? ash::OMNIBOX_CALCULATOR
                                      : ash::OMNIBOX_ANSWER);

  // Derive relevance from omnibox relevance and normalize it to [0, 1].
  set_relevance(search_result_->relevance / kMaxOmniboxScore);

  UpdateIcon();
  UpdateTitleAndDetails();

  set_answer_type(search_result_->answer_type);

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

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

void OmniboxAnswerResult::Open(int event_flags) {
  DCHECK(search_result_->destination_url.has_value());
  list_controller_->OpenURL(profile_, *search_result_->destination_url,
                            crosapi::PageTransitionToUiPageTransition(
                                search_result_->page_transition),
                            ui::DispositionFromEventFlags(event_flags));
}

void OmniboxAnswerResult::OnColorModeChanged(bool dark_mode_enabled) {
  if (!IsWeatherResult())
    UpdateIcon();
}

void OmniboxAnswerResult::UpdateIcon() {
  if (IsCalculatorResult()) {
    SetIcon(CreateAnswerIconInfo(omnibox::kCalculatorIcon));
  } else if (IsWeatherResult() &&
             search_result_->image_url.value_or(GURL()).is_valid()) {
    // Weather icons are downloaded. Check this first so that the local
    // default answer icon can be used as a fallback if the URL is missing.
    FetchImage(*search_result_->image_url);
  } else {
    SetIcon(CreateAnswerIconInfo(
        AnswerTypeToVectorIcon(search_result_->answer_type)));
  }
}

void OmniboxAnswerResult::UpdateTitleAndDetails() {
  if (IsWeatherResult()) {
    auto temperature = GetTemperature(search_result_->description);
    if (temperature) {
      SetBigTitleTextVector({CreateStringTextItem(temperature->first)});
      SetBigTitleSuperscriptTextVector(
          {CreateStringTextItem(temperature->second)});
    } else {
      // If the temperature can't be parsed, don't display this result.
      scoring().set_filtered(true);
    }

    if (search_result_->description_a11y_label.has_value()) {
      SetTitleTextVector({CreateStringTextItem(
          search_result_->description_a11y_label.value())});
    } else {
      std::vector<TextItem> title_vector;
      AppendTextItem(contents_, search_result_->contents_type, title_vector);
      SetTitleTextVector(title_vector);
    }

    std::vector<TextItem> details_vector;
    AppendTextItem(additional_description_,
                   search_result_->additional_description_type, details_vector);
    SetDetailsTextVector(details_vector);
  } else {
    // Not a weather result.

    std::vector<TextItem> title_vector;
    AppendTextItem(contents_, search_result_->contents_type, title_vector);
    AppendTextItem(additional_contents_,
                   search_result_->additional_contents_type, title_vector);
    SetTitleTextVector(title_vector);

    std::vector<TextItem> details_vector;
    AppendTextItem(description_, search_result_->description_type,
                   details_vector);
    AppendTextItem(additional_description_,
                   search_result_->additional_description_type, details_vector);
    SetDetailsTextVector(details_vector);

    // Dictionary answer details can be split over multiple lines.
    if (IsDictionaryResult())
      SetMultilineDetails(true);
  }

  const std::u16string accessible_name = ComputeAccessibleName(
      {big_title_text_vector(), title_text_vector(), details_text_vector()});
  SetAccessibleName(accessible_name);
}

void OmniboxAnswerResult::FetchImage(const GURL& url) {
  if (!bitmap_fetcher_) {
    bitmap_fetcher_ =
        std::make_unique<BitmapFetcher>(url, this, kOmniboxTrafficAnnotation);
  }
  bitmap_fetcher_->Init(net::ReferrerPolicy::NEVER_CLEAR,
                        network::mojom::CredentialsMode::kOmit);
  bitmap_fetcher_->Start(profile_->GetURLLoaderFactory().get());
}

void OmniboxAnswerResult::OnFetchComplete(const GURL& url,
                                          const SkBitmap* bitmap) {
  if (!bitmap)
    return;

  DCHECK(IsWeatherResult());
  IconInfo icon_info(ui::ImageModel::FromImageSkia(
                         gfx::ImageSkia::CreateFrom1xBitmap(*bitmap)),
                     kAnswerCardIconDimension);
  SetIcon(icon_info);
}

bool OmniboxAnswerResult::IsCalculatorResult() const {
  return search_result_->answer_type ==
         CrosApiSearchResult::AnswerType::kCalculator;
}

bool OmniboxAnswerResult::IsDictionaryResult() const {
  return search_result_->answer_type ==
         CrosApiSearchResult::AnswerType::kDictionary;
}

bool OmniboxAnswerResult::IsWeatherResult() const {
  return search_result_->answer_type ==
         CrosApiSearchResult::AnswerType::kWeather;
}

}  // namespace app_list