| // Copyright 2023 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/compose/compose_enabling.h" |
| |
| #include <functional> |
| #include <memory> |
| #include <tuple> |
| #include <type_traits> |
| |
| #include "base/check.h" |
| #include "base/containers/flat_set.h" |
| #include "base/functional/callback.h" |
| #include "base/functional/callback_helpers.h" |
| #include "base/logging.h" |
| #include "base/no_destructor.h" |
| #include "base/rand_util.h" |
| #include "base/strings/string_util.h" |
| #include "chrome/browser/about_flags.h" |
| #include "chrome/browser/browser_process.h" |
| #include "chrome/browser/compose/proto/compose_optimization_guide.pb.h" |
| #include "chrome/browser/flag_descriptions.h" |
| #include "chrome/browser/optimization_guide/optimization_guide_keyed_service.h" |
| #include "chrome/browser/profiles/profile_manager.h" |
| #include "chrome/browser/signin/identity_manager_factory.h" |
| #include "chrome/common/pref_names.h" |
| #include "components/compose/buildflags.h" |
| #include "components/compose/core/browser/compose_features.h" |
| #include "components/compose/core/browser/compose_metrics.h" |
| #include "components/compose/core/browser/config.h" |
| #include "components/optimization_guide/core/model_execution/feature_keys.h" |
| #include "components/prefs/pref_service.h" |
| #include "components/variations/service/variations_service.h" |
| #include "components/variations/service/variations_service_utils.h" |
| #include "components/webui/flags/feature_entry.h" |
| #include "components/webui/flags/flags_storage.h" |
| #include "content/public/browser/context_menu_params.h" |
| #include "content/public/browser/render_frame_host.h" |
| #if BUILDFLAG(IS_CHROMEOS) |
| #include "chromeos/constants/chromeos_features.h" |
| #endif // BUILDFLAG(IS_CHROMEOS) |
| |
| namespace { |
| |
| bool AutocompleteAllowed(std::string_view autocomplete_attribute) { |
| // Check autocomplete is not turned off. |
| return autocomplete_attribute != std::string("off"); |
| } |
| |
| std::unique_ptr<std::string>& GetCountryCodeOverride() { |
| static base::NoDestructor<std::unique_ptr<std::string>> country_code_override( |
| nullptr); |
| return *country_code_override; |
| } |
| |
| std::string GetCountryCode() { |
| if (GetCountryCodeOverride()) { |
| return *GetCountryCodeOverride(); |
| } |
| std::string country_code = |
| base::ToLowerASCII(variations::GetCurrentCountryCode( |
| g_browser_process->variations_service())); |
| DLOG_IF(WARNING, country_code.empty()) << "Couldn't get country info."; |
| return country_code; |
| } |
| |
| // Given a set of countries checks if the current variations country is in the |
| // list. A list with a single item that is "*" will accept all countries. |
| // Return tuple: (current_country_code, enabled_for_country). |
| std::tuple<std::string, bool> IsEnabledForCountry( |
| const base::flat_set<std::string>& enabled_countries) { |
| std::string country_code = GetCountryCode(); |
| if (enabled_countries.size() == 1 && enabled_countries.contains("*")) { |
| return {country_code, true}; |
| } |
| return {country_code, enabled_countries.contains(country_code)}; |
| } |
| |
| } // namespace |
| |
| // Static members' initializers. |
| int ComposeEnabling::enabled_for_testing_{0}; |
| int ComposeEnabling::skip_user_check_for_testing_{0}; |
| |
| ComposeEnabling::ComposeEnabling( |
| Profile* profile, |
| signin::IdentityManager* identity_manager, |
| OptimizationGuideKeyedService* opt_guide) |
| : profile_(profile), |
| opt_guide_(opt_guide), |
| identity_manager_(identity_manager) { |
| DCHECK(profile_); |
| } |
| |
| ComposeEnabling::~ComposeEnabling() { |
| opt_guide_ = nullptr; |
| identity_manager_ = nullptr; |
| profile_ = nullptr; |
| } |
| |
| // Static. |
| ComposeEnabling::ScopedOverride |
| ComposeEnabling::ScopedEnableComposeForTesting() { |
| enabled_for_testing_++; |
| return std::make_unique<base::ScopedClosureRunner>(base::BindOnce( |
| [](int& enabled_for_testing) { |
| enabled_for_testing--; |
| DCHECK(enabled_for_testing >= 0); |
| }, |
| std::ref(enabled_for_testing_))); |
| } |
| |
| // Static. |
| ComposeEnabling::ScopedOverride |
| ComposeEnabling::ScopedSkipUserCheckForTesting() { |
| skip_user_check_for_testing_++; |
| return std::make_unique<base::ScopedClosureRunner>(base::BindOnce( |
| [](int& skip_user_check_for_testing) { |
| skip_user_check_for_testing--; |
| DCHECK(skip_user_check_for_testing >= 0); |
| }, |
| std::ref(skip_user_check_for_testing_))); |
| } |
| |
| // Static. |
| ComposeEnabling::ScopedOverride ComposeEnabling::OverrideCountryForTesting( |
| std::string country_code) { |
| CHECK(!GetCountryCodeOverride()); |
| GetCountryCodeOverride() = std::make_unique<std::string>(country_code); |
| return std::make_unique<base::ScopedClosureRunner>( |
| base::BindOnce([]() { GetCountryCodeOverride().reset(); })); |
| } |
| |
| compose::ComposeHintDecision ComposeEnabling::GetOptimizationGuidanceForUrl( |
| const GURL& url, |
| Profile* profile) { |
| |
| if (!opt_guide_) { |
| DVLOG(2) << "Optimization guide not found, returns unspecified"; |
| return compose::ComposeHintDecision::COMPOSE_HINT_DECISION_UNSPECIFIED; |
| } |
| |
| optimization_guide::OptimizationMetadata metadata; |
| |
| auto opt_guide_has_hint = opt_guide_->CanApplyOptimization( |
| url, optimization_guide::proto::OptimizationType::COMPOSE, &metadata); |
| if (opt_guide_has_hint != |
| optimization_guide::OptimizationGuideDecision::kTrue) { |
| DVLOG(2) << "Optimization guide has no hint, returns unspecified"; |
| return compose::ComposeHintDecision::COMPOSE_HINT_DECISION_UNSPECIFIED; |
| } |
| |
| std::optional<compose::ComposeHintMetadata> compose_metadata; |
| if (metadata.any_metadata().has_value()) { |
| compose_metadata = |
| optimization_guide::ParsedAnyMetadata<compose::ComposeHintMetadata>( |
| metadata.any_metadata().value()); |
| } |
| if (!compose_metadata.has_value()) { |
| DVLOG(2) << "Optimization guide has no metadata, returns unspecified"; |
| return compose::ComposeHintDecision::COMPOSE_HINT_DECISION_UNSPECIFIED; |
| } |
| |
| DVLOG(2) << "Optimization guide returns enum " |
| << static_cast<int>(compose_metadata->decision()); |
| return compose_metadata->decision(); |
| } |
| |
| // Member function public entry point. |
| base::expected<void, compose::ComposeShowStatus> ComposeEnabling::IsEnabled() { |
| return CheckEnabling(opt_guide_, identity_manager_); |
| } |
| |
| // Static public entry point. |
| bool ComposeEnabling::IsEnabledForProfile(Profile* profile) { |
| OptimizationGuideKeyedService* opt_guide = |
| OptimizationGuideKeyedServiceFactory::GetForProfile(profile); |
| signin::IdentityManager* identity_manager = |
| IdentityManagerFactory::GetForProfileIfExists(profile); |
| return CheckEnabling(opt_guide, identity_manager).has_value(); |
| } |
| |
| bool ComposeEnabling::IsSettingVisible(Profile* profile) { |
| OptimizationGuideKeyedService* opt_guide = |
| OptimizationGuideKeyedServiceFactory::GetForProfile(profile); |
| signin::IdentityManager* identity_manager = |
| IdentityManagerFactory::GetForProfileIfExists(profile); |
| auto enabled = CheckEnabling(opt_guide, identity_manager); |
| if (!enabled.has_value() && |
| enabled.error() == |
| compose::ComposeShowStatus::kUserNotAllowedByOptimizationGuide) { |
| return opt_guide->IsSettingVisible( |
| optimization_guide::UserVisibleFeatureKey::kCompose); |
| } |
| return enabled.has_value(); |
| } |
| |
| // Private static. |
| base::expected<void, compose::ComposeShowStatus> ComposeEnabling::CheckEnabling( |
| OptimizationGuideKeyedService* opt_guide, |
| signin::IdentityManager* identity_manager) { |
| if (enabled_for_testing_) { |
| DVLOG(2) << "enabled for testing"; |
| return base::ok(); |
| } |
| |
| if (identity_manager == nullptr || opt_guide == nullptr) { |
| DVLOG(2) << "feature not reachable, a required pointer is nullptr"; |
| return base::unexpected(compose::ComposeShowStatus::kGenericBlocked); |
| } |
| |
| // Check if the compose feature is still eligible. |
| if (!base::FeatureList::IsEnabled(compose::features::kComposeEligible)) { |
| DVLOG(2) << "feature not eligible"; |
| return base::unexpected(compose::ComposeShowStatus::kNotComposeEligible); |
| } |
| |
| // Check that the feature flag is enabled. |
| if (!base::FeatureList::IsEnabled(compose::features::kEnableCompose)) { |
| DVLOG(2) << "feature not enabled "; |
| return base::unexpected( |
| compose::ComposeShowStatus::kComposeFeatureFlagDisabled); |
| } |
| |
| // Check if we're running in an enabled country. Note that an empty country |
| // code will cause Compose to be disabled. |
| std::string country_code; |
| bool is_enabled_for_country; |
| std::tie(country_code, is_enabled_for_country) = |
| IsEnabledForCountry(compose::GetComposeConfig().enabled_countries); |
| if (!is_enabled_for_country) { |
| DVLOG(2) << "not running in an enabled country: \"" << country_code << "\""; |
| return base::unexpected( |
| country_code.empty() |
| ? compose::ComposeShowStatus::kUndefinedCountry |
| : compose::ComposeShowStatus::kComposeNotEnabledInCountry); |
| } |
| |
| // Check signin status. |
| CoreAccountInfo core_account_info = |
| identity_manager->GetPrimaryAccountInfo(signin::ConsentLevel::kSignin); |
| if (core_account_info.IsEmpty() || |
| identity_manager->HasAccountWithRefreshTokenInPersistentErrorState( |
| core_account_info.account_id)) { |
| DVLOG(2) << "user not signed in"; |
| return base::unexpected(compose::ComposeShowStatus::kSignedOut); |
| } |
| |
| // TODO(b/314199871): Remove test bypass once this check becomes mock-able. |
| if (!skip_user_check_for_testing_ && |
| (!opt_guide->ShouldFeatureBeCurrentlyEnabledForUser( |
| optimization_guide::UserVisibleFeatureKey::kCompose))) { |
| DVLOG(2) << "Feature not available for this user"; |
| return base::unexpected( |
| compose::ComposeShowStatus::kUserNotAllowedByOptimizationGuide); |
| } |
| |
| // For ChromeOS only, check whether this device is supported. |
| #if BUILDFLAG(IS_CHROMEOS) |
| if (chromeos::features::ShouldDisableChromeComposeOnChromeOS()) { |
| DVLOG(2) << "feature disabled on ChromeOS"; |
| return base::unexpected(compose::ComposeShowStatus::kDisabledOnChromeOS); |
| } |
| #endif // BUILDFLAG(IS_CHROMEOS) |
| |
| DVLOG(2) << "enabled"; |
| return base::ok(); |
| } |
| |
| base::expected<void, compose::ComposeShowStatus> |
| ComposeEnabling::ShouldTriggerNoStatePopup( |
| std::string_view autocomplete_attribute, |
| bool allows_writing_suggestions, |
| Profile* profile, |
| PrefService* prefs, |
| translate::TranslateManager* translate_manager, |
| const url::Origin& top_level_frame_origin, |
| const url::Origin& element_frame_origin, |
| GURL url, |
| bool is_msbb_enabled) { |
| // Check if we're running in a country where the no state popup is enabled. |
| // Note that an empty country code will block the no state popup. |
| std::string country_code; |
| bool is_enabled_for_country; |
| std::tie(country_code, is_enabled_for_country) = IsEnabledForCountry( |
| compose::GetComposeConfig().proactive_nudge_countries); |
| if (!is_enabled_for_country) { |
| DVLOG(2) << "not running in an enabled country: \"" << country_code << "\""; |
| return base::unexpected( |
| country_code.empty() |
| ? compose::ComposeShowStatus::kUndefinedCountry |
| : compose::ComposeShowStatus::kComposeNotEnabledInCountry); |
| } |
| |
| // TODO(b/319661274): Support fenced frame checks from the Autofill popup |
| // entry point. |
| bool is_in_fenced_frame = false; |
| if (auto page_checks = |
| PageLevelChecks(translate_manager, url, top_level_frame_origin, |
| element_frame_origin, is_in_fenced_frame); |
| !page_checks.has_value()) { |
| return base::unexpected(page_checks.error()); |
| } |
| |
| // The no state popup should not show for unsupported languages even if the |
| // language bypass feature is enabled. |
| if (!IsPageLanguageSupported(translate_manager)) { |
| DVLOG(2) << "language not supported"; |
| return base::unexpected(compose::ComposeShowStatus::kUnsupportedLanguage); |
| } |
| |
| if (!is_msbb_enabled) { |
| return base::unexpected( |
| compose::ComposeShowStatus::kProactiveNudgeDisabledByMSBB); |
| } |
| |
| // Check URL with Optimization guide. |
| switch (GetOptimizationGuidanceForUrl(url, profile)) { |
| case compose::ComposeHintDecision::COMPOSE_HINT_DECISION_COMPOSE_DISABLED: |
| return base::unexpected(compose::ComposeShowStatus::kPerUrlChecksFailed); |
| case compose::ComposeHintDecision::COMPOSE_HINT_DECISION_DISABLE_NUDGE: |
| if (!compose::GetComposeConfig() |
| .proactive_nudge_bypass_optimization_guide) { |
| return base::unexpected( |
| compose::ComposeShowStatus::kProactiveNudgeDisabledByServerConfig); |
| } |
| break; |
| case compose::ComposeHintDecision::COMPOSE_HINT_DECISION_UNSPECIFIED: |
| if (!base::FeatureList::IsEnabled( |
| compose::features::kEnableNudgeForUnspecifiedHint)) { |
| return base::unexpected( |
| compose::ComposeShowStatus::kProactiveNudgeUnknownServerConfig); |
| } |
| break; |
| case compose::ComposeHintDecision::COMPOSE_HINT_DECISION_ENABLED: |
| break; |
| } |
| |
| // Check autocomplete attribute if the proactive nudge would be presented. |
| // TODO(b/303288183): Decide if we should keep this check or not. |
| if (!AutocompleteAllowed(autocomplete_attribute)) { |
| DVLOG(2) << "autocomplete=off"; |
| return base::unexpected(compose::ComposeShowStatus::kAutocompleteOff); |
| } |
| |
| if (!allows_writing_suggestions) { |
| DVLOG(2) << "writingsuggestions=false"; |
| return base::unexpected( |
| compose::ComposeShowStatus::kWritingSuggestionsFalse); |
| } |
| |
| if (!prefs->GetBoolean(prefs::kEnableProactiveNudge)) { |
| return base::unexpected( |
| compose::ComposeShowStatus:: |
| kProactiveNudgeDisabledGloballyByUserPreference); |
| } |
| |
| if (prefs->GetDict(prefs::kProactiveNudgeDisabledSitesWithTime) |
| .Find(element_frame_origin.Serialize())) { |
| return base::unexpected(compose::ComposeShowStatus:: |
| kProactiveNudgeDisabledForSiteByUserPreference); |
| } |
| |
| if (!compose::GetComposeConfig().proactive_nudge_enabled) { |
| return base::unexpected( |
| compose::ComposeShowStatus::kProactiveNudgeFeatureDisabled); |
| } |
| |
| return base::ok(); |
| } |
| |
| bool ComposeEnabling::ShouldTriggerSavedStatePopup( |
| autofill::AutofillSuggestionTriggerSource trigger_source) { |
| // No need to preform field and page level checks since there is already saved |
| // state. Only check config and features. |
| |
| if (!compose::GetComposeConfig().saved_state_nudge_enabled) { |
| return false; |
| } |
| |
| if (trigger_source == |
| autofill::AutofillSuggestionTriggerSource::kComposeDialogLostFocus && |
| !base::FeatureList::IsEnabled( |
| compose::features::kEnableComposeSavedStateNotification)) { |
| return false; |
| } |
| |
| return true; |
| } |
| |
| bool ComposeEnabling::ShouldTriggerContextMenu( |
| Profile* profile, |
| translate::TranslateManager* translate_manager, |
| content::RenderFrameHost* rfh, |
| content::ContextMenuParams& params) { |
| // Make sure the underlying field is one the feature works for. |
| if (!(params.is_content_editable_for_autofill || |
| (params.form_control_type && |
| *params.form_control_type == |
| blink::mojom::FormControlType::kTextArea))) { |
| compose::LogComposeContextMenuShowStatus( |
| compose::ComposeShowStatus::kIncompatibleFieldType); |
| DVLOG(2) << "not a supported text field"; |
| return false; |
| } |
| |
| // Get the page URL of the outermost frame. |
| GURL url = rfh->GetMainFrame()->GetLastCommittedURL(); |
| |
| // Check URL with the optimization guide. |
| compose::ComposeHintDecision decision = |
| GetOptimizationGuidanceForUrl(url, profile); |
| if (decision == |
| compose::ComposeHintDecision::COMPOSE_HINT_DECISION_COMPOSE_DISABLED) { |
| compose::LogComposeContextMenuShowStatus( |
| compose::ComposeShowStatus::kPerUrlChecksFailed); |
| DVLOG(2) << "disabled for the main frame URL"; |
| return false; |
| } |
| |
| auto show_status = PageLevelChecks( |
| translate_manager, url, rfh->GetMainFrame()->GetLastCommittedOrigin(), |
| params.frame_origin, rfh->IsNestedWithinFencedFrame()); |
| if (!show_status.has_value()) { |
| compose::LogComposeContextMenuShowStatus(show_status.error()); |
| DVLOG(2) << "page level checks failed"; |
| return false; |
| } |
| |
| if (!base::FeatureList::IsEnabled( |
| compose::features::kEnableComposeLanguageBypassForContextMenu) && |
| !IsPageLanguageSupported(translate_manager)) { |
| DVLOG(2) << "language not supported"; |
| compose::LogComposeContextMenuShowStatus( |
| compose::ComposeShowStatus::kUnsupportedLanguage); |
| return false; |
| } |
| |
| compose::LogComposeContextMenuShowStatus( |
| compose::ComposeShowStatus::kShouldShow); |
| return true; |
| } |
| |
| base::expected<void, compose::ComposeShowStatus> |
| ComposeEnabling::PageLevelChecks(translate::TranslateManager* translate_manager, |
| GURL url, |
| const url::Origin& top_level_frame_origin, |
| const url::Origin& element_frame_origin, |
| bool is_nested_within_fenced_frame) { |
| if (auto profile_show_status = IsEnabled(); |
| !profile_show_status.has_value()) { |
| DVLOG(2) << "not enabled"; |
| return profile_show_status; |
| } |
| |
| if (!url.SchemeIsHTTPOrHTTPS()) { |
| DVLOG(2) << "incorrect scheme"; |
| return base::unexpected(compose::ComposeShowStatus::kIncorrectScheme); |
| } |
| |
| if (is_nested_within_fenced_frame) { |
| DVLOG(2) << "field nested within fenced frame not supported"; |
| return base::unexpected( |
| compose::ComposeShowStatus::kFormFieldNestedInFencedFrame); |
| } |
| |
| // Note: This does not check frames between the current and the top level |
| // frame. Because all our metadata for compose is either based on the origin |
| // of the top level frame or actually part of the top level frame, this is |
| // sufficient for now. TODO(b/309162238) follow up on whether this is |
| // sufficient long-term. |
| if (top_level_frame_origin != element_frame_origin) { |
| DVLOG(2) << "cross frame origin not supported"; |
| return base::unexpected( |
| compose::ComposeShowStatus::kFormFieldInCrossOriginFrame); |
| } |
| |
| return base::ok(); |
| } |
| |
| bool ComposeEnabling::IsPageLanguageSupported( |
| translate::TranslateManager* translate_manager) { |
| std::string page_language = |
| translate_manager |
| ? translate_manager->GetLanguageState()->source_language() |
| : ""; |
| |
| // TODO(b/307814938): Make this finch configurable. |
| // Only English is supported for MVP, we will add more languages over time. |
| // We accept the empty string which might be returned if the translate system |
| // has not yet deterimed the language, and "und" which means translate |
| // couldn't find an answer. |
| return (page_language == "en" || page_language == "und" || |
| page_language.empty()); |
| } |