chromium/ash/webui/os_feedback_ui/backend/help_content_provider.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 "ash/webui/os_feedback_ui/backend/help_content_provider.h"

#include <cstdint>
#include <memory>
#include <optional>

#include "ash/webui/os_feedback_ui/mojom/os_feedback_ui.mojom.h"
#include "base/containers/contains.h"
#include "base/functional/bind.h"
#include "base/json/json_writer.h"
#include "base/logging.h"
#include "base/strings/strcat.h"
#include "base/strings/string_number_conversions.h"
#include "base/strings/utf_string_conversions.h"
#include "base/values.h"
#include "content/public/browser/browser_context.h"
#include "content/public/browser/storage_partition.h"
#include "google_apis/google_api_keys.h"
#include "mojo/public/cpp/bindings/pending_receiver.h"
#include "mojo/public/cpp/bindings/receiver.h"
#include "services/data_decoder/public/cpp/data_decoder.h"
#include "services/network/public/cpp/resource_request.h"
#include "services/network/public/cpp/shared_url_loader_factory.h"
#include "services/network/public/cpp/simple_url_loader.h"
#include "services/network/public/mojom/url_response_head.mojom.h"
#include "url/gurl.h"

namespace ash {
namespace feedback {
namespace {

using ::ash::os_feedback_ui::mojom::HelpContent;
using ::ash::os_feedback_ui::mojom::HelpContentPtr;
using ::ash::os_feedback_ui::mojom::HelpContentType;
using ::ash::os_feedback_ui::mojom::SearchRequestPtr;
using ::ash::os_feedback_ui::mojom::SearchResponse;
using ::ash::os_feedback_ui::mojom::SearchResponsePtr;

constexpr char kHelpContentProviderUrl[] =
    "https://scone-pa.clients6.google.com/v1/search/list?key=";

constexpr char kGoogleSupportSiteUrl[] = "https://support.google.com";

// We need to drop items in different languages from the device language.
// Therefore, we request more items in search request in order to return
// requested max results to client. Although the search api may return other
// languages, it biases on the requested locale. The current design is to
// display top 5 items. Initial testing shows adding 10 more is sufficient.
constexpr int kExtraItemsInRawResponse = 10;

// Response with 5 items takes ~7KB. A loose upper bound of 64KB is chosen to
// avoid breaking the flow in case the response is longer.
//
// The current design is to request maximum 15 items. If requirement is changed
// to support significant larger max results, then this should be calculated
// dynamically.
constexpr int kMaxBodySize = 200 * 1024;

const net::NetworkTrafficAnnotationTag kTrafficAnnotation =
    net::DefineNetworkTrafficAnnotation("help_content_provider", R"(
        semantics {
          sender: "HelpContentProvider"
          description:
            "Users can press Alt+Shift+i to report a bug or a feedback in "
            "general. The CrOS feedback tool tries to search for help contents "
            "as the users are entering text. The results are displayed as "
            "suggested help contents."
          trigger:
            "When user enters text descriping the issue in CrOS Feedback Tool."
          data:
            "The free-form text that user has entered."
          destination: GOOGLE_OWNED_SERVICE
          internal {
            contacts {
              email: "[email protected]"
            }
          }
          user_data {
            type: ARBITRARY_DATA
          }
          last_reviewed: "2023-08-14"
        }
        policy {
          cookies_allowed: NO
          setting:
            "This feature cannot be disabled by settings and is only activated "
            "by direct user request."
          chrome_policy {
            UserFeedbackAllowed {
              UserFeedbackAllowed: false
            }
          }
        })");

std::unique_ptr<network::ResourceRequest> CreateResourceRequest() {
  auto resource_request = std::make_unique<network::ResourceRequest>();

  resource_request->url =
      GURL(base::StrCat({kHelpContentProviderUrl, google_apis::GetAPIKey()}));
  resource_request->credentials_mode = network::mojom::CredentialsMode::kOmit;
  resource_request->method = net::HttpRequestHeaders::kPostMethod;
  resource_request->headers.SetHeader(net::HttpRequestHeaders::kContentType,
                                      "application/json");

  return resource_request;
}

bool IsLoaderSuccessful(const network::SimpleURLLoader* loader) {
  DCHECK(loader);

  if (loader->NetError() != net::OK) {
    LOG(ERROR) << "HelpContentProvider url loader network error: "
               << loader->NetError();
    return false;
  }

  if (!loader->ResponseInfo() || !loader->ResponseInfo()->headers) {
    LOG(ERROR) << "HelpContentProvider invalid response or "
                  "missing headers";
    return false;
  }

  // Success response codes are 2xx.
  auto response_code = loader->ResponseInfo()->headers->response_code();
  if (response_code < 200 || response_code >= 300) {
    LOG(ERROR) << "HelpContentProvider non-successful response code: "
               << loader->ResponseInfo()->headers->response_code();
    return false;
  }
  return true;
}

//  Sample json string:
// [
//     {
//       "url":
//       "/chromebook/thread/110208459?hl=en-gb",
//       "title": "Bluetooth Headphones",
//       "snippet": "I have ...",
//       "resultType": "CT_SUPPORT_FORUM_THREAD",
//       ...
//     },
// ]
HelpContentPtr GetHelpContent(const base::Value::Dict& data) {
  HelpContentPtr help_content = HelpContent::New();

  const std::string* title = data.FindString("title");
  if (title != nullptr) {
    help_content->title = base::UTF8ToUTF16(*title);
  }

  const std::string* url = data.FindString("url");
  if (url != nullptr) {
    if (url->empty() || url->at(0) == '/') {
      // The url returned from search is relative or empty.
      help_content->url = GURL(base::StrCat({kGoogleSupportSiteUrl, *url}));
    } else {
      help_content->url = GURL(*url);
    }
  }

  const std::string* result_type = data.FindString("resultType");
  help_content->content_type = (result_type == nullptr)
                                   ? HelpContentType::kUnknown
                                   : ToHelpContentType(*result_type);

  return help_content;
}

// Extract the language part of the locale string by removing region part if
// any. Sample locale: en, en-us, en-gb.
std::string ExtractLanguage(const std::string& locale) {
  std::size_t pos = locale.find("-");
  return (pos == std::string::npos) ? locale : locale.substr(0, pos);
}

}  // namespace

std::string ConvertSearchRequestToJson(
    const std::string& app_locale,
    bool is_child_account,
    const os_feedback_ui::mojom::SearchRequestPtr& request) {
  base::Value::Dict request_dict;

  request_dict.Set("helpcenter", "chromeos");
  request_dict.Set("query", request->query);
  request_dict.Set("language", app_locale);

  auto requested_results = request->max_results + kExtraItemsInRawResponse;

  // We need to add more buffers for child account. So if lots of help content
  // for a search are community forum, we may still return a few articles.
  if (is_child_account) {
    requested_results += kExtraItemsInRawResponse;
  }
  request_dict.Set("max_results", base::NumberToString(requested_results));
  std::string request_content;
  base::JSONWriter::Write(request_dict, &request_content);
  VLOG(2) << request_content;
  return request_content;
}

// The result_type comes from the enum ContentType defined in file:
//  google3/customer_support/content/proto/support_content_enums.proto
HelpContentType ToHelpContentType(const std::string& result_type) {
  // TODO(xiangdongkong): Confirm the mappings.
  if (result_type == "CT_ANSWER") {
    return HelpContentType::kArticle;
  }

  if (base::Contains(result_type, "FORUM")) {
    return HelpContentType::kForum;
  }
  LOG(WARNING) << "HelpContentProvider unknown content type: " << result_type;
  return HelpContentType::kUnknown;
}

void PopulateSearchResponse(const std::string& app_locale,
                            bool is_child_account,
                            const uint32_t max_results,
                            const base::Value& search_result,
                            SearchResponsePtr& search_response) {
  if (!search_result.is_dict()) {
    LOG(WARNING)
        << "HelpContentProvider the response json is not a dictionary: "
        << search_result;
    return;
  }
  DVLOG(2) << "HelpContentProvider response body after safe parsed: "
           << search_result;
  const base::Value::Dict& dict = search_result.GetDict();

  // Extract totalResults.
  const std::string* total_results_str = dict.FindString("totalResults");
  int total_results = 0;
  if (total_results_str &&
      base::StringToInt(*total_results_str, &total_results)) {
    search_response->total_results = total_results;
  }

  // Extract resource.
  const base::Value::List* resources = dict.FindList("resource");
  if (resources == nullptr) {
    return;
  }

  const std::string expected_language = ExtractLanguage(app_locale);
  // Extract HelpContents.
  for (auto& resource : *resources) {
    if (search_response->results.size() == max_results) {
      break;
    }
    if (!resource.is_dict()) {
      continue;
    }
    const base::Value::Dict& res_dict = resource.GetDict();
    const std::string* lang_str = res_dict.FindString("language");
    // Take items in the same language (regardless of regions). If the account
    // is a child account, we only add articles to the result.
    if (lang_str && expected_language == ExtractLanguage(*lang_str)) {
      HelpContentPtr help_content = GetHelpContent(res_dict);
      const HelpContentType type = help_content->content_type;
      if (type == HelpContentType::kArticle || !is_child_account) {
        search_response->results.push_back(std::move(help_content));
      }
    }
  }
}

HelpContentProvider::HelpContentProvider(
    const std::string& app_locale,
    const bool is_child_account,
    content::BrowserContext* browser_context)
    : app_locale_(app_locale),
      is_child_account_(is_child_account),
      url_loader_factory_(browser_context->GetDefaultStoragePartition()
                              ->GetURLLoaderFactoryForBrowserProcess()) {}

HelpContentProvider::HelpContentProvider(
    const std::string& app_locale,
    const bool is_child_account,
    scoped_refptr<network::SharedURLLoaderFactory> url_loader_factory)
    : app_locale_(app_locale),
      is_child_account_(is_child_account),
      url_loader_factory_(url_loader_factory) {}

HelpContentProvider::~HelpContentProvider() = default;

void HelpContentProvider::GetHelpContents(
    os_feedback_ui::mojom::SearchRequestPtr request,
    GetHelpContentsCallback callback) {
  auto resource_request = CreateResourceRequest();

  std::unique_ptr<network::SimpleURLLoader> url_loader =
      network::SimpleURLLoader::Create(std::move(resource_request),
                                       kTrafficAnnotation);
  url_loader->AttachStringForUpload(
      ConvertSearchRequestToJson(app_locale_, is_child_account_, request),
      "application/json");

  auto* const url_loader_ptr = url_loader.get();
  url_loader_ptr->DownloadToString(
      url_loader_factory_.get(),
      base::BindOnce(&HelpContentProvider::OnHelpContentSearchResponse,
                     weak_ptr_factory_.GetWeakPtr(), request->max_results,
                     std::move(callback), std::move(url_loader)),
      kMaxBodySize);
}

void HelpContentProvider::BindInterface(
    mojo::PendingReceiver<os_feedback_ui::mojom::HelpContentProvider>
        receiver) {
  receiver_.reset();
  receiver_.Bind(std::move(receiver));
}

void HelpContentProvider::OnHelpContentSearchResponse(
    const uint32_t max_results,
    GetHelpContentsCallback callback,
    std::unique_ptr<network::SimpleURLLoader> url_loader,
    std::unique_ptr<std::string> response_body) {
  if (IsLoaderSuccessful(url_loader.get()) && response_body) {
    DVLOG(2) << "HelpContentProvider response body: " << *response_body;
    // Send the JSON string to a dedicated service for safe parsing.
    data_decoder_.ParseJson(
        *response_body,
        base::BindOnce(&HelpContentProvider::OnResponseJsonParsed,
                       weak_ptr_factory_.GetWeakPtr(), max_results,
                       std::move(callback)));
  } else {
    SearchResponsePtr response = SearchResponse::New();
    std::move(callback).Run(std::move(response));
  }
}

void HelpContentProvider::OnResponseJsonParsed(
    const uint32_t max_results,
    GetHelpContentsCallback callback,
    data_decoder::DataDecoder::ValueOrError result) {
  SearchResponsePtr response = SearchResponse::New();

  if (result.has_value()) {
    PopulateSearchResponse(app_locale_, is_child_account_, max_results, *result,
                           response);
  } else {
    LOG(ERROR)
        << "HelpContentProvider data decoder failed to parse json. Error: "
        << result.error();
  }

  std::move(callback).Run(std::move(response));
}

}  // namespace feedback
}  // namespace ash