// 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/file_suggest/item_suggest_cache.h"
#include <algorithm>
#include <string>
#include "ash/constants/ash_pref_names.h"
#include "ash/public/cpp/app_list/app_list_features.h"
#include "base/callback_list.h"
#include "base/functional/bind.h"
#include "base/metrics/field_trial_params.h"
#include "base/metrics/histogram_functions.h"
#include "base/strings/strcat.h"
#include "base/strings/utf_string_conversions.h"
#include "chrome/browser/profiles/profile.h"
#include "chrome/browser/signin/identity_manager_factory.h"
#include "components/drive/drive_pref_names.h"
#include "components/google/core/common/google_util.h"
#include "components/prefs/pref_service.h"
#include "components/signin/public/base/consent_level.h"
#include "components/signin/public/identity_manager/access_token_info.h"
#include "components/signin/public/identity_manager/account_info.h"
#include "components/signin/public/identity_manager/identity_manager.h"
#include "components/signin/public/identity_manager/scope_set.h"
#include "google_apis/gaia/gaia_constants.h"
#include "net/base/load_flags.h"
#include "net/base/net_errors.h"
#include "net/http/http_request_headers.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/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 {
// Maximum accepted size of an ItemSuggest response. 1MB.
constexpr int kMaxResponseSize = 1024 * 1024;
constexpr net::NetworkTrafficAnnotationTag kTrafficAnnotation =
net::DefineNetworkTrafficAnnotation("launcher_item_suggest", R"(
semantics {
sender: "Launcher suggested drive files"
description:
"The Chrome OS launcher requests suggestions for Drive files from "
"the Drive ItemSuggest API. These are displayed in the launcher."
trigger:
"Once on login after Drive FS is mounted. Afterwards, whenever the "
"Chrome OS launcher is opened."
data:
"OAuth2 access token."
destination: GOOGLE_OWNED_SERVICE
}
policy {
cookies_allowed: NO
setting:
"This cannot be disabled except by policy."
chrome_policy {
DriveDisabled {
DriveDisabled: true
}
}
})");
bool IsDisabledByPolicy(const Profile* profile) {
return profile->GetPrefs()->GetBoolean(drive::prefs::kDisableDrive);
}
base::Time GetLastRequestTime(Profile* profile) {
return profile->GetPrefs()->GetTime(
ash::prefs::kLauncherLastContinueRequestTime);
}
void SetLastRequestTime(Profile* profile, const base::Time& time) {
profile->GetPrefs()->SetTime(ash::prefs::kLauncherLastContinueRequestTime,
time);
}
//------------------
// Metrics utilities
//------------------
void LogStatus(ItemSuggestCache::Status status) {
base::UmaHistogramEnumeration("Apps.AppList.ItemSuggestCache.Status", status);
}
void LogResponseSize(const int size) {
base::UmaHistogramCounts100000("Apps.AppList.ItemSuggestCache.ResponseSize",
size);
}
void LogLatency(base::TimeDelta latency) {
base::UmaHistogramTimes("Apps.AppList.ItemSuggestCache.UpdateCacheLatency",
latency);
}
//----------------------
// JSON response parsing
//----------------------
std::optional<std::string> GetString(const base::Value::Dict& value,
const std::string& key) {
const auto* field = value.FindString(key);
if (!field) {
return std::nullopt;
}
return *field;
}
std::optional<ItemSuggestCache::Result> ConvertResult(
const base::Value* value) {
if (!value->is_dict()) {
return std::nullopt;
}
const auto& value_dict = value->GetDict();
// Get the item ID and display name.
const auto& item_id = GetString(value_dict, "itemId");
const auto& display_text = GetString(value_dict, "displayText");
if (!item_id || !display_text) {
return std::nullopt;
}
ItemSuggestCache::Result result(item_id.value(), display_text.value(),
/*prediction_reason=*/std::nullopt);
// Get the justification string. We allow this to be empty, so return the
// previously-created `result` on failure.
const auto* justification_dict = value_dict.FindDict("justification");
if (!justification_dict) {
return result;
}
// We use `unstructuredJustificationDescription` because justifications are
// displayed on one line, and `justificationDescription` is intended for
// multi-line formatting.
const auto* description =
justification_dict->FindDict("unstructuredJustificationDescription");
if (!description) {
return result;
}
// `unstructuredJustificationDescription` contains only one text segment by
// convention.
const auto* text_segments = description->FindList("textSegment");
if (!text_segments || text_segments->empty() ||
!(*text_segments)[0].is_dict()) {
return result;
}
const auto& text_segment = (*text_segments)[0].GetDict();
const auto justification = GetString(text_segment, "text");
if (!justification) {
return result;
}
result.prediction_reason = justification;
return result;
}
std::optional<ItemSuggestCache::Results> ConvertResults(
const base::Value* value) {
if (!value->is_dict()) {
return std::nullopt;
}
const auto& value_dict = value->GetDict();
const auto suggestion_id = GetString(value_dict, "suggestionSessionId");
if (!suggestion_id) {
return std::nullopt;
}
ItemSuggestCache::Results results(suggestion_id.value());
const auto* items = value_dict.FindList("item");
if (!items) {
// Return empty results if there are no items.
return results;
}
for (const auto& result_value : *items) {
auto result = ConvertResult(&result_value);
// If any result fails conversion, fail completely and return std::nullopt,
// rather than just skipping this result. This makes clear the distinction
// between a response format issue and the response containing no results.
if (!result) {
return std::nullopt;
}
results.results.push_back(std::move(result.value()));
}
return results;
}
} // namespace
BASE_FEATURE(kLauncherItemSuggest,
"LauncherItemSuggest",
base::FEATURE_DISABLED_BY_DEFAULT);
constexpr base::FeatureParam<bool> ItemSuggestCache::kEnabled;
constexpr base::FeatureParam<std::string> ItemSuggestCache::kServerUrl;
constexpr base::FeatureParam<std::string> ItemSuggestCache::kModelName;
constexpr base::FeatureParam<bool> ItemSuggestCache::kMultipleQueriesPerSession;
constexpr base::FeatureParam<int> ItemSuggestCache::kLongDelayMinutes;
ItemSuggestCache::Result::Result(
const std::string& id,
const std::string& title,
const std::optional<std::string>& prediction_reason)
: id(id), title(title), prediction_reason(prediction_reason) {}
ItemSuggestCache::Result::Result(const Result& other)
: id(other.id),
title(other.title),
prediction_reason(other.prediction_reason) {}
ItemSuggestCache::Result::~Result() = default;
ItemSuggestCache::Results::Results(const std::string& suggestion_id)
: suggestion_id(suggestion_id) {}
ItemSuggestCache::Results::Results(const Results& other)
: suggestion_id(other.suggestion_id), results(other.results) {}
ItemSuggestCache::Results::~Results() = default;
ItemSuggestCache::ItemSuggestCache(
const std::string& locale,
Profile* profile,
scoped_refptr<network::SharedURLLoaderFactory> url_loader_factory)
: made_request_(false),
enabled_(kEnabled.Get()),
server_url_(kServerUrl.Get()),
multiple_queries_per_session_(kMultipleQueriesPerSession.Get()),
locale_(locale),
profile_(profile),
url_loader_factory_(std::move(url_loader_factory)) {
DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_);
}
ItemSuggestCache::~ItemSuggestCache() = default;
base::CallbackListSubscription ItemSuggestCache::RegisterCallback(
ItemSuggestCache::OnResultsCallback callback) {
return on_results_callback_list_.Add(std::move(callback));
}
std::optional<ItemSuggestCache::Results> ItemSuggestCache::GetResults() {
// Return a copy because a pointer to |results_| will become invalid whenever
// the cache is updated.
return results_;
}
std::string ItemSuggestCache::GetRequestBody() {
// We request that ItemSuggest serve our request via particular model by
// specifying the model name in client_tags. This is a non-standard part of
// the API, implemented so we can experiment with model backends. The
// client_tags can be set via Finch based on what is expected by the
// ItemSuggest backend, and unexpected tags will be assigned a default model.
static constexpr char kRequestBody[] = R"({
"client_info": {
"platform_type": "CHROME_OS",
"scenario_type": "CHROME_OS_ZSS_FILES",
"language_code": "$1",
"request_type": "BACKGROUND_REQUEST",
"client_tags": {
"name": "$2"
}
},
"max_suggestions": 10,
"type_detail_fields": "drive_item.title,justification.display_text"
})";
const std::string& model = kModelName.Get();
return base::ReplaceStringPlaceholders(kRequestBody, {locale_, model},
nullptr);
}
base::TimeDelta ItemSuggestCache::GetDelay() {
bool use_long_delay = profile_->GetPrefs()->GetBoolean(
ash::prefs::kLauncherUseLongContinueDelay);
return base::Minutes(use_long_delay ? kLongDelayMinutes.Get()
: kShortDelayMinutes);
}
void ItemSuggestCache::MaybeUpdateCache() {
DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_);
update_start_time_ = base::TimeTicks::Now();
if (base::Time::Now() - GetLastRequestTime(profile_) < GetDelay()) {
return;
}
// Make no requests and exit in these cases:
// - Item suggest has been disabled via experiment.
// - Item suggest has been disabled by policy.
// - The server url is not https or not trusted by Google.
// - We've already made a request this session and we are not configured to
// query multiple times.
if (!enabled_) {
LogStatus(Status::kDisabledByExperiment);
return;
} else if (IsDisabledByPolicy(profile_)) {
LogStatus(Status::kDisabledByPolicy);
return;
} else if (!server_url_.SchemeIs(url::kHttpsScheme) ||
!google_util::IsGoogleAssociatedDomainUrl(server_url_)) {
LogStatus(Status::kInvalidServerUrl);
return;
} else if (made_request_ && !multiple_queries_per_session_) {
LogStatus(Status::kPostLaunchUpdateIgnored);
return;
}
signin::IdentityManager* identity_manager =
IdentityManagerFactory::GetForProfile(profile_);
if (!identity_manager) {
LogStatus(Status::kNoIdentityManager);
return;
}
// Fetch an OAuth2 access token.
token_fetcher_ = std::make_unique<signin::PrimaryAccountAccessTokenFetcher>(
"launcher_item_suggest", identity_manager,
signin::ScopeSet({GaiaConstants::kDriveReadOnlyOAuth2Scope}),
base::BindOnce(&ItemSuggestCache::OnTokenReceived,
weak_factory_.GetWeakPtr()),
signin::PrimaryAccountAccessTokenFetcher::Mode::kImmediate,
signin::ConsentLevel::kSync);
}
void ItemSuggestCache::UpdateCacheWithJsonForTest(
const std::string json_response) {
data_decoder::DataDecoder::ParseJsonIsolated(
json_response, base::BindOnce(&ItemSuggestCache::OnJsonParsed,
weak_factory_.GetWeakPtr()));
}
void ItemSuggestCache::OnTokenReceived(GoogleServiceAuthError error,
signin::AccessTokenInfo token_info) {
DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_);
token_fetcher_.reset();
if (error.state() != GoogleServiceAuthError::NONE) {
LogStatus(Status::kGoogleAuthError);
return;
}
// Make a new request. This destroys any existing |url_loader_| which will
// cancel that request if it is in-progress.
SetLastRequestTime(profile_, base::Time::Now());
made_request_ = true;
url_loader_ = MakeRequestLoader(token_info.token);
url_loader_->SetRetryOptions(0, network::SimpleURLLoader::RETRY_NEVER);
url_loader_->AttachStringForUpload(GetRequestBody(), "application/json");
// Perform the request.
url_loader_->DownloadToString(
url_loader_factory_.get(),
base::BindOnce(&ItemSuggestCache::OnSuggestionsReceived,
weak_factory_.GetWeakPtr()),
kMaxResponseSize);
}
void ItemSuggestCache::OnSuggestionsReceived(
const std::unique_ptr<std::string> json_response) {
DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_);
const int net_error = url_loader_->NetError();
if (net_error != net::OK) {
if (!url_loader_->ResponseInfo() || !url_loader_->ResponseInfo()->headers) {
if (net_error == net::ERR_INSUFFICIENT_RESOURCES) {
LogStatus(Status::kResponseTooLarge);
} else {
// Note that requests ending in kNetError don't count towards
// ItemSuggest QPS, but the last request time is still updated.
LogStatus(Status::kNetError);
}
} else {
const int status = url_loader_->ResponseInfo()->headers->response_code();
if (status >= 500) {
LogStatus(Status::k5xxStatus);
} else if (status >= 400) {
LogStatus(Status::k4xxStatus);
} else if (status >= 300) {
LogStatus(Status::k3xxStatus);
}
}
return;
} else if (!json_response || json_response->empty()) {
LogStatus(Status::kEmptyResponse);
return;
}
LogResponseSize(json_response->size());
// Parse the JSON response from ItemSuggest.
data_decoder::DataDecoder::ParseJsonIsolated(
*json_response, base::BindOnce(&ItemSuggestCache::OnJsonParsed,
weak_factory_.GetWeakPtr()));
}
void ItemSuggestCache::OnJsonParsed(
data_decoder::DataDecoder::ValueOrError result) {
DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_);
if (!result.has_value()) {
LogStatus(Status::kJsonParseFailure);
return;
}
// Convert the JSON value into a Results object. If the conversion fails, or
// if the conversion contains no results, we shouldn't update the stored
// results.
const auto& results = ConvertResults(&*result);
if (!results) {
LogStatus(Status::kJsonConversionFailure);
} else if (results->results.empty()) {
LogStatus(Status::kNoResultsInResponse);
if (!results_) {
// Make sure that |results_| is non-null to indicate that an update was
// successful.
results_ = std::move(results.value());
}
} else {
LogStatus(Status::kOk);
LogLatency(base::TimeTicks::Now() - update_start_time_);
results_ = std::move(results.value());
on_results_callback_list_.Notify();
}
}
std::unique_ptr<network::SimpleURLLoader> ItemSuggestCache::MakeRequestLoader(
const std::string& token) {
auto resource_request = std::make_unique<network::ResourceRequest>();
resource_request->method = "POST";
resource_request->url = server_url_;
// Do not allow cookies.
resource_request->credentials_mode = network::mojom::CredentialsMode::kOmit;
// Ignore the cache because we always want fresh results.
resource_request->load_flags =
net::LOAD_BYPASS_CACHE | net::LOAD_DISABLE_CACHE;
DCHECK(resource_request->url.is_valid());
resource_request->headers.SetHeader(net::HttpRequestHeaders::kContentType,
"application/json");
resource_request->headers.SetHeader(net::HttpRequestHeaders::kAuthorization,
"Bearer " + token);
return network::SimpleURLLoader::Create(std::move(resource_request),
kTrafficAnnotation);
}
// static
std::optional<ItemSuggestCache::Results> ItemSuggestCache::ConvertJsonForTest(
const base::Value* value) {
return ConvertResults(value);
}
} // namespace ash