blob: b3a0698310360903dd67450d9446ed783fe850f9 [file] [log] [blame]
// Copyright 2025 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
#include "components/omnibox/browser/aim_eligibility_service.h"
#include <memory>
#include <string>
#include "base/base64.h"
#include "base/feature_list.h"
#include "base/functional/bind.h"
#include "base/memory/scoped_refptr.h"
#include "base/metrics/histogram_functions.h"
#include "base/strings/strcat.h"
#include "base/strings/string_util.h"
#include "components/omnibox/browser/aim_eligibility_service_observer.h"
#include "components/omnibox/browser/omnibox_prefs.h"
#include "components/omnibox/common/omnibox_feature_configs.h"
#include "components/prefs/pref_registry_simple.h"
#include "components/prefs/pref_service.h"
#include "components/search/search.h"
#include "components/search_engines/template_url_service.h"
#include "components/signin/public/identity_manager/accounts_in_cookie_jar_info.h"
#include "components/signin/public/identity_manager/identity_manager.h"
#include "google_apis/gaia/google_service_auth_error.h"
#include "net/base/load_flags.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 "third_party/omnibox_proto/aim_eligibility_response.pb.h"
#include "url/gurl.h"
BASE_FEATURE(kAimServerEligibilityEnabled,
"AimServerEligibilityEnabled",
base::FEATURE_DISABLED_BY_DEFAULT);
namespace {
// If disabled, AIM is completely turned off (kill switch).
BASE_FEATURE(kAimEnabled, "AimEnabled", base::FEATURE_ENABLED_BY_DEFAULT);
// If enabled, uses the server response for AIM eligibility for English locales.
// Has no effect if kAimServerEligibilityEnabled is enabled.
BASE_FEATURE(kAimServerEligibilityEnabledEn,
"AimServerEligibilityEnabledEn",
base::FEATURE_DISABLED_BY_DEFAULT);
// UMA histograms:
// Histogram for the eligibility request status.
static constexpr char kEligibilityRequestStatusHistogramName[] =
"Omnibox.AimEligibility.EligibilityRequestStatus";
// Histogram for the eligibility request response code.
static constexpr char kEligibilityRequestResponseCodeHistogramName[] =
"Omnibox.AimEligibility.EligibilityResponseCode";
// Histogram for the eligibility response source.
static constexpr char kEligibilityResponseSourceHistogramName[] =
"Omnibox.AimEligibility.EligibilityResponseSource";
// Histogram prefix for the eligibility response.
static constexpr char kEligibilityResponseHistogramPrefix[] =
"Omnibox.AimEligibility.EligibilityResponse";
// Histogram prefix for changes to the eligibility response.
static constexpr char kEligibilityResponseChangeHistogramPrefix[] =
"Omnibox.AimEligibility.EligibilityResponseChange";
static constexpr char kRequestPath[] = "/async/folae";
static constexpr char kRequestQuery[] = "async=_fmt:pb";
// The default value for the AIM policy pref; 0 = allowed, 1 = disallowed.
constexpr int kAIModeAllowedDefault = 0;
// The pref name used for storing the eligibility response proto.
constexpr char kResponsePrefName[] =
"aim_eligibility_service.aim_eligibility_response";
// Returns the request URL or an empty GURL if a valid URL cannot be created;
// e.g., Google is not the default search provider.
GURL GetRequestUrl(const TemplateURLService* template_url_service) {
if (!search::DefaultSearchProviderIsGoogle(template_url_service)) {
return GURL();
}
GURL base_gurl(
template_url_service->search_terms_data().GoogleBaseURLValue());
if (!base_gurl.is_valid()) {
return GURL();
}
GURL::Replacements replacements;
replacements.SetPathStr(kRequestPath);
replacements.SetQueryStr(kRequestQuery);
return base_gurl.ReplaceComponents(replacements);
}
const net::NetworkTrafficAnnotationTag kRequestTrafficAnnotation =
net::DefineNetworkTrafficAnnotation("aim_eligibility_fetch", R"(
semantics {
sender: "Chrome AI Mode Eligibility Service"
description:
"Retrieves the set of AI Mode features the client is eligible for "
"from the server."
trigger:
"Requests are made on startup, when user's profile state changes, "
"and periodically while Chrome is running."
user_data {
type: NONE
}
data:
"No request body is sent; this is a GET request with no query params."
destination: GOOGLE_OWNED_SERVICE
internal {
contacts { email: "chrome-desktop-search@google.com" }
}
last_reviewed: "2025-08-06"
}
policy {
cookies_allowed: YES
cookies_store: "user"
setting: "Coupled to Google default search."
policy_exception_justification:
"Not gated by policy. Setting AIModeSetting to '1' prevents the "
"response from being used. But Google Chrome still makes the "
"requests and saves the response to disk so that it's available when "
"the policy is unset."
})");
// Parses `response_string` into `response_proto`. Does not modify
// `response_proto` if parsing fails. Returns false on failure.
bool ParseResponseString(const std::string& response_string,
omnibox::AimEligibilityResponse* response_proto) {
omnibox::AimEligibilityResponse proto;
if (!proto.ParseFromString(response_string)) {
return false;
}
*response_proto = proto;
return true;
}
// Reads `kResponsePrefName` and parses it into `response_proto`. Does not
// modify `response_proto` if parsing fails. Returns false on failure.
bool GetResponseFromPrefs(const PrefService* prefs,
omnibox::AimEligibilityResponse* response_proto) {
std::string encoded_response = prefs->GetString(kResponsePrefName);
if (encoded_response.empty()) {
return false;
}
std::string response_string;
if (!base::Base64Decode(encoded_response, &response_string)) {
return false;
}
if (!ParseResponseString(response_string, response_proto)) {
return false;
}
return true;
}
} // namespace
// static
void AimEligibilityService::RegisterProfilePrefs(PrefRegistrySimple* registry) {
registry->RegisterStringPref(kResponsePrefName, "");
registry->RegisterIntegerPref(omnibox::kAIModeSettings,
kAIModeAllowedDefault);
}
bool AimEligibilityService::IsAimAllowedByPolicy(const PrefService* prefs) {
return prefs->GetInteger(omnibox::kAIModeSettings) == kAIModeAllowedDefault;
}
AimEligibilityService::AimEligibilityService(
PrefService& pref_service,
TemplateURLService* template_url_service,
scoped_refptr<network::SharedURLLoaderFactory> url_loader_factory,
signin::IdentityManager* identity_manager)
: pref_service_(pref_service),
template_url_service_(template_url_service),
url_loader_factory_(url_loader_factory),
identity_manager_(identity_manager) {
if (base::FeatureList::IsEnabled(kAimEnabled)) {
Initialize();
}
}
AimEligibilityService::~AimEligibilityService() = default;
bool AimEligibilityService::IsCountry(const std::string& country) const {
// Country codes are in lowercase ISO 3166-1 alpha-2 format; e.g., us, br, in.
// See components/variations/service/variations_service.h
return GetCountryCode() == country;
}
bool AimEligibilityService::IsLanguage(const std::string& language) const {
// Locale follows BCP 47 format; e.g., en-US, fr-FR, ja-JP.
// See ui/base/l10n/l10n_util.h
return base::StartsWith(GetLocale(), language, base::CompareCase::SENSITIVE);
}
void AimEligibilityService::AddObserver(
AimEligibilityServiceObserver* observer) {
observers_.AddObserver(observer);
}
void AimEligibilityService::RemoveObserver(
AimEligibilityServiceObserver* observer) {
observers_.RemoveObserver(observer);
}
bool AimEligibilityService::IsServerEligibilityEnabled() const {
return base::FeatureList::IsEnabled(kAimServerEligibilityEnabled) ||
(base::FeatureList::IsEnabled(kAimServerEligibilityEnabledEn) &&
IsLanguage("en"));
}
bool AimEligibilityService::IsAimLocallyEligible() const {
// Kill switch: If AIM is completely disabled, return false.
if (!base::FeatureList::IsEnabled(kAimEnabled)) {
return false;
}
// Always check Google DSE and Policy requirements.
if (!search::DefaultSearchProviderIsGoogle(template_url_service_) ||
!IsAimAllowedByPolicy(&pref_service_.get())) {
return false;
}
return true;
}
bool AimEligibilityService::IsAimEligible() const {
// Check local eligibility first.
if (!IsAimLocallyEligible()) {
return false;
}
// Conditionally check server response eligibility requirement.
if (IsServerEligibilityEnabled()) {
base::UmaHistogramEnumeration(kEligibilityResponseSourceHistogramName,
most_recent_response_source_);
return most_recent_response_.is_eligible();
}
return true;
}
bool AimEligibilityService::IsPdfUploadEligible() const {
if (!IsAimEligible()) {
return false;
}
if (IsServerEligibilityEnabled()) {
return most_recent_response_.is_pdf_upload_eligible();
}
return true;
}
// Private methods -------------------------------------------------------------
void AimEligibilityService::Initialize() {
// The service should not be initialized if AIM is disabled.
CHECK(base::FeatureList::IsEnabled(kAimEnabled));
// The service should not be initialized twice.
CHECK(!initialized_);
if (!template_url_service_) {
return;
}
if (!template_url_service_->loaded()) {
template_url_service_subscription_ =
template_url_service_->RegisterOnLoadedCallback(base::BindOnce(
&AimEligibilityService::Initialize, weak_factory_.GetWeakPtr()));
return;
}
initialized_ = true;
pref_change_registrar_.Init(&pref_service_.get());
pref_change_registrar_.Add(
kResponsePrefName,
base::BindRepeating(&AimEligibilityService::OnEligibilityResponseChanged,
weak_factory_.GetWeakPtr()));
LoadMostRecentResponse();
StartServerEligibilityRequest(RequestSource::kStartup);
if (identity_manager_) {
identity_manager_observation_.Observe(identity_manager_);
}
}
void AimEligibilityService::OnAccountsInCookieUpdated(
const signin::AccountsInCookieJarInfo& accounts_in_cookie_jar_info,
const GoogleServiceAuthError& error) {
// Change to the accounts in the cookie jar might affect AIM eligibility.
// Refresh the server eligibility state.
StartServerEligibilityRequest(RequestSource::kCookieChange);
}
void AimEligibilityService::OnEligibilityResponseChanged() {
CHECK(initialized_);
LogEligibilityResponseChange();
observers_.Notify(&AimEligibilityServiceObserver::OnAimEligibilityChanged);
}
void AimEligibilityService::UpdateMostRecentResponse(
const omnibox::AimEligibilityResponse& response_proto) {
CHECK(initialized_);
std::string response_string;
response_proto.SerializeToString(&response_string);
std::string encoded_response = base::Base64Encode(response_string);
pref_service_->SetString(kResponsePrefName, encoded_response);
most_recent_response_ = response_proto;
most_recent_response_source_ = EligibilityResponseSource::kServer;
}
void AimEligibilityService::LoadMostRecentResponse() {
CHECK(initialized_);
omnibox::AimEligibilityResponse prefs_response;
if (!GetResponseFromPrefs(&pref_service_.get(), &prefs_response)) {
return;
}
most_recent_response_ = prefs_response;
most_recent_response_source_ = EligibilityResponseSource::kPrefs;
}
void AimEligibilityService::StartServerEligibilityRequest(
RequestSource request_source) {
CHECK(initialized_);
// URLLoaderFactory may be null in tests.
if (!url_loader_factory_) {
return;
}
// Request URL may be invalid.
GURL request_url = GetRequestUrl(template_url_service_.get());
if (!request_url.is_valid()) {
return;
}
std::unique_ptr<network::ResourceRequest> request =
std::make_unique<network::ResourceRequest>();
request->url = request_url;
request->credentials_mode = network::mojom::CredentialsMode::kInclude;
request->load_flags = net::LOAD_DO_NOT_SAVE_COOKIES;
// Set the SiteForCookies to the request URL's site to avoid cookie blocking.
request->site_for_cookies = net::SiteForCookies::FromUrl(request->url);
std::unique_ptr<network::SimpleURLLoader> loader =
network::SimpleURLLoader::Create(std::move(request),
kRequestTrafficAnnotation);
LogEligibilityRequestStatus(EligibilityRequestStatus::kSent, request_source);
loader->DownloadToStringOfUnboundedSizeUntilCrashAndDie(
url_loader_factory_.get(),
base::BindOnce(&AimEligibilityService::OnServerEligibilityResponse,
weak_factory_.GetWeakPtr(), std::move(loader),
request_source));
}
void AimEligibilityService::OnServerEligibilityResponse(
std::unique_ptr<network::SimpleURLLoader> loader,
RequestSource request_source,
std::unique_ptr<std::string> response_string) {
CHECK(initialized_);
const int response_code =
loader->ResponseInfo() && loader->ResponseInfo()->headers
? loader->ResponseInfo()->headers->response_code()
: 0;
LogEligibilityRequestResponseCode(response_code, request_source);
if (response_code != 200 || !response_string) {
LogEligibilityRequestStatus(EligibilityRequestStatus::kErrorResponse,
request_source);
return;
}
omnibox::AimEligibilityResponse response_proto;
if (!ParseResponseString(*response_string, &response_proto)) {
LogEligibilityRequestStatus(EligibilityRequestStatus::kFailedToParse,
request_source);
return;
}
LogEligibilityRequestStatus(EligibilityRequestStatus::kSuccess,
request_source);
UpdateMostRecentResponse(response_proto);
LogEligibilityResponse(request_source);
}
std::string AimEligibilityService::GetHistogramNameSlicedByRequestSource(
const std::string& histogram_name,
RequestSource request_source) const {
auto request_source_suffix = [](RequestSource request_source) {
switch (request_source) {
case RequestSource::kStartup:
return ".Startup";
case RequestSource::kCookieChange:
return ".CookieChange";
}
return "";
};
return base::StrCat({histogram_name, request_source_suffix(request_source)});
}
void AimEligibilityService::LogEligibilityRequestStatus(
EligibilityRequestStatus status,
RequestSource request_source) const {
const auto& name = kEligibilityRequestStatusHistogramName;
const auto& sliced_name =
GetHistogramNameSlicedByRequestSource(name, request_source);
base::UmaHistogramEnumeration(name, status);
base::UmaHistogramEnumeration(sliced_name, status);
}
void AimEligibilityService::LogEligibilityRequestResponseCode(
int response_code,
RequestSource request_source) const {
const auto& name = kEligibilityRequestResponseCodeHistogramName;
const auto& sliced_name =
GetHistogramNameSlicedByRequestSource(name, request_source);
base::UmaHistogramSparse(name, response_code);
base::UmaHistogramSparse(sliced_name, response_code);
}
void AimEligibilityService::LogEligibilityResponse(
RequestSource request_source) const {
const auto& prefix = kEligibilityResponseHistogramPrefix;
const auto& sliced_prefix =
GetHistogramNameSlicedByRequestSource(prefix, request_source);
base::UmaHistogramBoolean(base::StrCat({prefix, ".is_eligible"}),
most_recent_response_.is_eligible());
base::UmaHistogramBoolean(base::StrCat({sliced_prefix, ".is_eligible"}),
most_recent_response_.is_eligible());
base::UmaHistogramBoolean(base::StrCat({prefix, ".is_pdf_upload_eligible"}),
most_recent_response_.is_pdf_upload_eligible());
base::UmaHistogramBoolean(
base::StrCat({sliced_prefix, ".is_pdf_upload_eligible"}),
most_recent_response_.is_pdf_upload_eligible());
}
void AimEligibilityService::LogEligibilityResponseChange() const {
// Prefs are updated before `most_recent_response_` is. Compare the prefs with
// the previous state of the server response and log changes to each field.
omnibox::AimEligibilityResponse prefs_response;
if (!GetResponseFromPrefs(&pref_service_.get(), &prefs_response)) {
return;
}
const auto& prefix = kEligibilityResponseChangeHistogramPrefix;
base::UmaHistogramBoolean(
base::StrCat({prefix, ".is_eligible"}),
most_recent_response_.is_eligible() != prefs_response.is_eligible());
base::UmaHistogramBoolean(base::StrCat({prefix, ".is_pdf_upload_eligible"}),
most_recent_response_.is_pdf_upload_eligible() !=
prefs_response.is_pdf_upload_eligible());
}