chromium/chrome/browser/ui/android/hats/internal/java/src/org/chromium/chrome/browser/ui/hats/SurveyThrottler.java

// 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.

package org.chromium.chrome.browser.ui.hats;

import androidx.annotation.IntDef;
import androidx.annotation.VisibleForTesting;

import org.chromium.base.CommandLine;
import org.chromium.base.ThreadUtils;
import org.chromium.base.metrics.RecordHistogram;
import org.chromium.chrome.browser.firstrun.FirstRunStatus;
import org.chromium.chrome.browser.flags.ChromeSwitches;
import org.chromium.components.browser_ui.util.date.CalendarFactory;

import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.util.Calendar;
import java.util.Random;

/**
 * Class used to check whether survey can be shown based on metadata. The class is also responsible
 * to collect survey related information, and update the metadata for a triggerId.
 *
 * <p>Internally, the class checks criteria(s) for the given trggerId and global state. If all the
 * throttling checks passed, the instance will perform a dice roll to decide if the survey can show
 * based on the probability.
 */
public class SurveyThrottler {
    private static final int MIN_DAYS_BETWEEN_ANY_PROMPT_DISPLAYED = 180;

    /**
     * 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_PROMPT_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.FIRST_TIME_USER,
        FilteringResult.USER_PROMPT_SURVEY,
        FilteringResult.OTHER_SURVEY_DISPLAYED_RECENTLY,
        FilteringResult.NUM_ENTRIES
    })
    @Retention(RetentionPolicy.SOURCE)
    public @interface FilteringResult {
        int SURVEY_PROMPT_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 FIRST_TIME_USER = 8;
        int USER_PROMPT_SURVEY = 9;
        int OTHER_SURVEY_DISPLAYED_RECENTLY = 10;

        // Number of entries
        int NUM_ENTRIES = 11;
    }

    private final SurveyConfig mSurveyConfig;
    private SurveyMetadata mMetadata;

    /**
     * @param config Survey config associated with the throttler.
     */
    SurveyThrottler(SurveyConfig config) {
        mSurveyConfig = config;
    }

    /**
     * @return Whether the given survey can be shown.
     */
    public boolean canShowSurvey() {
        // Assert to be run on the background thread, since reading calendar can be a blocking call.
        ThreadUtils.assertOnBackgroundThread();

        if (isSurveyForceEnabled()) {
            recordSurveyFilteringResult(FilteringResult.FORCE_SURVEY_ON_COMMAND_PRESENT);
            return true;
        }

        if (FirstRunStatus.isFirstRunTriggered()) {
            recordSurveyFilteringResult(FilteringResult.FIRST_TIME_USER);
            return false;
        }

        // Ignore the random selection since it's a user prompt survey.
        if (mSurveyConfig.mUserPrompted) {
            recordSurveyFilteringResult(FilteringResult.USER_PROMPT_SURVEY);
            return true;
        }

        return isRandomlySelectedForSurvey();
    }

    /** Logs in SharedPreferences that the survey prompt was displayed. */
    public void recordSurveyPromptDisplayed() {
        getMetadata().setPromptDisplayed();
    }

    /**
     * Rolls a random number to see if the user was eligible for the survey. The user will skip the
     * roll if: 1. User is a first time user; 2. User has performed the roll for the survey today;
     * 3. Max number is not setup correctly.
     *
     * @return Whether the user is eligible (i.e. the random number rolled was 0).
     */
    private boolean isRandomlySelectedForSurvey() {
        int lastDiceRolledDate = getMetadata().getSurveyLastDiceRolledDate();
        int today = getMetadata().getCurrentDate();
        if (lastDiceRolledDate == today) {
            recordSurveyFilteringResult(FilteringResult.USER_ALREADY_SAMPLED_TODAY);
            return false;
        }

        if (mSurveyConfig.mProbability <= 0) {
            recordSurveyFilteringResult(FilteringResult.MAX_NUMBER_MISSING);
            return false;
        }

        // Do not roll when current survey is displayed previously.
        // TODO(crbug.com/40759323): Support configure display survey again by the client.
        if (getMetadata().getLastPromptDisplayedDate() > 0) {
            recordSurveyFilteringResult(FilteringResult.SURVEY_PROMPT_ALREADY_DISPLAYED);
            return false;
        }

        if (today - SurveyMetadata.getLastPromptDisplayedDateForAnySurvey()
                < MIN_DAYS_BETWEEN_ANY_PROMPT_DISPLAYED) {
            recordSurveyFilteringResult(FilteringResult.OTHER_SURVEY_DISPLAYED_RECENTLY);
            return false;
        }

        getMetadata().setDiceRolled();
        if (isSelectedWithByRandom()) {
            recordSurveyFilteringResult(FilteringResult.USER_SELECTED_FOR_SURVEY);
            return true;
        } else {
            recordSurveyFilteringResult(FilteringResult.ROLLED_NON_ZERO_NUMBER);
            return false;
        }
    }

    private SurveyMetadata getMetadata() {
        // Create the metadata lazily since getEncodedDate might be blocking.
        if (mMetadata == null) {
            mMetadata = new SurveyMetadata(mSurveyConfig.mTriggerId, this::getEncodedDate);
        }
        return mMetadata;
    }

    @VisibleForTesting
    boolean isSelectedWithByRandom() {
        return new Random().nextFloat() <= mSurveyConfig.mProbability;
    }

    /**
     * Return the encoded date as int based on the current year and day of year from the calendar.
     */
    @VisibleForTesting
    int getEncodedDate() {
        return getEncodedDateImpl(CalendarFactory.get());
    }

    static int getEncodedDateImpl(Calendar calendar) {
        return calendar.get(Calendar.YEAR) * 366 + calendar.get(Calendar.DAY_OF_YEAR);
    }

    private static void recordSurveyFilteringResult(@FilteringResult int value) {
        // TODO(crbug.com/40283353): Add per-survey metrics.
        RecordHistogram.recordEnumeratedHistogram(
                "Android.Survey.SurveyFilteringResults", value, FilteringResult.NUM_ENTRIES);
    }

    /** @return Whether survey is enabled by command line flag. */
    private static boolean isSurveyForceEnabled() {
        return CommandLine.getInstance().hasSwitch(ChromeSwitches.CHROME_FORCE_ENABLE_SURVEY);
    }
}