blob: 213cfe64dbde3683b7b62db5d6d60ee1919e3212 [file] [log] [blame]
// Copyright 2020 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
package org.chromium.chrome.browser.omnibox.voice;
import static org.chromium.chrome.browser.preferences.ChromePreferenceKeys.ASSISTANT_VOICE_SEARCH_ENABLED;
import android.accounts.Account;
import android.content.Context;
import android.content.Intent;
import android.content.res.ColorStateList;
import android.graphics.drawable.Drawable;
import android.text.TextUtils;
import androidx.annotation.ColorInt;
import androidx.annotation.IntDef;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import androidx.appcompat.content.res.AppCompatResources;
import org.chromium.base.Promise;
import org.chromium.base.SysUtils;
import org.chromium.base.metrics.RecordHistogram;
import org.chromium.base.task.AsyncTask;
import org.chromium.chrome.R;
import org.chromium.chrome.browser.flags.ChromeFeatureList;
import org.chromium.chrome.browser.gsa.GSAState;
import org.chromium.chrome.browser.preferences.SharedPreferencesManager;
import org.chromium.chrome.browser.profiles.Profile;
import org.chromium.chrome.browser.search_engines.TemplateUrlServiceFactory;
import org.chromium.chrome.browser.signin.services.IdentityServicesProvider;
import org.chromium.components.browser_ui.styles.ChromeColors;
import org.chromium.components.externalauth.ExternalAuthUtils;
import org.chromium.components.search_engines.TemplateUrlService;
import org.chromium.components.signin.AccountManagerFacade;
import org.chromium.components.signin.AccountManagerFacadeProvider;
import org.chromium.components.signin.AccountsChangeObserver;
import org.chromium.components.signin.base.CoreAccountInfo;
import org.chromium.components.signin.identitymanager.ConsentLevel;
import org.chromium.components.signin.identitymanager.IdentityManager;
import org.chromium.components.signin.identitymanager.PrimaryAccountChangeEvent;
import org.chromium.ui.util.ColorUtils;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.util.ArrayList;
import java.util.List;
/**
* Service for state tracking and event delivery to classes that need to observe the state
* of Assistant Voice Search.
**/
public class AssistantVoiceSearchService implements TemplateUrlService.TemplateUrlServiceObserver,
IdentityManager.Observer,
AccountsChangeObserver {
@VisibleForTesting
static final String STARTUP_HISTOGRAM_SUFFIX = ".Startup";
@VisibleForTesting
static final String USER_ELIGIBILITY_HISTOGRAM = "Assistant.VoiceSearch.UserEligibility";
@VisibleForTesting
static final String USER_ELIGIBILITY_FAILURE_REASON_HISTOGRAM =
"Assistant.VoiceSearch.UserEligibility.FailureReason";
@VisibleForTesting
static final String AGSA_VERSION_HISTOGRAM = "Assistant.VoiceSearch.AgsaVersion";
private static final String DEFAULT_ASSISTANT_AGSA_MIN_VERSION = "11.7";
private static final boolean DEFAULT_ASSISTANT_COLORFUL_MIC_ENABLED = false;
// These values are persisted to logs. Entries should not be renumbered and
// numeric values should never be reused.
@IntDef({EligibilityFailureReason.AGSA_CANT_HANDLE_INTENT,
EligibilityFailureReason.AGSA_VERSION_BELOW_MINIMUM,
EligibilityFailureReason.CHROME_NOT_GOOGLE_SIGNED,
EligibilityFailureReason.AGSA_NOT_GOOGLE_SIGNED,
EligibilityFailureReason.NON_GOOGLE_SEARCH_ENGINE,
EligibilityFailureReason.NO_CHROME_ACCOUNT, EligibilityFailureReason.LOW_END_DEVICE,
EligibilityFailureReason.MULTIPLE_ACCOUNTS_ON_DEVICE,
EligibilityFailureReason.AGSA_NOT_INSTALLED})
@Retention(RetentionPolicy.SOURCE)
@interface EligibilityFailureReason {
int AGSA_CANT_HANDLE_INTENT = 0;
int AGSA_VERSION_BELOW_MINIMUM = 1;
// No longer used, replaced by Chrome-side eligibility checks.
// int AGSA_DOESNT_SUPPORT_VOICE_SEARCH_CHECK_NOT_COMPLETE = 2;
// No longer used, replaced by Chrome-side eligibility checks.
// int AGSA_DOESNT_SUPPORT_VOICE_SEARCH = 3;
int CHROME_NOT_GOOGLE_SIGNED = 4;
int AGSA_NOT_GOOGLE_SIGNED = 5;
// No longer used, AGSA now supports attribution to arbitrary accounts.
// int ACCOUNT_MISMATCH = 6;
int NON_GOOGLE_SEARCH_ENGINE = 7;
int NO_CHROME_ACCOUNT = 8;
int LOW_END_DEVICE = 9;
int MULTIPLE_ACCOUNTS_ON_DEVICE = 10;
int AGSA_NOT_INSTALLED = 11;
// STOP: When updating this, also update values in enums.xml and make sure to update the
// IntDef above.
int NUM_ENTRIES = 12;
}
/** Allows outside classes to listen for changes in this service. */
public interface Observer {
/**
* Called when the service changes, use relevant getters to update your state. This
* indicates that the state of AssistantVoiceSearchService has changed and you should
* re-query the getters you're interested in.
* - The microphone Drawable icon can change during runtime.
**/
void onAssistantVoiceSearchServiceChanged();
}
// TODO(wylieb): Convert this to an ObserverList and add #addObserver, #removeObserver.
private final Observer mObserver;
private final Context mContext;
private final ExternalAuthUtils mExternalAuthUtils;
private final TemplateUrlService mTemplateUrlService;
private final GSAState mGsaState;
private final SharedPreferencesManager mSharedPrefsManager;
private final IdentityManager mIdentityManager;
private final AccountManagerFacade mAccountManagerFacade;
private boolean mIsDefaultSearchEngineGoogle;
private boolean mIsAssistantVoiceSearchEnabled;
private boolean mIsColorfulMicEnabled;
private boolean mShouldShowColorfulMic;
private boolean mIsMultiAccountCheckEnabled;
private String mMinAgsaVersion;
public AssistantVoiceSearchService(@NonNull Context context,
@NonNull ExternalAuthUtils externalAuthUtils,
@NonNull TemplateUrlService templateUrlService, @NonNull GSAState gsaState,
@Nullable Observer observer, @NonNull SharedPreferencesManager sharedPrefsManager,
@NonNull IdentityManager identityManager,
@NonNull AccountManagerFacade accountManagerFacade) {
mContext = context;
mExternalAuthUtils = externalAuthUtils;
mTemplateUrlService = templateUrlService;
mGsaState = gsaState;
mSharedPrefsManager = sharedPrefsManager;
mObserver = observer;
mIdentityManager = identityManager;
mAccountManagerFacade = accountManagerFacade;
mAccountManagerFacade.addObserver(this);
mIdentityManager.addObserver(this);
mTemplateUrlService.addObserver(this);
initializeAssistantVoiceSearchState();
}
public void destroy() {
mTemplateUrlService.removeObserver(this);
mIdentityManager.removeObserver(this);
mAccountManagerFacade.removeObserver(this);
}
private void notifyObserver() {
if (mObserver == null) return;
mObserver.onAssistantVoiceSearchServiceChanged();
}
/** Cache Assistant voice search variable state. */
@VisibleForTesting
void initializeAssistantVoiceSearchState() {
mIsAssistantVoiceSearchEnabled =
ChromeFeatureList.isEnabled(ChromeFeatureList.OMNIBOX_ASSISTANT_VOICE_SEARCH);
mIsColorfulMicEnabled = ChromeFeatureList.getFieldTrialParamByFeatureAsBoolean(
ChromeFeatureList.OMNIBOX_ASSISTANT_VOICE_SEARCH, "colorful_mic",
DEFAULT_ASSISTANT_COLORFUL_MIC_ENABLED);
mMinAgsaVersion = ChromeFeatureList.getFieldTrialParamByFeature(
ChromeFeatureList.OMNIBOX_ASSISTANT_VOICE_SEARCH, "min_agsa_version");
mIsMultiAccountCheckEnabled = ChromeFeatureList.getFieldTrialParamByFeatureAsBoolean(
ChromeFeatureList.OMNIBOX_ASSISTANT_VOICE_SEARCH, "enable_multi_account_check",
/* default= */ true);
mIsDefaultSearchEngineGoogle = mTemplateUrlService.isDefaultSearchEngineGoogle();
mShouldShowColorfulMic = isColorfulMicEnabled();
}
/** @return Whether the user has had a chance to enable the feature. */
public boolean needsEnabledCheck() {
return !mSharedPrefsManager.contains(ASSISTANT_VOICE_SEARCH_ENABLED);
}
/**
* Checks if the client is eligible Assistant for voice search. It's
* {@link canRequestAssistantVoiceSearch} with additional conditions:
* - The feature must be enabled.
* - The consent flow must be accepted.
*/
public boolean shouldRequestAssistantVoiceSearch() {
return mIsAssistantVoiceSearchEnabled && canRequestAssistantVoiceSearch()
&& isEnabledByPreference();
}
/**
* Checks if the client meets the device requirements to use Assistant for voice search. This
* doesn't check if the client should use Assistant voice search, which accounts for additional
* conditions.
*/
public boolean canRequestAssistantVoiceSearch() {
return mIsAssistantVoiceSearchEnabled
&& isDeviceEligibleForAssistant(/* returnImmediately= */ true, /* outList= */ null);
}
/**
* @return Gets the current mic drawable, this will create the drawable if it doesn't already
* exist or reuse the existing Drawable's ConstantState if it does already exist.
**/
public Drawable getCurrentMicDrawable() {
return AppCompatResources.getDrawable(
mContext, mShouldShowColorfulMic ? R.drawable.ic_colorful_mic : R.drawable.btn_mic);
}
/** @return The correct ColorStateList for the current theme. */
public @Nullable ColorStateList getMicButtonColorStateList(
@ColorInt int primaryColor, Context context) {
if (mShouldShowColorfulMic) return null;
final boolean useLightColors =
ColorUtils.shouldUseLightForegroundOnBackground(primaryColor);
int id = ChromeColors.getPrimaryIconTintRes(useLightColors);
return AppCompatResources.getColorStateList(context, id);
}
/** Called from {@link VoiceRecognitionHandler} after the consent flow has completed. */
public void onAssistantConsentDialogComplete(boolean useAssistant) {
if (useAssistant) updateColorfulMicState();
}
/**
* @return Whether the user has enabled the feature, ensure {@link needsEnabledCheck} is
* called first.
*/
private boolean isEnabledByPreference() {
return mSharedPrefsManager.readBoolean(
ASSISTANT_VOICE_SEARCH_ENABLED, /* default= */ false);
}
/** @return Whether the colorful mic is enabled. */
private boolean isColorfulMicEnabled() {
return mContext.getPackageManager() != null && mIsColorfulMicEnabled
&& shouldRequestAssistantVoiceSearch();
}
/**
* @return The min Agsa version required for assistant voice search. This method checks the
* chrome feature list for this value before using the default.
*/
private String getAgsaMinVersion() {
if (TextUtils.isEmpty(mMinAgsaVersion)) return DEFAULT_ASSISTANT_AGSA_MIN_VERSION;
return mMinAgsaVersion;
}
/**
* Checks all Assistant eligibility conditions. Optionally assess all failure conditions.
* @param returnImmediately Whether to return as soon as the client is ineligible.
* @param outList A list that the failure reasons will be added to, when returnImmediately is
* false. This list will not be altered when returnImmediately is true. Must be
* non-null if returnImmediately is false.
* @return Whether the device is eligible for Assistant.
*/
@VisibleForTesting
protected boolean isDeviceEligibleForAssistant(
boolean returnImmediately, List<Integer> outList) {
assert returnImmediately || outList != null;
if (!mGsaState.isGsaInstalled()) {
if (returnImmediately) return false;
outList.add(EligibilityFailureReason.AGSA_NOT_INSTALLED);
}
if (!mGsaState.canAgsaHandleIntent(getAssistantVoiceSearchIntent())) {
if (returnImmediately) return false;
outList.add(EligibilityFailureReason.AGSA_CANT_HANDLE_INTENT);
}
if (mGsaState.isAgsaVersionBelowMinimum(
mGsaState.getAgsaVersionName(), getAgsaMinVersion())) {
if (returnImmediately) return false;
outList.add(EligibilityFailureReason.AGSA_VERSION_BELOW_MINIMUM);
}
// AGSA will throw an exception if Chrome isn't Google signed.
if (!mExternalAuthUtils.isChromeGoogleSigned()) {
if (returnImmediately) return false;
outList.add(EligibilityFailureReason.CHROME_NOT_GOOGLE_SIGNED);
}
if (!mExternalAuthUtils.isGoogleSigned(GSAState.PACKAGE_NAME)) {
if (returnImmediately) return false;
outList.add(EligibilityFailureReason.AGSA_NOT_GOOGLE_SIGNED);
}
if (!mIsDefaultSearchEngineGoogle) {
if (returnImmediately) return false;
outList.add(EligibilityFailureReason.NON_GOOGLE_SEARCH_ENGINE);
}
if (!mIdentityManager.hasPrimaryAccount()) {
if (returnImmediately) return false;
outList.add(EligibilityFailureReason.NO_CHROME_ACCOUNT);
}
if (SysUtils.isLowEndDevice()) {
if (returnImmediately) return false;
outList.add(EligibilityFailureReason.LOW_END_DEVICE);
}
if (mIsMultiAccountCheckEnabled && doesViolateMultiAccountCheck()) {
if (returnImmediately) return false;
outList.add(EligibilityFailureReason.MULTIPLE_ACCOUNTS_ON_DEVICE);
}
// Either we would have failed already or we have some errors on the list.
// Otherwise this client is eligible for assistant.
return returnImmediately || outList.size() == 0;
}
/** @return The Intent for Assistant voice search. */
public Intent getAssistantVoiceSearchIntent() {
Intent intent = new Intent(Intent.ACTION_SEARCH);
intent.setPackage(GSAState.PACKAGE_NAME);
return intent;
}
/** Return the current user email or null if no account is present. */
public @Nullable String getUserEmail() {
CoreAccountInfo info = mIdentityManager.getPrimaryAccountInfo(ConsentLevel.SYNC);
return info == null ? null : info.getEmail();
}
/**
* Records user eligibility to histograms that are specific to voice search init.
*
* This should be called each time an Assistant voice search is started.
*
* See reportUserEligibility for details on which histograms are recorded.
*/
void reportMicPressUserEligibility() {
// The base histogram (no suffix) is used to record the per-mic-press eligibility.
reportUserEligibility("");
}
/**
* Records user eligibility to histograms that are specific to browser startup.
*
* This should be only be called once per browser startup.
*
* See reportUserEligibility for details on which histograms are recorded.
*/
public static void reportStartupUserEligibility(Context context) {
AssistantVoiceSearchService service =
new AssistantVoiceSearchService(context, ExternalAuthUtils.getInstance(),
TemplateUrlServiceFactory.get(), GSAState.getInstance(context),
/*observer=*/null, SharedPreferencesManager.getInstance(),
IdentityServicesProvider.get().getIdentityManager(
Profile.getLastUsedRegularProfile()),
AccountManagerFacadeProvider.getInstance());
service.reportUserEligibility(STARTUP_HISTOGRAM_SUFFIX);
}
/**
* Reports user eligilibility to a histogram determined by |timingSuffix|,
* - User eligibility is always reported to USER_ELIGIBILITY_HISTOGRAM + suffix.
* - If the user is ineligible, it also reports the reasons for ineligibility to
* USER_ELIGIBILITY_FAILURE_REASON_HISTOGRAM + suffix.
* - If AGSA is available, the version number is reported to AGSA_VERSION_HISTOGRAM + suffix.
*
* @param timingSuffix The suffix to attach to the histograms to indicate the point at which
* they are being recorded. For example, this can be used to specifically report
* eligibility on browser startup.
*/
private void reportUserEligibility(String timingSuffix) {
List<Integer> failureReasons = new ArrayList<>();
boolean eligible = isDeviceEligibleForAssistant(
/* returnImmediately= */ false, /* outList */ failureReasons);
RecordHistogram.recordBooleanHistogram(USER_ELIGIBILITY_HISTOGRAM + timingSuffix, eligible);
// See notes in {@link GSAState#parseAgsaMajorMinorVersionAsInteger} for details about this
// number.
Integer versionNumber =
mGsaState.parseAgsaMajorMinorVersionAsInteger(mGsaState.getAgsaVersionName());
if (versionNumber != null) {
RecordHistogram.recordSparseHistogram(
AGSA_VERSION_HISTOGRAM + timingSuffix, (int) versionNumber);
}
for (@EligibilityFailureReason int reason : failureReasons) {
RecordHistogram.recordEnumeratedHistogram(
USER_ELIGIBILITY_FAILURE_REASON_HISTOGRAM + timingSuffix, reason,
EligibilityFailureReason.NUM_ENTRIES);
}
}
private void updateColorfulMicState() {
final boolean shouldShowColorfulMic = isColorfulMicEnabled();
// Execute the update/notification in an AsyncTask to prevent re-entrant calls.
new AsyncTask<Boolean>() {
@Override
protected Boolean doInBackground() {
return mShouldShowColorfulMic != shouldShowColorfulMic;
}
@Override
protected void onPostExecute(Boolean notify) {
mShouldShowColorfulMic = shouldShowColorfulMic;
if (notify) notifyObserver();
}
}.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
}
/** Wrapped multi-account check to handle the exception. */
@VisibleForTesting
boolean doesViolateMultiAccountCheck() {
// In case of the accounts cannot be fetched -- we can't be sure so default to true.
final Promise<List<Account>> promise = mAccountManagerFacade.getAccounts();
return !mExternalAuthUtils.canUseGooglePlayServices() || !promise.isFulfilled()
|| promise.getResult().size() > 1;
}
// TemplateUrlService.TemplateUrlServiceObserver implementation
@Override
public void onTemplateURLServiceChanged() {
boolean searchEngineGoogle = mTemplateUrlService.isDefaultSearchEngineGoogle();
if (mIsDefaultSearchEngineGoogle == searchEngineGoogle) return;
mIsDefaultSearchEngineGoogle = searchEngineGoogle;
mShouldShowColorfulMic = isColorfulMicEnabled();
notifyObserver();
}
// IdentityManager.Observer implementation
@Override
public void onPrimaryAccountChanged(PrimaryAccountChangeEvent eventDetails) {
updateColorfulMicState();
}
// AccountsChangeObserver implementation
@Override
public void onAccountsChanged() {
updateColorfulMicState();
}
// Test-only methods
/** Enable the colorful mic for testing purposes. */
void setColorfulMicEnabledForTesting(boolean enabled) {
mIsColorfulMicEnabled = enabled;
mShouldShowColorfulMic = enabled;
}
void setMultiAccountCheckEnabledForTesting(boolean enabled) {
mIsMultiAccountCheckEnabled = enabled;
}
}