chromium/chrome/browser/ash/app_list/search/arc/recommend_apps_fetcher_impl.cc

// Copyright 2021 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/arc/recommend_apps_fetcher_impl.h"

#include <cstdint>
#include <iomanip>
#include <string_view>

#include "base/base64url.h"
#include "base/json/json_reader.h"
#include "base/metrics/histogram_functions.h"
#include "base/metrics/histogram_macros.h"
#include "base/strings/string_split.h"
#include "base/strings/string_util.h"
#include "chrome/browser/ash/app_list/arc/arc_app_utils.h"
#include "chrome/browser/ash/app_list/search/arc/recommend_apps_fetcher_delegate.h"
#include "net/base/load_flags.h"
#include "net/http/http_status_code.h"
#include "net/traffic_annotation/network_traffic_annotation.h"
#include "services/network/public/cpp/resource_request.h"
#include "services/network/public/cpp/simple_url_loader.h"
#include "services/network/public/mojom/url_response_head.mojom.h"

namespace app_list {

namespace {

constexpr const char kGetAppListUrl[] =
    "https://android.clients.google.com/fdfe/chrome/"
    "getfastreinstallappslist?cfecu=false";

// These are error codes from the json response of the API.
constexpr int kResponseErrorNotEnoughApps = 5;
constexpr int kResponseErrorNotFirstTimeChromebookUser = 6;

// The response starts with a prefix ")]}'". This needs to be removed before
// further parsing.
constexpr std::string_view kJsonXssPreventionPrefix = ")]}'";

constexpr base::TimeDelta kDownloadTimeOut = base::Minutes(1);

constexpr const int64_t kMaxDownloadBytes = 1024 * 1024;  // 1Mb

// Convert a decimal number to a hexadecimal number. Return a string as result.
std::string HexEncode(int64_t id) {
  std::ostringstream string_stream;
  string_stream << std::setbase(16) << id;
  return string_stream.str();
}

void OnGetAndroidId(bool* get_android_id_successfully,
                    int64_t* android_id,
                    base::OnceCallback<void()> callback,
                    bool ok,
                    int64_t id) {
  // TODO(thanhdng): Add a UMA histogram here.
  *get_android_id_successfully = ok;
  *android_id = id;
  std::move(callback).Run();
}

}  // namespace

RecommendAppsFetcherImpl::RecommendAppsFetcherImpl(
    RecommendAppsFetcherDelegate* delegate,
    network::mojom::URLLoaderFactory* url_loader_factory)
    : delegate_(delegate), url_loader_factory_(url_loader_factory) {}

RecommendAppsFetcherImpl::~RecommendAppsFetcherImpl() = default;

void RecommendAppsFetcherImpl::StartDownload() {
  if (!get_android_id_successfully_) {
    // Retry getting android ID up to 5 times after giving up downloading.
    if (num_get_android_id_retry_ < 5) {
      num_get_android_id_retry_++;
      arc::GetAndroidId(base::BindOnce(
          &OnGetAndroidId, &get_android_id_successfully_, &android_id_,
          base::BindOnce(&RecommendAppsFetcherImpl::StartDownload,
                         weak_ptr_factory_.GetWeakPtr())));
    }
    return;
  }

  net::NetworkTrafficAnnotationTag traffic_annotation =
      net::DefineNetworkTrafficAnnotation("play_recommended_apps_reinstall", R"(
        semantics {
          sender: "ChromeOS Launcher"
          description:
            "ChromeOS launcher provides app reinstall suggestions for user."
            "This request downloads the suggestions' metadata using user's "
            "Android ID."
          trigger:
            "When user open the launcher."
          data:
            "URL of the Google Play API."
          destination: GOOGLE_OWNED_SERVICE
        }
        policy {
          cookies_allowed: YES
          setting:
            "NA"
          chrome_policy {
            SuggestedContentEnabled {
             SuggestedContentEnabled: true
            }
          }
        })");

  auto resource_request = std::make_unique<network::ResourceRequest>();
  resource_request->url = GURL(kGetAppListUrl);
  resource_request->method = "GET";
  resource_request->load_flags =
      net::LOAD_BYPASS_CACHE | net::LOAD_DISABLE_CACHE;

  resource_request->headers.SetHeader(net::HttpRequestHeaders::kContentType,
                                      "application/json");
  resource_request->headers.SetHeader("X-DFE-Device-Id",
                                      HexEncode(android_id_));

  start_time_ = base::TimeTicks::Now();
  app_list_loader_ = network::SimpleURLLoader::Create(
      std::move(resource_request), traffic_annotation);
  // Retry up to three times if network changes are detected during the
  // download.
  app_list_loader_->SetRetryOptions(
      3, network::SimpleURLLoader::RETRY_ON_NETWORK_CHANGE);
  app_list_loader_->DownloadToString(
      url_loader_factory_,
      base::BindOnce(&RecommendAppsFetcherImpl::OnDownloaded,
                     base::Unretained(this)),
      kMaxDownloadBytes);

  // Abort the download attempt if it takes longer than one minute.
  download_timer_.Start(FROM_HERE, kDownloadTimeOut, this,
                        &RecommendAppsFetcherImpl::OnDownloadTimeout);
}

void RecommendAppsFetcherImpl::OnDownloadTimeout() {
  // Destroy the fetcher, which will abort the download attempt.
  app_list_loader_.reset();

  // TODO(thanhdng): Add a UMA histogram here recording the time difference.
  delegate_->OnLoadError();
}

void RecommendAppsFetcherImpl::OnDownloaded(
    std::unique_ptr<std::string> response_body) {
  download_timer_.Stop();

  // TODO(thanhdng): Add a UMA histogram here recording the time difference.

  std::unique_ptr<network::SimpleURLLoader> loader(std::move(app_list_loader_));
  if (!loader->ResponseInfo() || !loader->ResponseInfo()->headers) {
    delegate_->OnLoadError();
    return;
  }
  // TODO(thanhndng): Add a UMA histogram here recording the response code.

  // If the recommended app list could not be downloaded, show an error message
  // to the user.
  if (!response_body || response_body->empty()) {
    delegate_->OnLoadError();
    return;
  }

  // If the recommended app list were downloaded successfully, show them to
  // the user.
  //
  std::string_view response_body_json(*response_body);
  if (base::StartsWith(response_body_json, kJsonXssPreventionPrefix))
    response_body_json.remove_prefix(kJsonXssPreventionPrefix.length());
  std::optional<base::Value> output = ParseResponse(response_body_json);
  if (!output.has_value()) {
    // TODO(thanhdng): Add a UMA histogram here.
    delegate_->OnParseResponseError();
    return;
  }

  delegate_->OnLoadSuccess(std::move(output.value()));
}

std::optional<base::Value> RecommendAppsFetcherImpl::ParseResponse(
    std::string_view response) {
  auto parsed_json = base::JSONReader::ReadAndReturnValueWithError(response);

  if (!parsed_json.has_value()) {
    LOG(ERROR) << "Error parsing response JSON: "
               << parsed_json.error().message;
    // TODO(thanhdng): Add a UMA histogram here.
    return std::nullopt;
  } else if (!parsed_json->is_list() && !parsed_json->is_dict()) {
    LOG(ERROR) << "Error parsing response JSON: Content malformed.";
    return std::nullopt;
  }

  // If the response is a dictionary, it is an error message in the
  // following format:
  //   {"Error code":"error code","Error message":"Error message"}
  if (parsed_json->is_dict()) {
    const std::string* response_error_code_str =
        parsed_json->GetDict().FindString("Error code");

    if (!response_error_code_str) {
      LOG(ERROR) << "Unable to find error code: response="
                 << response.substr(0, 128);
      // TODO(thanhdng): Add a UMA histogram here.
      return std::nullopt;
    }

    int response_error_code = 0;
    if (!base::StringToInt(*response_error_code_str, &response_error_code)) {
      LOG(WARNING) << "Unable to parse error code: "
                   << *response_error_code_str;
      // TODO(thanhdng): Add a UMA histogram here.
      return std::nullopt;
    }

    if (response_error_code == kResponseErrorNotFirstTimeChromebookUser) {
      NOTREACHED_IN_MIGRATION();
    } else if (response_error_code == kResponseErrorNotEnoughApps) {
      // TODO(thanhdng): Add a UMA histogram here.
    } else {
      LOG(WARNING) << "Unknown error code: " << *response_error_code_str;
      // TODO(thanhdng): Add a UMA histogram here.
    }

    return std::nullopt;
  }

  // Otherwise, the response should return a list of apps.
  const auto& app_list = parsed_json->GetList();
  if (app_list.empty()) {
    DVLOG(1) << "No app in the response.";
    // TODO(thanhdng): Add a UMA histogram here.
    return std::nullopt;
  }

  base::Value::List output;
  for (const auto& item : app_list) {
    base::Value::Dict output_map;

    const auto* dict = item.GetIfDict();
    if (!dict) {
      DVLOG(1) << "Cannot parse item.";
      continue;
    }

    // Retrieve the app title.
    const auto* title = dict->FindStringByDottedPath("title_.name_");
    if (title)
      output_map.Set("name", *title);

    // Retrieve the package name.
    const auto* package_name = dict->FindStringByDottedPath("id_.id_");
    if (package_name)
      output_map.Set("package_name", *package_name);

    // Retrieve the icon URL for the app.
    //
    // The name "privateDoNotAccessOrElseSafeUrlWrappedValue_" here is because
    // it is a direct serialization from the proto message. The value has been
    // sanitized so it is regarded as a safe URL. In general, if the response is
    // a protobuf, we should not directly access this field but use the wrapper
    // method getSafeUrlString() to read it. In our case, we don't have the
    // option other than access it directly.
    const auto* icon_url = dict->FindStringByDottedPath(
        "icon_.url_.privateDoNotAccessOrElseSafeUrlWrappedValue_");
    if (icon_url)
      output_map.Set("icon", *icon_url);

    if (output_map.empty()) {
      DVLOG(1) << "Invalid app item.";
      continue;
    }

    output.Append(std::move(output_map));
  }

  // TODO(thanhdng): Add a UMA histogram here to record the parse have completed
  // successfully.
  return base::Value(std::move(output));
}

}  // namespace app_list