| // 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/string_util.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, 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>( |
| signin::OAuthConsumerId::kLauncherItemSuggest, identity_manager, |
| 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( |
| 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 |