chromium/chrome/browser/ui/webui/ash/emoji/gif_tenor_api_fetcher.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/ui/webui/ash/emoji/gif_tenor_api_fetcher.h"

#include <memory>
#include <optional>
#include <string>
#include <string_view>
#include <utility>
#include <vector>

#include "base/check_deref.h"
#include "base/functional/bind.h"
#include "base/memory/scoped_refptr.h"
#include "base/strings/string_number_conversions.h"
#include "base/strings/string_util.h"
#include "base/time/time.h"
#include "base/values.h"
#include "chrome/browser/ui/webui/ash/emoji/emoji_picker.mojom.h"
#include "chrome/common/channel_info.h"
#include "components/endpoint_fetcher/endpoint_fetcher.h"
#include "components/version_info/channel.h"
#include "net/base/url_util.h"
#include "net/http/http_status_code.h"
#include "net/traffic_annotation/network_traffic_annotation.h"
#include "services/data_decoder/public/cpp/data_decoder.h"
#include "services/network/public/cpp/shared_url_loader_factory.h"
#include "ui/gfx/geometry/size.h"
#include "url/gurl.h"

namespace ash {

namespace {

using emoji_picker::mojom::PageHandler;

constexpr char kTenorBaseUrl[] = "https://tenor.googleapis.com";
constexpr char kHttpMethod[] = "GET";
constexpr char kHttpContentType[] = "application/json";

constexpr char kContentFilterName[] = "contentfilter";
constexpr char kContentFilterValue[] = "high";

constexpr char kArRangeName[] = "ar_range";
constexpr char kArRangeValue[] = "wide";

constexpr char kMediaFilterName[] = "media_filter";
constexpr char kMediaFilterValue[] = "gif,tinygif,tinygifpreview";

constexpr char kClientKeyName[] = "client_key";
constexpr char kClientKeyValue[] = "chromeos";

constexpr char kPosName[] = "pos";
constexpr base::TimeDelta kTimeout = base::Milliseconds(10000);

constexpr char kSearchApi[] = "/v2/search";
constexpr net::NetworkTrafficAnnotationTag kSearchTrafficAnnotation =
    net::DefineNetworkTrafficAnnotation("chromeos_emoji_picker_search_fetcher",
                                        R"(
      semantics {
        sender: "ChromeOS Emoji Picker"
        description:
          "Gets a list of the most relevant GIFs from the tenor API "
          "(https://developers.google.com/tenor) for a given search term."
        trigger:
          "When a user opens the emoji picker and selects the GIF section, "
          "then type in a search query in the search bar."
        data:
          "Text a user has typed into a text field."
          "Position of the next batch of GIFs, used for infiniate scroll."
          "Authentication to this API is done through Chrome's API key."
        destination: GOOGLE_OWNED_SERVICE
      }
      policy {
        cookies_allowed: NO
        setting:
          "No setting. The feature does nothing by default. Users must take"
          "an explicit action to trigger it."
        policy_exception_justification:
          "Not implemented, not considered useful. This request is part of a "
          "flow which is user-initiated, and is not a background request."
      }
)");

std::unique_ptr<EndpointFetcher> CreateEndpointFetcher(
    const scoped_refptr<network::SharedURLLoaderFactory> url_loader_factory,
    const GURL& url,
    const net::NetworkTrafficAnnotationTag& annotation_tag) {
  return std::make_unique<EndpointFetcher>(
      /*url_loader_factory=*/url_loader_factory,
      /*url=*/url,
      /*http_method=*/kHttpMethod,
      /*content_type=*/kHttpContentType,
      /*timeout=*/kTimeout,
      /*post_data=*/"",
      /*headers=*/std::vector<std::string>(),
      /*cors_exempt_headers=*/std::vector<std::string>(),
      /*annotation_tag=*/annotation_tag, chrome::GetChannel());
}

const base::Value::List* FindList(
    data_decoder::DataDecoder::ValueOrError& result,
    const std::string& key) {
  if (!result.has_value()) {
    return nullptr;
  }

  const auto* response = result->GetIfDict();
  if (!response) {
    return nullptr;
  }

  const auto* list = response->FindList(key);
  return list ? list : nullptr;
}

std::vector<emoji_picker::mojom::GifResponsePtr> ParseGifs(
    const base::Value::List* results) {
  std::vector<emoji_picker::mojom::GifResponsePtr> gifs;
  for (const auto& result : *results) {
    const auto* gif = result.GetIfDict();
    if (!gif) {
      continue;
    }

    const auto* id = gif->FindString("id");
    if (!id) {
      continue;
    }

    const auto* content_description = gif->FindString("content_description");
    if (!content_description) {
      continue;
    }

    const auto* media_formats = gif->FindDict("media_formats");
    if (!media_formats) {
      continue;
    }

    const auto* full_gif = media_formats->FindDict("gif");
    if (!full_gif) {
      continue;
    }

    const base::Value::List* full_size = full_gif->FindList("dims");
    if (!full_size) {
      continue;
    }

    if (full_size->size() != 2) {
      // [width, height]
      continue;
    }

    const std::optional<int> full_width = full_size->front().GetIfInt();
    if (!full_width.has_value()) {
      continue;
    }

    const std::optional<int> full_height = full_size->back().GetIfInt();
    if (!full_height.has_value()) {
      continue;
    }

    const auto* full_url = full_gif->FindString("url");
    if (!full_url) {
      continue;
    }

    const auto full_gurl = GURL(full_url->c_str());
    if (!full_gurl.is_valid()) {
      continue;
    }

    const auto* preview_gif = media_formats->FindDict("tinygif");
    if (!preview_gif) {
      continue;
    }

    const auto* preview_size = preview_gif->FindList("dims");
    if (!preview_size) {
      continue;
    }

    if (preview_size->size() != 2) {
      // [width, height]
      continue;
    }

    const auto preview_width = preview_size->front().GetIfInt();
    if (!preview_width.has_value()) {
      continue;
    }

    const auto preview_height = preview_size->back().GetIfInt();
    if (!preview_height.has_value()) {
      continue;
    }

    const auto* preview_url = preview_gif->FindString("url");
    if (!preview_url) {
      continue;
    }

    const auto preview_gurl = GURL(preview_url->c_str());
    if (!preview_gurl.is_valid()) {
      continue;
    }

    const base::Value::Dict* tiny_gif_preview =
        media_formats->FindDict("tinygifpreview");
    if (!tiny_gif_preview) {
      continue;
    }

    const std::string* tiny_gif_preview_url =
        tiny_gif_preview->FindString("url");
    if (!tiny_gif_preview_url) {
      continue;
    }

    const GURL tiny_gif_preview_gurl = GURL(*tiny_gif_preview_url);
    if (!tiny_gif_preview_gurl.is_valid()) {
      continue;
    }

    gifs.push_back(emoji_picker::mojom::GifResponse::New(
        *id, *content_description,
        emoji_picker::mojom::GifUrls::New(full_gurl, preview_gurl,
                                          tiny_gif_preview_gurl),
        gfx::Size(preview_width.value(), preview_height.value()),
        gfx::Size(*full_width, *full_height)));
  }
  return gifs;
}

GURL GetUrl(const char* endpoint, const std::optional<std::string>& pos) {
  GURL url = net::AppendQueryParameter(GURL(kTenorBaseUrl).Resolve(endpoint),
                                       kContentFilterName, kContentFilterValue);
  url = net::AppendQueryParameter(url, kArRangeName, kArRangeValue);
  url = net::AppendQueryParameter(url, kMediaFilterName, kMediaFilterValue);
  url = net::AppendQueryParameter(url, kClientKeyName, kClientKeyValue);
  if (pos) {
    url = net::AppendQueryParameter(url, kPosName, pos.value());
  }
  return url;
}

emoji_picker::mojom::Status GetError(
    std::unique_ptr<EndpointResponse> response) {
  return response->error_type.has_value() &&
                 response->error_type == FetchErrorType::kNetError
             ? emoji_picker::mojom::Status::kNetError
             : emoji_picker::mojom::Status::kHttpError;
}

}  // namespace

GifTenorApiFetcher::GifTenorApiFetcher()
    : endpoint_fetcher_creator_{base::BindRepeating(&CreateEndpointFetcher)} {}

GifTenorApiFetcher::GifTenorApiFetcher(
    EndpointFetcherCreator endpoint_fetcher_creator)
    : endpoint_fetcher_creator_{endpoint_fetcher_creator} {}

GifTenorApiFetcher::~GifTenorApiFetcher() = default;

// `endpoint_fetcher` may be null.
void GifTenorApiFetcher::TenorGifsApiResponseHandler(
    TenorGifsApiCallback callback,
    std::unique_ptr<EndpointFetcher> endpoint_fetcher,
    std::unique_ptr<EndpointResponse> response) {
  if (response->http_status_code == net::HTTP_OK) {
    data_decoder::DataDecoder::ParseJsonIsolated(
        response->response,
        base::BindOnce(&GifTenorApiFetcher::OnGifsJsonParsed,
                       weak_ptr_factory_.GetWeakPtr(), std::move(callback)));
    return;
  }
  std::move(callback).Run(
      GetError(std::move(response)),
      emoji_picker::mojom::TenorGifResponse::New(
          "", std::vector<emoji_picker::mojom::GifResponsePtr>{}));
}

void GifTenorApiFetcher::OnGifsJsonParsed(
    TenorGifsApiCallback callback,
    data_decoder::DataDecoder::ValueOrError result) {
  const auto* gifs = FindList(result, "results");
  if (!gifs) {
    std::move(callback).Run(
        emoji_picker::mojom::Status::kHttpError,
        emoji_picker::mojom::TenorGifResponse::New(
            "", std::vector<emoji_picker::mojom::GifResponsePtr>{}));
    return;
  }
  const auto* next = result->GetDict().FindString("next");
  std::move(callback).Run(emoji_picker::mojom::Status::kHttpOk,
                          emoji_picker::mojom::TenorGifResponse::New(
                              next ? *next : "", ParseGifs(gifs)));
}

void GifTenorApiFetcher::FetchCategories(
    PageHandler::GetCategoriesCallback callback,
    const scoped_refptr<network::SharedURLLoaderFactory> url_loader_factory) {
  constexpr char kCategoriesApi[] = "/v2/categories";
  constexpr net::NetworkTrafficAnnotationTag kTrafficAnnotation =
      net::DefineNetworkTrafficAnnotation(
          "chromeos_emoji_picker_categories_fetcher",
          R"(
      semantics {
        sender: "ChromeOS Emoji Picker"
        description:
          "Gets GIF categories from the tenor API "
          "(https://developers.google.com/tenor)."
        trigger:
          "When a user opens the emoji picker and select the GIF section."
        data:
          "None, (authentication to this API is done through Chrome's API key)."
        destination: GOOGLE_OWNED_SERVICE
      }
      policy {
        cookies_allowed: NO
        setting:
          "No setting. The feature does nothing by default. Users must take "
          "an explicit action to trigger it."
        policy_exception_justification:
          "Not implemented, not considered useful. This request is part of a "
          "flow which is user-initiated, and is not a background request."
      }
  )");

  auto endpoint_fetcher = endpoint_fetcher_creator_.Run(
      url_loader_factory,
      net::AppendQueryParameter(GURL(kTenorBaseUrl).Resolve(kCategoriesApi),
                                kClientKeyName, kClientKeyValue),
      kTrafficAnnotation);
  auto* const endpoint_fetcher_ptr = endpoint_fetcher.get();
  endpoint_fetcher_ptr->PerformRequest(
      base::BindOnce(&GifTenorApiFetcher::FetchCategoriesResponseHandler,
                     weak_ptr_factory_.GetWeakPtr(), std::move(callback),
                     std::move(endpoint_fetcher)),
      nullptr);
}

void GifTenorApiFetcher::FetchCategoriesResponseHandler(
    PageHandler::GetCategoriesCallback callback,
    std::unique_ptr<EndpointFetcher> endpoint_fetcher,
    std::unique_ptr<EndpointResponse> response) {
  if (response->http_status_code == net::HTTP_OK) {
    data_decoder::DataDecoder::ParseJsonIsolated(
        response->response,
        base::BindOnce(&GifTenorApiFetcher::OnCategoriesJsonParsed,
                       weak_ptr_factory_.GetWeakPtr(), std::move(callback)));
    return;
  }
  std::move(callback).Run(GetError(std::move(response)),
                          std::vector<std::string>{});
}

void GifTenorApiFetcher::OnCategoriesJsonParsed(
    PageHandler::GetCategoriesCallback callback,
    data_decoder::DataDecoder::ValueOrError result) {
  const auto* tags = FindList(result, "tags");
  if (!tags) {
    std::move(callback).Run(emoji_picker::mojom::Status::kHttpError,
                            std::vector<std::string>{});
    return;
  }

  std::vector<std::string> categories;
  for (const auto& tag : *tags) {
    const auto* category = tag.GetIfDict();
    if (!category) {
      continue;
    }

    const auto* name = category->FindString("name");
    if (!name) {
      continue;
    }

    categories.push_back(*name);
  }

  std::move(callback).Run(emoji_picker::mojom::Status::kHttpOk,
                          std::move(categories));
}

void GifTenorApiFetcher::FetchFeaturedGifs(
    TenorGifsApiCallback callback,
    const scoped_refptr<network::SharedURLLoaderFactory> url_loader_factory,
    const std::optional<std::string>& pos) {
  constexpr char kFeaturedApi[] = "/v2/featured";
  constexpr net::NetworkTrafficAnnotationTag kTrafficAnnotation =
      net::DefineNetworkTrafficAnnotation(
          "chromeos_emoji_picker_featured_fetcher",
          R"(
      semantics {
        sender: "ChromeOS Emoji Picker"
        description:
          "Gets featured GIFs from the tenor API "
          "(https://developers.google.com/tenor)."
        trigger:
          "When a user opens the emoji picker and selects the GIF section, "
          "and the trending GIFs subcategory is active."
        data:
          "Position of the next batch of GIFs, used for infiniate scroll."
          "Authentication to this API is done through Chrome's API key."
        destination: GOOGLE_OWNED_SERVICE
      }
      policy {
        cookies_allowed: NO
        setting:
          "No setting. The feature does nothing by default. Users must take"
          "an explicit action to trigger it."
        policy_exception_justification:
          "Not implemented, not considered useful. This request is part of a "
          "flow which is user-initiated, and is not a background request."
      }
  )");

  auto endpoint_fetcher = endpoint_fetcher_creator_.Run(
      url_loader_factory, GetUrl(kFeaturedApi, pos), kTrafficAnnotation);
  auto* const endpoint_fetcher_ptr = endpoint_fetcher.get();
  endpoint_fetcher_ptr->PerformRequest(
      base::BindOnce(&GifTenorApiFetcher::TenorGifsApiResponseHandler,
                     weak_ptr_factory_.GetWeakPtr(), std::move(callback),
                     std::move(endpoint_fetcher)),
      nullptr);
}

void GifTenorApiFetcher::FetchGifSearch(
    TenorGifsApiCallback callback,
    const scoped_refptr<network::SharedURLLoaderFactory> url_loader_factory,
    const std::string& query,
    const std::optional<std::string>& pos,
    std::optional<int> limit) {
  GURL url = GetUrl(kSearchApi, pos);
  url = net::AppendQueryParameter(url, "q", query);
  if (limit.has_value()) {
    url = net::AppendQueryParameter(url, "limit", base::NumberToString(*limit));
  }

  auto endpoint_fetcher = endpoint_fetcher_creator_.Run(
      url_loader_factory, url, kSearchTrafficAnnotation);
  auto* const endpoint_fetcher_ptr = endpoint_fetcher.get();
  endpoint_fetcher_ptr->PerformRequest(
      base::BindOnce(&GifTenorApiFetcher::TenorGifsApiResponseHandler,
                     weak_ptr_factory_.GetWeakPtr(), std::move(callback),
                     std::move(endpoint_fetcher)),
      nullptr);
}

std::unique_ptr<EndpointFetcher> GifTenorApiFetcher::FetchGifSearchCancellable(
    TenorGifsApiCallback callback,
    const scoped_refptr<network::SharedURLLoaderFactory> url_loader_factory,
    std::string_view query,
    const std::optional<std::string>& pos,
    std::optional<int> limit) {
  GURL url = GetUrl(kSearchApi, pos);
  url = net::AppendQueryParameter(url, "q", query);
  if (limit.has_value()) {
    url = net::AppendQueryParameter(url, "limit", base::NumberToString(*limit));
  }

  std::unique_ptr<EndpointFetcher> endpoint_fetcher =
      endpoint_fetcher_creator_.Run(url_loader_factory, url,
                                    kSearchTrafficAnnotation);
  CHECK_DEREF(endpoint_fetcher.get())
      .PerformRequest(
          base::BindOnce(&GifTenorApiFetcher::TenorGifsApiResponseHandler,
                         weak_ptr_factory_.GetWeakPtr(), std::move(callback),
                         /*endpoint_fetcher=*/nullptr),
          nullptr);
  return endpoint_fetcher;
}

void GifTenorApiFetcher::FetchGifsByIds(
    PageHandler::GetGifsByIdsCallback callback,
    const scoped_refptr<network::SharedURLLoaderFactory> url_loader_factory,
    const std::vector<std::string>& ids) {
  constexpr char kPostsApi[] = "/v2/posts";
  constexpr net::NetworkTrafficAnnotationTag kTrafficAnnotation =
      net::DefineNetworkTrafficAnnotation("chromeos_emoji_picker_posts_fetcher",
                                          R"(
      semantics {
        sender: "ChromeOS Emoji Picker"
        description:
          "Gets a list of GIFs from the tenor API "
          "(https://developers.google.com/tenor) for the specified IDs."
        trigger:
          "When a user opens the emoji picker and selects the GIF section, "
          "and the recent GIFs subcategory is active."
        data:
          "The IDs of the GIFS saved in recent."
          "Authentication to this API is done through Chrome's API key."
        destination: GOOGLE_OWNED_SERVICE
      }
      policy {
        cookies_allowed: NO
        setting:
          "No setting. The feature does nothing by default. Users must take"
          "an explicit action to trigger it."
        policy_exception_justification:
          "Not implemented, not considered useful. This request is part of a "
          "flow which is user-initiated, and is not a background request."
      }
  )");

  auto endpoint_fetcher = endpoint_fetcher_creator_.Run(
      url_loader_factory,
      net::AppendQueryParameter(
          net::AppendQueryParameter(GURL(kTenorBaseUrl).Resolve(kPostsApi),
                                    kClientKeyName, kClientKeyValue),
          "ids", base::JoinString(ids, ",")),
      kTrafficAnnotation);
  auto* const endpoint_fetcher_ptr = endpoint_fetcher.get();
  endpoint_fetcher_ptr->PerformRequest(
      base::BindOnce(&GifTenorApiFetcher::FetchGifsByIdsResponseHandler,
                     weak_ptr_factory_.GetWeakPtr(), std::move(callback),
                     std::move(endpoint_fetcher)),
      nullptr);
}

void GifTenorApiFetcher::FetchGifsByIdsResponseHandler(
    emoji_picker::mojom::PageHandler::GetGifsByIdsCallback callback,
    std::unique_ptr<EndpointFetcher> endpoint_fetcher,
    std::unique_ptr<EndpointResponse> response) {
  if (response->http_status_code == net::HTTP_OK) {
    data_decoder::DataDecoder::ParseJsonIsolated(
        response->response,
        base::BindOnce(&GifTenorApiFetcher::OnGifsByIdsJsonParsed,
                       weak_ptr_factory_.GetWeakPtr(), std::move(callback)));
    return;
  }
  std::move(callback).Run(GetError(std::move(response)),
                          std::vector<emoji_picker::mojom::GifResponsePtr>{});
}

void GifTenorApiFetcher::OnGifsByIdsJsonParsed(
    emoji_picker::mojom::PageHandler::GetGifsByIdsCallback callback,
    data_decoder::DataDecoder::ValueOrError result) {
  const auto* gifs = FindList(result, "results");
  if (!gifs) {
    std::move(callback).Run(emoji_picker::mojom::Status::kHttpError,
                            std::vector<emoji_picker::mojom::GifResponsePtr>{});
    return;
  }

  std::move(callback).Run(emoji_picker::mojom::Status::kHttpOk,
                          ParseGifs(gifs));
}
}  // namespace ash