blob: f3e4ba7e121551b5e389bd9b1913e2a0d17bcc84 [file] [log] [blame]
// Copyright 2017 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.survey;
import android.content.Context;
import android.content.SharedPreferences;
import android.os.Handler;
import android.support.annotation.IntDef;
import android.support.annotation.VisibleForTesting;
import android.text.TextUtils;
import org.chromium.base.AsyncTask;
import org.chromium.base.CommandLine;
import org.chromium.base.ContextUtils;
import org.chromium.base.ThreadUtils;
import org.chromium.base.metrics.RecordHistogram;
import org.chromium.base.metrics.RecordUserAction;
import org.chromium.chrome.R;
import org.chromium.chrome.browser.ChromeFeatureList;
import org.chromium.chrome.browser.ChromeSwitches;
import org.chromium.chrome.browser.infobar.InfoBarContainer;
import org.chromium.chrome.browser.infobar.InfoBarContainerLayout.Item;
import org.chromium.chrome.browser.infobar.InfoBarIdentifier;
import org.chromium.chrome.browser.infobar.SurveyInfoBar;
import org.chromium.chrome.browser.infobar.SurveyInfoBarDelegate;
import org.chromium.chrome.browser.preferences.privacy.PrivacyPreferencesManager;
import org.chromium.chrome.browser.tab.EmptyTabObserver;
import org.chromium.chrome.browser.tab.Tab;
import org.chromium.chrome.browser.tab.TabObserver;
import org.chromium.chrome.browser.tabmodel.EmptyTabModelSelectorObserver;
import org.chromium.chrome.browser.tabmodel.TabModelSelector;
import org.chromium.chrome.browser.tabmodel.TabModelSelectorObserver;
import org.chromium.chrome.browser.util.FeatureUtilities;
import org.chromium.components.variations.VariationsAssociatedData;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.util.Calendar;
import java.util.Random;
/**
* Class that controls if and when to show surveys related to the Chrome Home experiment.
*/
public class ChromeSurveyController implements InfoBarContainer.InfoBarAnimationListener {
/**
* The survey questions for this survey are the same as those in the survey used for Chrome
* Home, so we reuse the old infobar key to prevent the users from seeing the same survey more
* than once.
*/
static final String SURVEY_INFO_BAR_DISPLAYED_KEY = "chrome_home_survey_info_bar_displayed";
static final String DATE_LAST_ROLLED_KEY = "last_rolled_for_chrome_survey_key";
private static final String CHROME_SURVEY_TRIAL_NAME = "ChromeSurvey";
private static final String MAX_NUMBER = "max-number";
private static final String SITE_ID_PARAM_NAME = "site-id";
private static final long REQUIRED_VISIBILITY_DURATION_MS = 5000;
private static boolean sForceUmaEnabledForTesting;
public static final String COMMAND_LINE_PARAM_NAME = "survey_override_site_id";
/**
* Reasons that the user was rejected from being selected for a survey
* Note: these values cannot change and must match the SurveyFilteringResult enum in enums.xml
* because they're written to logs.
*/
@IntDef({FilteringResult.SURVEY_INFOBAR_ALREADY_DISPLAYED,
FilteringResult.FORCE_SURVEY_ON_COMMAND_PRESENT,
FilteringResult.USER_ALREADY_SAMPLED_TODAY, FilteringResult.MAX_NUMBER_MISSING,
FilteringResult.ROLLED_NON_ZERO_NUMBER, FilteringResult.USER_SELECTED_FOR_SURVEY,
FilteringResult.SURVEY_ALREADY_EXISTS})
@Retention(RetentionPolicy.SOURCE)
public @interface FilteringResult {
int SURVEY_INFOBAR_ALREADY_DISPLAYED = 0;
int FORCE_SURVEY_ON_COMMAND_PRESENT = 2;
int USER_ALREADY_SAMPLED_TODAY = 3;
int MAX_NUMBER_MISSING = 4;
int ROLLED_NON_ZERO_NUMBER = 5;
int USER_SELECTED_FOR_SURVEY = 6;
int SURVEY_ALREADY_EXISTS = 7;
// Number of entries
int NUM_ENTRIES = 8;
}
/**
* How the infobar was closed and its visibility status when it was closed.
* Note: these values must match the InfoBarClosingStates enum in enums.xml.
*/
@IntDef({InfoBarClosingState.ACCEPTED_SURVEY, InfoBarClosingState.CLOSE_BUTTON,
InfoBarClosingState.VISIBLE_INDIRECT, InfoBarClosingState.HIDDEN_INDIRECT})
@Retention(RetentionPolicy.SOURCE)
public @interface InfoBarClosingState {
int ACCEPTED_SURVEY = 0;
int CLOSE_BUTTON = 1;
int VISIBLE_INDIRECT = 2;
int HIDDEN_INDIRECT = 3;
// Number of entries
int NUM_ENTRIES = 4;
}
private TabModelSelector mTabModelSelector;
private Handler mLoggingHandler;
private Tab mSurveyInfoBarTab;
private TabModelSelectorObserver mTabModelObserver;
@VisibleForTesting
ChromeSurveyController() {
// Empty constructor.
}
/**
* Checks if the conditions to show the survey are met and starts the process if they are.
* @param tabModelSelector The tab model selector to access the tab on which the survey will be
* shown.
*/
public static void initialize(TabModelSelector tabModelSelector) {
assert tabModelSelector != null;
new StartDownloadIfEligibleTask(new ChromeSurveyController(), tabModelSelector)
.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
}
/**
* Downloads the survey if the user qualifies.
* @param context The current Android {@link Context}.
* @param tabModelSelector The tab model selector to access the tab on which the survey will be
* shown.
*/
private void startDownload(Context context, TabModelSelector tabModelSelector) {
mLoggingHandler = new Handler();
mTabModelSelector = tabModelSelector;
SurveyController surveyController = SurveyController.getInstance();
String siteId = getSiteId();
if (TextUtils.isEmpty(siteId)) return;
Runnable onSuccessRunnable = new Runnable() {
@Override
public void run() {
onSurveyAvailable(siteId);
}
};
String siteContext = String.format(
"Is Modern Design Enabled? %s", FeatureUtilities.isChromeModernDesignEnabled());
surveyController.downloadSurvey(context, siteId, onSuccessRunnable, siteContext);
}
private String getSiteId() {
CommandLine commandLine = CommandLine.getInstance();
if (commandLine.hasSwitch(COMMAND_LINE_PARAM_NAME)) {
return commandLine.getSwitchValue(COMMAND_LINE_PARAM_NAME);
} else {
return VariationsAssociatedData.getVariationParamValue(
CHROME_SURVEY_TRIAL_NAME, SITE_ID_PARAM_NAME);
}
}
/** @return Whether the user qualifies for the survey. */
private boolean doesUserQualifyForSurvey() {
if (!isUMAEnabled() && !sForceUmaEnabledForTesting) return false;
if (hasInfoBarBeenDisplayed()) return false;
return true;
}
/**
* Called when the survey has finished downloading to show the survey info bar.
* @param siteId The site id of the survey to display.
*/
@VisibleForTesting
void onSurveyAvailable(String siteId) {
if (attemptToShowOnTab(mTabModelSelector.getCurrentTab(), siteId)) return;
mTabModelObserver = new EmptyTabModelSelectorObserver() {
@Override
public void onChange() {
attemptToShowOnTab(mTabModelSelector.getCurrentTab(), siteId);
}
};
mTabModelSelector.addObserver(mTabModelObserver);
}
/**
* Show the survey info bar on the passed in tab if the tab is finished loading.
* Else, it adds a listener to the tab to show the info bar once conditions are met.
* @param tab The tab to attempt to attach the survey info bar.
* @param siteId The site id of the survey to display.
* @return If the infobar was successfully shown.
*/
private boolean attemptToShowOnTab(Tab tab, String siteId) {
if (!isValidTabForSurvey(tab)) return false;
if (tab.isLoading() || !tab.isUserInteractable()) {
tab.addObserver(createTabObserver(tab, siteId));
return false;
}
showSurveyInfoBar(tab, siteId);
return true;
}
/**
* Shows the survey info bar.
* @param tab The tab to attach the survey info bar.
* @param siteId The site id of the survey to display.
*/
@VisibleForTesting
void showSurveyInfoBar(Tab tab, String siteId) {
mSurveyInfoBarTab = tab;
tab.getInfoBarContainer().addAnimationListener(this);
SurveyInfoBar.showSurveyInfoBar(tab.getWebContents(), siteId, true,
R.drawable.chrome_sync_logo, getSurveyInfoBarDelegate());
RecordUserAction.record("Android.Survey.ShowSurveyInfoBar");
mTabModelSelector.removeObserver(mTabModelObserver);
}
/**
* @return The observer that handles cases where the user switches tabs before an infobar is
* shown.
*/
private TabObserver createTabObserver(Tab tab, String siteId) {
return new EmptyTabObserver() {
@Override
public void onInteractabilityChanged(boolean isInteractable) {
showInfoBarIfApplicable(tab, siteId, this);
}
@Override
public void onPageLoadFinished(Tab tab) {
showInfoBarIfApplicable(tab, siteId, this);
}
@Override
public void onHidden(Tab tab) {
// An infobar shouldn't appear on a tab that the user left.
tab.removeObserver(this);
}
};
}
/**
* Shows the infobar if the passed in tab is fully loaded and interactable.
* @param tab The tab to attach the survey info bar.
* @param siteId The site id of the survey to display.
* @param observer The tab observer to remove from the passed in tab.
*/
@VisibleForTesting
void showInfoBarIfApplicable(Tab tab, String siteId, TabObserver observer) {
if (!tab.isUserInteractable() || tab.isLoading()) return;
showSurveyInfoBar(tab, siteId);
tab.removeObserver(observer);
}
/** @return Whether the user has consented to reporting usage metrics and crash dumps. */
private boolean isUMAEnabled() {
return PrivacyPreferencesManager.getInstance().isUsageAndCrashReportingPermittedByUser();
}
/** @return If the survey info bar for this survey was logged as seen before. */
@VisibleForTesting
boolean hasInfoBarBeenDisplayed() {
SharedPreferences sharedPreferences = ContextUtils.getAppSharedPreferences();
if (sharedPreferences.getLong(SURVEY_INFO_BAR_DISPLAYED_KEY, -1L) != -1L) {
recordSurveyFilteringResult(FilteringResult.SURVEY_INFOBAR_ALREADY_DISPLAYED);
return true;
}
return false;
}
/**
* Checks if the tab is valid for a survey (i.e. not null, no null webcontents & not incognito).
* @param tab The tab to be checked.
* @return Whether or not the tab is valid.
*/
@VisibleForTesting
boolean isValidTabForSurvey(Tab tab) {
return tab != null && tab.getWebContents() != null && !tab.isIncognito();
}
/** @return A new {@link ChromeSurveyController} for testing. */
@VisibleForTesting
public static ChromeSurveyController createChromeSurveyControllerForTests() {
return new ChromeSurveyController();
}
@Override
public void notifyAnimationFinished(int animationType) {}
@Override
public void notifyAllAnimationsFinished(Item frontInfoBar) {
mLoggingHandler.removeCallbacksAndMessages(null);
// If the survey info bar is in front, start the countdown to log that it was displayed.
if (frontInfoBar == null
|| frontInfoBar.getInfoBarIdentifier()
!= InfoBarIdentifier.SURVEY_INFOBAR_ANDROID) {
return;
}
mLoggingHandler.postDelayed(
() -> recordInfoBarDisplayed(), REQUIRED_VISIBILITY_DURATION_MS);
}
/**
* Rolls a random number to see if the user was eligible for the survey
* @return Whether the user is eligible (i.e. the random number rolled was 0).
*/
@VisibleForTesting
boolean isRandomlySelectedForSurvey() {
SharedPreferences preferences = ContextUtils.getAppSharedPreferences();
int lastDate = preferences.getInt(DATE_LAST_ROLLED_KEY, -1);
int today = getDayOfYear();
if (lastDate == today) {
recordSurveyFilteringResult(FilteringResult.USER_ALREADY_SAMPLED_TODAY);
return false;
}
int maxNumber = getMaxNumber();
if (FeatureUtilities.isChromeModernDesignEnabled()) {
maxNumber = ChromeFeatureList.getFieldTrialParamByFeatureAsInt(
ChromeFeatureList.CHROME_MODERN_DESIGN, MAX_NUMBER, maxNumber);
}
if (maxNumber == -1) {
recordSurveyFilteringResult(FilteringResult.MAX_NUMBER_MISSING);
return false;
}
preferences.edit().putInt(DATE_LAST_ROLLED_KEY, today).apply();
if (getRandomNumberUpTo(maxNumber) == 0) {
recordSurveyFilteringResult(FilteringResult.USER_SELECTED_FOR_SURVEY);
return true;
} else {
recordSurveyFilteringResult(FilteringResult.ROLLED_NON_ZERO_NUMBER);
return false;
}
}
/**
* @param max The max threshold for the random number generator.
* @return A random number from 0 (inclusive) to the max number (exclusive).
*/
@VisibleForTesting
int getRandomNumberUpTo(int max) {
return new Random().nextInt(max);
}
/** @return The max number as stated in the finch config. */
@VisibleForTesting
int getMaxNumber() {
try {
String number = VariationsAssociatedData.getVariationParamValue(
CHROME_SURVEY_TRIAL_NAME, MAX_NUMBER);
if (TextUtils.isEmpty(number)) return -1;
return Integer.parseInt(number);
} catch (NumberFormatException e) {
return -1;
}
}
/** @return The day of the year for today. */
@VisibleForTesting
int getDayOfYear() {
ThreadUtils.assertOnBackgroundThread();
return Calendar.getInstance().get(Calendar.DAY_OF_YEAR);
}
/**
* @return The survey info bar delegate containing actions specific to the Chrome Home survey.
*/
private SurveyInfoBarDelegate getSurveyInfoBarDelegate() {
return new SurveyInfoBarDelegate() {
@Override
public void onSurveyInfoBarTabInteractabilityChanged(boolean isInteractable) {
if (mSurveyInfoBarTab == null) return;
if (!isInteractable) {
mLoggingHandler.removeCallbacksAndMessages(null);
return;
}
mLoggingHandler.postDelayed(
() -> recordInfoBarDisplayed(), REQUIRED_VISIBILITY_DURATION_MS);
}
@Override
public void onSurveyInfoBarTabHidden() {
mLoggingHandler.removeCallbacksAndMessages(null);
mSurveyInfoBarTab = null;
}
@Override
public void onSurveyInfoBarClosed(boolean viaCloseButton, boolean visibleWhenClosed) {
if (viaCloseButton) {
recordInfoBarClosingState(InfoBarClosingState.CLOSE_BUTTON);
recordInfoBarDisplayed();
} else {
if (visibleWhenClosed) {
recordInfoBarClosingState(InfoBarClosingState.VISIBLE_INDIRECT);
} else {
recordInfoBarClosingState(InfoBarClosingState.HIDDEN_INDIRECT);
}
}
}
@Override
public void onSurveyTriggered() {
recordInfoBarClosingState(InfoBarClosingState.ACCEPTED_SURVEY);
recordInfoBarDisplayed();
}
@Override
public String getSurveyPromptString() {
return ContextUtils.getApplicationContext().getString(
R.string.chrome_survey_prompt);
}
};
}
/** Logs in {@link SharedPreferences} that the info bar was displayed. */
private void recordInfoBarDisplayed() {
// This can be called multiple times e.g. by mLoggingHandler & onSurveyInfoBarClosed().
// Return early to allow only one call to this method (http://crbug.com/791076).
if (mSurveyInfoBarTab == null) return;
if (mSurveyInfoBarTab.getInfoBarContainer() != null) {
mSurveyInfoBarTab.getInfoBarContainer().removeAnimationListener(this);
}
mLoggingHandler.removeCallbacksAndMessages(null);
SharedPreferences sharedPreferences = ContextUtils.getAppSharedPreferences();
sharedPreferences.edit()
.putLong(SURVEY_INFO_BAR_DISPLAYED_KEY, System.currentTimeMillis())
.apply();
mSurveyInfoBarTab = null;
}
@VisibleForTesting
void setTabModelSelector(TabModelSelector tabModelSelector) {
mTabModelSelector = tabModelSelector;
}
private void recordSurveyFilteringResult(@FilteringResult int value) {
RecordHistogram.recordEnumeratedHistogram(
"Android.Survey.SurveyFilteringResults", value, FilteringResult.NUM_ENTRIES);
}
private void recordInfoBarClosingState(@InfoBarClosingState int value) {
RecordHistogram.recordEnumeratedHistogram(
"Android.Survey.InfoBarClosingState", value, InfoBarClosingState.NUM_ENTRIES);
}
static class StartDownloadIfEligibleTask extends AsyncTask<Void, Void, Boolean> {
ChromeSurveyController mController;
final TabModelSelector mSelector;
public StartDownloadIfEligibleTask(
ChromeSurveyController controller, TabModelSelector tabModelSelector) {
mController = controller;
mSelector = tabModelSelector;
}
@Override
protected Boolean doInBackground(Void... params) {
if (!mController.doesUserQualifyForSurvey()) return false;
if (SurveyController.getInstance().doesSurveyExist(
mController.getSiteId(), ContextUtils.getApplicationContext())) {
mController.recordSurveyFilteringResult(FilteringResult.SURVEY_ALREADY_EXISTS);
return true;
} else {
boolean forceSurveyOn = false;
if (CommandLine.getInstance().hasSwitch(
ChromeSwitches.CHROME_FORCE_ENABLE_SURVEY)) {
forceSurveyOn = true;
mController.recordSurveyFilteringResult(
FilteringResult.FORCE_SURVEY_ON_COMMAND_PRESENT);
}
return mController.isRandomlySelectedForSurvey() || forceSurveyOn;
}
}
@Override
protected void onPostExecute(Boolean result) {
if (result) mController.startDownload(ContextUtils.getApplicationContext(), mSelector);
}
}
// Force enable UMA testing for testing.
@VisibleForTesting
public static void forceIsUMAEnabledForTesting(boolean forcedUMAStatus) {
sForceUmaEnabledForTesting = forcedUMAStatus;
}
@VisibleForTesting
public static Long getRequiredVisibilityDurationMs() {
return REQUIRED_VISIBILITY_DURATION_MS;
}
@VisibleForTesting
public static String getChromeSurveyInfoBarDisplayedKey() {
return SURVEY_INFO_BAR_DISPLAYED_KEY;
}
@VisibleForTesting
public static String getCommandLineParamName() {
return COMMAND_LINE_PARAM_NAME;
}
}