blob: d3b0524ef52050f36a20f06d55e506c9c1ebd0ce [file] [log] [blame]
// Copyright 2017 The Chromium Authors
// 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.ui.signin;
import android.accounts.Account;
import android.content.Context;
import android.graphics.drawable.Drawable;
import android.text.format.DateUtils;
import android.view.View;
import android.view.ViewGroup;
import androidx.annotation.DimenRes;
import androidx.annotation.IntDef;
import androidx.annotation.Nullable;
import androidx.annotation.StringDef;
import androidx.annotation.StringRes;
import androidx.annotation.VisibleForTesting;
import org.chromium.base.Promise;
import org.chromium.base.metrics.RecordHistogram;
import org.chromium.base.metrics.RecordUserAction;
import org.chromium.chrome.browser.flags.ChromeFeatureList;
import org.chromium.chrome.browser.preferences.ChromePreferenceKeys;
import org.chromium.chrome.browser.preferences.SharedPreferencesManager;
import org.chromium.chrome.browser.profiles.Profile;
import org.chromium.chrome.browser.signin.services.DisplayableProfileData;
import org.chromium.chrome.browser.signin.services.IdentityServicesProvider;
import org.chromium.chrome.browser.signin.services.ProfileDataCache;
import org.chromium.chrome.browser.signin.services.SigninPreferencesManager;
import org.chromium.chrome.browser.ui.signin.SyncConsentActivityLauncher.AccessPoint;
import org.chromium.chrome.features.start_surface.StartSurfaceConfiguration;
import org.chromium.components.browser_ui.widget.impression.ImpressionTracker;
import org.chromium.components.browser_ui.widget.impression.OneShotImpressionListener;
import org.chromium.components.signin.AccountManagerFacade;
import org.chromium.components.signin.AccountManagerFacadeProvider;
import org.chromium.components.signin.AccountUtils;
import org.chromium.components.signin.Tribool;
import org.chromium.components.signin.base.AccountInfo;
import org.chromium.components.signin.base.CoreAccountInfo;
import org.chromium.components.signin.identitymanager.AccountInfoServiceProvider;
import org.chromium.components.signin.identitymanager.ConsentLevel;
import org.chromium.components.signin.identitymanager.IdentityManager;
import org.chromium.components.signin.metrics.SigninAccessPoint;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
/**
* A controller for configuring the sync promo. It sets up the sync promo depending on the
* context: whether there are any Google accounts on the device which have been previously signed in
* or not. The controller also takes care of counting impressions, recording signin related user
* actions and histograms.
*/
public class SyncPromoController {
/** Specifies the various states of sync promo. */
@Retention(RetentionPolicy.SOURCE)
@IntDef({
SyncPromoState.NO_PROMO,
SyncPromoState.PROMO_FOR_SIGNED_OUT_STATE,
SyncPromoState.PROMO_FOR_SIGNED_IN_STATE,
SyncPromoState.PROMO_FOR_SYNC_TURNED_OFF_STATE,
})
public @interface SyncPromoState {
/** Promo is hidden. */
int NO_PROMO = 0;
/** Promo is shown when a user is signed out. */
int PROMO_FOR_SIGNED_OUT_STATE = 1;
/** Promo is shown when a user is signed in without sync consent. */
int PROMO_FOR_SIGNED_IN_STATE = 2;
/** Promo is shown when a user is signed in with sync consent but has turned off sync. */
int PROMO_FOR_SYNC_TURNED_OFF_STATE = 3;
}
/**
* Receives notifications when user clicks close button in the promo.
*/
public interface OnDismissListener {
/**
* Action to be performed when the promo is being dismissed.
*/
void onDismiss();
}
private static final int MAX_TOTAL_PROMO_SHOW_COUNT = 100;
private static final int MAX_IMPRESSIONS_BOOKMARKS = 20;
private static final int MAX_IMPRESSIONS_SETTINGS = 20;
private static final int NTP_SYNC_PROMO_RESET_AFTER_DAY = 30;
private static final int NTP_SYNC_PROMO_INCREASE_SHOW_COUNT_AFTER_MINUTE = 30;
private static final String SYNC_ANDROID_NTP_PROMO_MAX_IMPRESSIONS =
"SyncAndroidNTPPromoMaxImpressions";
/** Strings used for promo shown count histograms. */
@StringDef({UserAction.CONTINUED, UserAction.DISMISSED, UserAction.SHOWN})
@Retention(RetentionPolicy.SOURCE)
@interface UserAction {
String CONTINUED = "Continued";
String DISMISSED = "Dismissed";
String SHOWN = "Shown";
}
private @Nullable DisplayableProfileData mProfileData;
private @Nullable ImpressionTracker mImpressionTracker;
private final @AccessPoint int mAccessPoint;
private final String mImpressionUserActionName;
private final @Nullable String mSyncPromoDismissedPreferenceTracker;
private final @StringRes int mTitleStringId;
private final @StringRes int mDescriptionStringId;
private final SyncConsentActivityLauncher mSyncConsentActivityLauncher;
/**
* Determines whether the Sync promo can be shown.
* @param accessPoint The access point for which the impression limit is being checked.
*/
public static boolean canShowSyncPromo(@AccessPoint int accessPoint) {
switch (accessPoint) {
case SigninAccessPoint.BOOKMARK_MANAGER:
return canShowBookmarkPromo();
case SigninAccessPoint.NTP_CONTENT_SUGGESTIONS:
return canShowNTPPromo();
case SigninAccessPoint.RECENT_TABS:
// There is no impression limit or dismiss button in Recent Tabs promo.
return true;
case SigninAccessPoint.SETTINGS:
return canShowSettingsPromo();
default:
assert false : "Unexpected value for access point: " + accessPoint;
return false;
}
}
private static long getNTPSyncPromoResetAfterMillis() {
if (ChromeFeatureList.isEnabled(
ChromeFeatureList.SYNC_ANDROID_LIMIT_NTP_PROMO_IMPRESSIONS)) {
return NTP_SYNC_PROMO_RESET_AFTER_DAY * DateUtils.DAY_IN_MILLIS;
}
return StartSurfaceConfiguration.SIGNIN_PROMO_NTP_RESET_AFTER_HOURS.getValue()
* DateUtils.HOUR_IN_MILLIS;
}
/**
* If the signin promo card has been hidden for longer than the {@link
* StartSurfaceConfiguration#SIGNIN_PROMO_NTP_RESET_AFTER_HOURS}, resets the impression counts,
* {@link ChromePreferenceKeys#SIGNIN_PROMO_NTP_FIRST_SHOWN_TIME} and {@link
* ChromePreferenceKeys#SIGNIN_PROMO_NTP_LAST_SHOWN_TIME} to allow the promo card to show again.
*/
public static void resetNTPSyncPromoLimitsIfHiddenForTooLong() {
final long currentTime = System.currentTimeMillis();
final long resetAfterMs = getNTPSyncPromoResetAfterMillis();
final long lastShownTime = SharedPreferencesManager.getInstance().readLong(
ChromePreferenceKeys.SIGNIN_PROMO_NTP_LAST_SHOWN_TIME, 0L);
if (resetAfterMs <= 0 || lastShownTime <= 0) return;
if (currentTime - lastShownTime >= resetAfterMs) {
SharedPreferencesManager.getInstance().writeInt(
getPromoShowCountPreferenceName(SigninAccessPoint.NTP_CONTENT_SUGGESTIONS), 0);
SharedPreferencesManager.getInstance().removeKey(
ChromePreferenceKeys.SIGNIN_PROMO_NTP_FIRST_SHOWN_TIME);
SharedPreferencesManager.getInstance().removeKey(
ChromePreferenceKeys.SIGNIN_PROMO_NTP_LAST_SHOWN_TIME);
}
}
private static boolean canShowBookmarkPromo() {
boolean isPromoDismissed = SharedPreferencesManager.getInstance().readBoolean(
ChromePreferenceKeys.SIGNIN_PROMO_BOOKMARKS_DECLINED, false);
return SharedPreferencesManager.getInstance().readInt(
getPromoShowCountPreferenceName(SigninAccessPoint.BOOKMARK_MANAGER))
< MAX_IMPRESSIONS_BOOKMARKS
&& !isPromoDismissed;
}
private static boolean timeElapsedSinceFirstShownExceedsLimit() {
final long timeSinceFirstShownLimitMs =
StartSurfaceConfiguration.SIGNIN_PROMO_NTP_SINCE_FIRST_TIME_SHOWN_LIMIT_HOURS
.getValue()
* DateUtils.HOUR_IN_MILLIS;
if (timeSinceFirstShownLimitMs <= 0) return false;
final long currentTime = System.currentTimeMillis();
final long firstShownTime = SharedPreferencesManager.getInstance().readLong(
ChromePreferenceKeys.SIGNIN_PROMO_NTP_FIRST_SHOWN_TIME, 0L);
return firstShownTime > 0 && currentTime - firstShownTime >= timeSinceFirstShownLimitMs;
}
private static int getNTPMaxImpressions() {
if (ChromeFeatureList.isEnabled(
ChromeFeatureList.SYNC_ANDROID_LIMIT_NTP_PROMO_IMPRESSIONS)) {
return ChromeFeatureList.getFieldTrialParamByFeatureAsInt(
ChromeFeatureList.SYNC_ANDROID_LIMIT_NTP_PROMO_IMPRESSIONS,
SYNC_ANDROID_NTP_PROMO_MAX_IMPRESSIONS, 5);
}
return StartSurfaceConfiguration.SIGNIN_PROMO_NTP_COUNT_LIMIT.getValue();
}
private static boolean canShowNTPPromo() {
int promoShowCount = SharedPreferencesManager.getInstance().readInt(
getPromoShowCountPreferenceName(SigninAccessPoint.NTP_CONTENT_SUGGESTIONS));
if (promoShowCount >= getNTPMaxImpressions()) {
return false;
}
if (timeElapsedSinceFirstShownExceedsLimit()) {
return false;
}
if (SharedPreferencesManager.getInstance().readBoolean(
ChromePreferenceKeys.SIGNIN_PROMO_NTP_PROMO_DISMISSED, false)) {
return false;
}
if (ChromeFeatureList.isEnabled(ChromeFeatureList.FORCE_DISABLE_EXTENDED_SYNC_PROMOS)) {
return false;
}
final @Nullable Account visibleAccount = getVisibleAccount();
if (visibleAccount == null) {
return true;
}
final Promise<AccountInfo> visibleAccountPromise =
AccountInfoServiceProvider.get().getAccountInfoByEmail(visibleAccount.name);
return visibleAccountPromise.isFulfilled()
&& visibleAccountPromise.getResult()
.getAccountCapabilities()
.canOfferExtendedSyncPromos()
== Tribool.TRUE;
}
private static boolean canShowSettingsPromo() {
SharedPreferencesManager preferencesManager = SharedPreferencesManager.getInstance();
boolean isPromoDismissed = preferencesManager.readBoolean(
ChromePreferenceKeys.SIGNIN_PROMO_SETTINGS_PERSONALIZED_DISMISSED, false);
return preferencesManager.readInt(
getPromoShowCountPreferenceName(SigninAccessPoint.SETTINGS))
< MAX_IMPRESSIONS_SETTINGS
&& !isPromoDismissed;
}
// Find the visible account for sync promos
private static @Nullable Account getVisibleAccount() {
final IdentityManager identityManager = IdentityServicesProvider.get().getIdentityManager(
Profile.getLastUsedRegularProfile());
@Nullable
Account visibleAccount = CoreAccountInfo.getAndroidAccountFrom(
identityManager.getPrimaryAccountInfo(ConsentLevel.SIGNIN));
final AccountManagerFacade accountManagerFacade =
AccountManagerFacadeProvider.getInstance();
if (visibleAccount == null) {
visibleAccount =
AccountUtils.getDefaultAccountIfFulfilled(accountManagerFacade.getAccounts());
}
return visibleAccount;
}
@VisibleForTesting
public static String getPromoShowCountPreferenceName(@AccessPoint int accessPoint) {
switch (accessPoint) {
case SigninAccessPoint.BOOKMARK_MANAGER:
return ChromePreferenceKeys.SYNC_PROMO_SHOW_COUNT.createKey(
SigninPreferencesManager.SyncPromoAccessPointId.BOOKMARKS);
case SigninAccessPoint.NTP_CONTENT_SUGGESTIONS:
// This preference may get reset while the other ones are never reset unless device
// data is wiped.
return ChromePreferenceKeys.SYNC_PROMO_SHOW_COUNT.createKey(
SigninPreferencesManager.SyncPromoAccessPointId.NTP);
case SigninAccessPoint.SETTINGS:
return ChromePreferenceKeys.SYNC_PROMO_SHOW_COUNT.createKey(
SigninPreferencesManager.SyncPromoAccessPointId.SETTINGS);
default:
throw new IllegalArgumentException(
"Unexpected value for access point: " + accessPoint);
}
}
/**
* Creates a new SyncPromoController.
* @param accessPoint Specifies the AccessPoint from which the promo is to be shown.
* @param syncConsentActivityLauncher Launcher of {@link SyncConsentActivity}.
*/
public SyncPromoController(
@AccessPoint int accessPoint, SyncConsentActivityLauncher syncConsentActivityLauncher) {
mAccessPoint = accessPoint;
mSyncConsentActivityLauncher = syncConsentActivityLauncher;
switch (mAccessPoint) {
case SigninAccessPoint.BOOKMARK_MANAGER:
mImpressionUserActionName = "Signin_Impression_FromBookmarkManager";
mSyncPromoDismissedPreferenceTracker =
ChromePreferenceKeys.SIGNIN_PROMO_BOOKMARKS_DECLINED;
mTitleStringId = R.string.sync_promo_title_bookmarks;
mDescriptionStringId = R.string.sync_promo_description_bookmarks;
break;
case SigninAccessPoint.NTP_CONTENT_SUGGESTIONS:
mImpressionUserActionName = "Signin_Impression_FromNTPContentSuggestions";
mSyncPromoDismissedPreferenceTracker =
ChromePreferenceKeys.SIGNIN_PROMO_NTP_PROMO_DISMISSED;
mTitleStringId = R.string.sync_promo_title_ntp_content_suggestions;
mDescriptionStringId = R.string.sync_promo_description_ntp_content_suggestions;
break;
case SigninAccessPoint.RECENT_TABS:
mImpressionUserActionName = "Signin_Impression_FromRecentTabs";
mSyncPromoDismissedPreferenceTracker = null;
mTitleStringId = R.string.sync_promo_title_recent_tabs;
mDescriptionStringId = R.string.sync_promo_description_recent_tabs;
break;
case SigninAccessPoint.SETTINGS:
mImpressionUserActionName = "Signin_Impression_FromSettings";
mSyncPromoDismissedPreferenceTracker =
ChromePreferenceKeys.SIGNIN_PROMO_SETTINGS_PERSONALIZED_DISMISSED;
mTitleStringId = R.string.sync_promo_title_settings;
mDescriptionStringId = R.string.sync_promo_description_settings;
break;
default:
throw new IllegalArgumentException(
"Unexpected value for access point: " + mAccessPoint);
}
}
/**
* Sets up the sync promo view.
* @param profileDataCache The {@link ProfileDataCache} that stores profile data.
* @param view The {@link PersonalizedSigninPromoView} that should be set up.
* @param listener The {@link SyncPromoController.OnDismissListener} to be set to the view.
*/
public void setUpSyncPromoView(ProfileDataCache profileDataCache,
PersonalizedSigninPromoView view, SyncPromoController.OnDismissListener listener) {
final IdentityManager identityManager = IdentityServicesProvider.get().getIdentityManager(
Profile.getLastUsedRegularProfile());
assert !identityManager.hasPrimaryAccount(ConsentLevel.SYNC) : "Sync is already enabled!";
final @Nullable Account visibleAccount = getVisibleAccount();
// Set up the sync promo
if (visibleAccount == null) {
setupPromoView(view, /* profileData= */ null, listener);
return;
}
setupPromoView(
view, profileDataCache.getProfileDataOrDefault(visibleAccount.name), listener);
}
/**
* Configures the signin promo view and resets the impression tracker. If this controller has
* been previously set up.
* @param view The view in which the promo will be added.
* @param profileData If not null, the promo will be configured to be in the hot state, using
* the account image, email and full name of the user to set the picture and the text of
* the promo appropriately. Otherwise, the promo will be in the cold state.
* @param onDismissListener Listener which handles the action of dismissing the promo. A null
* onDismissListener marks that the promo is not dismissible and as a result the close
* button is hidden.
*/
private void setupPromoView(PersonalizedSigninPromoView view,
final @Nullable DisplayableProfileData profileData,
final @Nullable OnDismissListener onDismissListener) {
if (mImpressionTracker != null) {
mImpressionTracker.setListener(null);
mImpressionTracker = null;
}
mImpressionTracker = new ImpressionTracker(view);
mImpressionTracker.setListener(
new OneShotImpressionListener(this::recordSigninPromoImpression));
mProfileData = profileData;
if (mProfileData == null) {
setupColdState(view);
} else {
setupHotState(view);
}
if (onDismissListener != null) {
// Recent Tabs promos can't be dismissed.
assert mAccessPoint != SigninAccessPoint.RECENT_TABS;
view.getDismissButton().setVisibility(View.VISIBLE);
view.getDismissButton().setOnClickListener(promoView -> {
assert mSyncPromoDismissedPreferenceTracker != null;
SharedPreferencesManager.getInstance().writeBoolean(
mSyncPromoDismissedPreferenceTracker, true);
recordShowCountHistogram(UserAction.DISMISSED);
onDismissListener.onDismiss();
});
} else {
view.getDismissButton().setVisibility(View.GONE);
}
}
/**
* Should be called when the view is not in use anymore (e.g. it's being recycled).
*/
public void detach() {
if (mImpressionTracker != null) {
mImpressionTracker.setListener(null);
mImpressionTracker = null;
}
}
/** Increases promo show count by one. */
public void increasePromoShowCount() {
if (mAccessPoint == SigninAccessPoint.NTP_CONTENT_SUGGESTIONS) {
final long currentTime = System.currentTimeMillis();
final long lastShownTime = SharedPreferencesManager.getInstance().readLong(
ChromePreferenceKeys.SIGNIN_PROMO_NTP_LAST_SHOWN_TIME, 0L);
if (currentTime - lastShownTime < NTP_SYNC_PROMO_INCREASE_SHOW_COUNT_AFTER_MINUTE
* DateUtils.MINUTE_IN_MILLIS
&& ChromeFeatureList.isEnabled(
ChromeFeatureList.SYNC_ANDROID_LIMIT_NTP_PROMO_IMPRESSIONS)) {
return;
}
if (SharedPreferencesManager.getInstance().readLong(
ChromePreferenceKeys.SIGNIN_PROMO_NTP_FIRST_SHOWN_TIME)
== 0) {
SharedPreferencesManager.getInstance().writeLong(
ChromePreferenceKeys.SIGNIN_PROMO_NTP_FIRST_SHOWN_TIME, currentTime);
}
SharedPreferencesManager.getInstance().writeLong(
ChromePreferenceKeys.SIGNIN_PROMO_NTP_LAST_SHOWN_TIME, currentTime);
}
if (mAccessPoint != SigninAccessPoint.RECENT_TABS) {
SharedPreferencesManager.getInstance().incrementInt(
getPromoShowCountPreferenceName(mAccessPoint));
}
SharedPreferencesManager.getInstance().incrementInt(
ChromePreferenceKeys.SYNC_PROMO_TOTAL_SHOW_COUNT);
recordShowCountHistogram(UserAction.SHOWN);
}
// TODO(crbug.com/1323197): we can share more code between setupColdState() and setupHotState().
// The difference between the 2 will just be the avatar and the behavior of the primary button.
private void setupColdState(PersonalizedSigninPromoView view) {
final Context context = view.getContext();
view.getImage().setImageResource(R.drawable.chrome_sync_logo);
setImageSize(context, view, R.dimen.signin_promo_cold_state_image_size);
view.getTitle().setText(mTitleStringId);
view.getDescription().setText(mDescriptionStringId);
view.getPrimaryButton().setText(R.string.sync_promo_turn_on_sync);
view.getPrimaryButton().setOnClickListener(v -> signinWithNewAccount(context));
view.getSecondaryButton().setVisibility(View.GONE);
}
private void setupHotState(PersonalizedSigninPromoView view) {
final Context context = view.getContext();
Drawable accountImage = mProfileData.getImage();
view.getImage().setImageDrawable(accountImage);
setImageSize(context, view, R.dimen.sync_promo_account_image_size);
view.getTitle().setText(mTitleStringId);
view.getDescription().setText(mDescriptionStringId);
view.getPrimaryButton().setOnClickListener(v -> signinWithDefaultAccount(context));
if (IdentityServicesProvider.get()
.getIdentityManager(Profile.getLastUsedRegularProfile())
.hasPrimaryAccount(ConsentLevel.SIGNIN)) {
view.getPrimaryButton().setText(R.string.sync_promo_turn_on_sync);
view.getSecondaryButton().setVisibility(View.GONE);
return;
}
view.getPrimaryButton().setText(context.getString(
R.string.sync_promo_continue_as, mProfileData.getGivenNameOrFullNameOrEmail()));
view.getSecondaryButton().setText(R.string.signin_promo_choose_another_account);
view.getSecondaryButton().setOnClickListener(v -> signinWithNotDefaultAccount(context));
view.getSecondaryButton().setVisibility(View.VISIBLE);
}
private void signinWithNewAccount(Context context) {
recordShowCountHistogram(UserAction.CONTINUED);
mSyncConsentActivityLauncher.launchActivityForPromoAddAccountFlow(context, mAccessPoint);
}
private void signinWithDefaultAccount(Context context) {
recordShowCountHistogram(UserAction.CONTINUED);
mSyncConsentActivityLauncher.launchActivityForPromoDefaultFlow(
context, mAccessPoint, mProfileData.getAccountEmail());
}
private void signinWithNotDefaultAccount(Context context) {
recordShowCountHistogram(UserAction.CONTINUED);
mSyncConsentActivityLauncher.launchActivityForPromoChooseAccountFlow(
context, mAccessPoint, mProfileData.getAccountEmail());
}
private void recordShowCountHistogram(@UserAction String actionType) {
final String accessPoint;
switch (mAccessPoint) {
case SigninAccessPoint.BOOKMARK_MANAGER:
accessPoint = SigninPreferencesManager.SyncPromoAccessPointId.BOOKMARKS;
break;
case SigninAccessPoint.NTP_CONTENT_SUGGESTIONS:
accessPoint = SigninPreferencesManager.SyncPromoAccessPointId.NTP;
break;
case SigninAccessPoint.RECENT_TABS:
accessPoint = SigninPreferencesManager.SyncPromoAccessPointId.RECENT_TABS;
break;
case SigninAccessPoint.SETTINGS:
accessPoint = SigninPreferencesManager.SyncPromoAccessPointId.SETTINGS;
break;
default:
throw new IllegalArgumentException(
"Unexpected value for access point" + mAccessPoint);
}
RecordHistogram.recordExactLinearHistogram(
"Signin.SyncPromo." + actionType + ".Count." + accessPoint,
SharedPreferencesManager.getInstance().readInt(
ChromePreferenceKeys.SYNC_PROMO_TOTAL_SHOW_COUNT),
MAX_TOTAL_PROMO_SHOW_COUNT);
}
private void setImageSize(
Context context, PersonalizedSigninPromoView view, @DimenRes int dimenResId) {
ViewGroup.LayoutParams layoutParams = view.getImage().getLayoutParams();
layoutParams.height = context.getResources().getDimensionPixelSize(dimenResId);
layoutParams.width = context.getResources().getDimensionPixelSize(dimenResId);
view.getImage().setLayoutParams(layoutParams);
}
private void recordSigninPromoImpression() {
RecordUserAction.record(mImpressionUserActionName);
}
@VisibleForTesting
public static void setPrefSigninPromoDeclinedBookmarksForTests(boolean isDeclined) {
SharedPreferencesManager.getInstance().writeBoolean(
ChromePreferenceKeys.SIGNIN_PROMO_BOOKMARKS_DECLINED, isDeclined);
}
@VisibleForTesting
public static int getMaxImpressionsBookmarksForTests() {
return MAX_IMPRESSIONS_BOOKMARKS;
}
}