chromium/android_webview/nonembedded/java/src/org/chromium/android_webview/services/AwVariationsSeedFetcher.java

// Copyright 2018 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.android_webview.services;

import android.app.job.JobInfo;
import android.app.job.JobParameters;
import android.app.job.JobScheduler;
import android.app.job.JobService;
import android.content.ComponentName;
import android.content.Context;
import android.os.PersistableBundle;

import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;

import org.chromium.android_webview.common.AwSwitches;
import org.chromium.android_webview.common.VariationsFastFetchModeUtils;
import org.chromium.android_webview.common.variations.VariationsServiceMetricsHelper;
import org.chromium.android_webview.common.variations.VariationsUtils;
import org.chromium.base.CommandLine;
import org.chromium.base.ContextUtils;
import org.chromium.base.Log;
import org.chromium.base.ResettersForTesting;
import org.chromium.base.task.AsyncTask;
import org.chromium.base.task.BackgroundOnlyAsyncTask;
import org.chromium.base.task.PostTask;
import org.chromium.base.task.TaskTraits;
import org.chromium.base.version_info.Channel;
import org.chromium.base.version_info.VersionConstants;
import org.chromium.components.background_task_scheduler.TaskIds;
import org.chromium.components.variations.firstrun.VariationsSeedFetcher;
import org.chromium.components.variations.firstrun.VariationsSeedFetcher.SeedFetchInfo;
import org.chromium.components.variations.firstrun.VariationsSeedFetcher.SeedInfo;

import java.net.HttpURLConnection;
import java.util.Date;
import java.util.Random;
import java.util.concurrent.Executor;
import java.util.concurrent.TimeUnit;

/**
 * AwVariationsSeedFetcher is a JobService which periodically downloads the variations seed. The job
 * is scheduled whenever an app requests the seed, and it's been at least 1 day since the last
 * fetch. If WebView is never used, the job will never run. The 1-day minimum fetch period is chosen
 * as a trade-off between seed freshness (and prompt delivery of feature killswitches) and data and
 * battery usage. Various Android versions may enforce longer periods, depending on WebView usage
 * and battery-saving features. AwVariationsSeedFetcher is not meant to be used outside the
 * variations service. For the equivalent fetch in Chrome, see AsyncInitTaskRunner$FetchSeedTask.
 */
// TODO(crbug.com/40842120): consider using BackgroundTaskScheduler instead of JobService
public class AwVariationsSeedFetcher extends JobService {
    @VisibleForTesting public static final String JOB_REQUEST_COUNT_KEY = "RequestCount";
    @VisibleForTesting public static final int JOB_MAX_REQUEST_COUNT = 5;
    // Represents whether the currently scheduled job is Fast Mode seed fetches or a normal seed
    // fetch. This also enables the seed fetcher to check the status of whether a SafeMode seed
    // fetch has already been requested, preventing unnecessary repeated requests and enabling the
    // seed fetcher to determine if a regularly shceduled seed fetch request should be cancelled.
    @VisibleForTesting public static final String JOB_REQUEST_FAST_MODE = "RequestFastMode";
    @VisibleForTesting public static final String PERIODIC_FAST_MODE = "PeriodicFastMode";

    private static final String TAG = "AwVariationsSeedFet-";
    private static final int JOB_ID = TaskIds.WEBVIEW_VARIATIONS_SEED_FETCH_JOB_ID;
    private static final long MIN_JOB_PERIOD_MILLIS = TimeUnit.HOURS.toMillis(12);
    private static final int JOB_BACKOFF_POLICY = JobInfo.BACKOFF_POLICY_EXPONENTIAL;
    private static final long JOB_INITIAL_BACKOFF_TIME_IN_MS = TimeUnit.MINUTES.toMillis(5);
    // Want to test with a small, but non-zero value to imitate behavior more similar to
    // what will be in production. Using zero for testing risks testing behavior that may
    // not show faulty behavior that a non-zero jitter would. This also allows for a small
    // enough delay that it should not massively affect automated testing.
    private static final int SMALL_JITTER_IN_MS = 5;

    /** Clock used to fake time in tests. */
    public interface Clock {
        long currentTimeMillis();
    }

    private static JobScheduler sMockJobScheduler;
    private static VariationsSeedFetcher sMockDownloader;
    private static Clock sTestClock;
    private static Date sDateForTesting;

    private FetchTask mFetchTask;
    private static final int sJitter =
            new Random().nextInt((int) VariationsFastFetchModeUtils.MAX_ALLOWABLE_SEED_AGE_MS);
    private static boolean sUseSmallJitterForTesting;

    private static long currentTimeMillis() {
        if (sTestClock != null) {
            return sTestClock.currentTimeMillis();
        }
        return System.currentTimeMillis();
    }

    private static String getChannelStr() {
        switch (VersionConstants.CHANNEL) {
            case Channel.STABLE:
                return "stable";
            case Channel.BETA:
                return "beta";
            case Channel.DEV:
                return "dev";
            case Channel.CANARY:
                return "canary";
            default:
                return null;
        }
    }

    private static JobInfo getPendingJob(JobScheduler scheduler) {
        return scheduler.getPendingJob(JOB_ID);
    }

    private static JobScheduler getScheduler() {
        if (sMockJobScheduler != null) return sMockJobScheduler;

        // This may be null due to vendor framework bugs. https://crbug.com/968636
        return (JobScheduler)
                ContextUtils.getApplicationContext()
                        .getSystemService(Context.JOB_SCHEDULER_SERVICE);
    }

    /** Determines whether the currently scheduled job is in Fast Mode. */
    private static boolean isFastModeJob(@Nullable PersistableBundle bundle) {
        if (bundle == null) return false;
        // Default to assume WebView is not in Fast Mode
        return bundle.getBoolean(JOB_REQUEST_FAST_MODE);
    }

    /**
     * Cancels SafeMode seed fetch jobs on the scheduler.
     * Assumes that scheduleIfNeeded(true) has been previously called during activation
     * of FastFetch mode, causing periodic seed fetch requests. This prevents unnecessary
     * periodic seed fetches, instead of the usual single-fire seed fetch requests for
     * non-SafeMode seed fetches.
     */
    public static void cancelSafeModeSeedFetchSchedulerJob() {
        JobScheduler scheduler = getScheduler();
        if (scheduler == null) return;
        if (getPendingJob(scheduler) != null) {
            VariationsUtils.debugLog("Cancelling SafeMode seed download job.");
            scheduler.cancel(JOB_ID);
        }
    }

    /**
     * This method returns whether the incoming job request should be scheduled or not based on
     * three conditions in descending order of priority:
     * 1. No job is currently scheduled
     * 2. Whether the command line switch - finch-seed-ignore-pending-download is set
     * 3. The incoming job request is for Fast Mode and there is a regular seed fetch job already
     * scheduled
     *
     * It also cancels currently scheduled jobs as need based on two conditions:
     * 1. The command line switch - finch-seed-ignore-pending-download is set and the currently
     * scheduled job is for Fast Mode. This prevents multiple periodic seed fetch jobs, since the
     * Fast Mode jobs execute periodically.
     * 2. If no Fast Mode job is scheduled and the incoming job request is for a Fast Mode job.
     * This occurs when a regular seed fetch job has been scheduled, but should be canceled to free
     * up resources for the higher priority Fast Mode job. Note: These conditions are dependent on a
     * job being scheduled. Otherwise, no jobs can be canceled.
     *
     * @param scheduler Reference to the job scheduler
     * @param requireFastMode Indicates if the incoming job request is for Fast Mode
     *
     * @return A return value of true indicates the scheduler will NOT schedule a new job. A return
     *         value of false means that the scheduler will schedule the new job as requested.
     */
    private static boolean handlePreviouslyScheduledJob(
            JobScheduler scheduler, boolean requireFastMode) {
        // Check if a job is already scheduled.
        JobInfo jobInfo = getPendingJob(scheduler);
        if (jobInfo == null) return false;

        PersistableBundle bundle = jobInfo.getExtras();
        boolean inFastMode = isFastModeJob(bundle);
        boolean ignorePendingDownload =
                CommandLine.getInstance().hasSwitch(AwSwitches.FINCH_SEED_IGNORE_PENDING_DOWNLOAD);

        if (ignorePendingDownload) {
            if (inFastMode) {
                // Prevent multiple periodic Fast Mode seed fetches
                scheduler.cancel(JOB_ID);
            }
            return false;
        }
        if (!inFastMode && requireFastMode) {
            // This is when a regular seed fetch job is scheduled, no SafeMode seed fetch is
            // scheduled. There is a need to cancel this seed fetch and reschedule the
            // higher priority, unrestricted seed fetch.

            // The situation where scheduleIfNeeded(true) is called is
            // only expected to ever occur once before a deactivate command is subsequently
            // made, cancelling all seed fetch jobs.
            scheduler.cancel(JOB_ID);
            VariationsUtils.debugLog(
                    "Regular seed download job already scheduled. "
                            + "Canceling for Fast Mode job.");
            return false;
        }

        // A job is already scheduled and meets our requirements
        VariationsUtils.debugLog("Seed download job already scheduled");
        return true;
    }

    /** Should only be called by {@link VariationsSeedServer} for non-SafeMode seed fetches. */
    public static void scheduleIfNeeded() {
        scheduleIfNeeded(false);
    }

    /**
     * There are two types of calls made to this scheduler - SafeMode and regular seed fetches.
     * Normal seed fetches are made via bound IPC through {@link VariationsSeedServer}.
     * SafeMode seed fetches are made through {@link NonEmbeddedFastVariationsSeedSafeModeAction}.
     *
     * @param fastVariationsSeed Indicates whether the request is for a SafeMode seed fetch,
     * which requires a higher priority and less restricted seed fetch job, or a regular seed fetch
     * job.
     */
    public static void scheduleIfNeeded(boolean requireFastMode) {
        JobScheduler scheduler = getScheduler();
        if (scheduler == null) return;
        boolean alreadyScheduled = handlePreviouslyScheduledJob(scheduler, requireFastMode);
        if (alreadyScheduled) return;

        // Note: we don't throttle fast mode, since we need to set up a periodic job,
        // and it already has random delay on the initial fetch.
        if (!requireFastMode && hasFetchTaskRunRecently()) {
            VariationsUtils.debugLog("Throttling seed download job");
            return;
        }

        VariationsUtils.debugLog("Scheduling seed download job");
        scheduleJob(scheduler, requireFastMode, /* requestPeriodicFastMode= */ false);
    }

    private static boolean hasFetchTaskRunRecently() {
        // Check how long it's been since FetchTask last ran.
        long lastRequestTime = VariationsUtils.getStampTime();
        if (lastRequestTime != 0) {
            long now = currentTimeMillis();
            long minJobPeriodMillis =
                    VariationsUtils.getDurationSwitchValueInMillis(
                            AwSwitches.FINCH_SEED_MIN_DOWNLOAD_PERIOD, MIN_JOB_PERIOD_MILLIS);
            // At this point when requireFastMode == true, we have likely recently
            // scheduled/completed a regular seed fetch On top of that FastMode is expected to
            // only ever schedule a periodic job once Since we still want to schedule a periodic
            // seed fetch, ignore the minimum seed fetch request time frame (once) so that we can
            // still schedule the periodic seed fetch for Fast Mode.
            return now < lastRequestTime + minJobPeriodMillis;
        }
        return false;
    }

    @VisibleForTesting
    public static void scheduleJob(
            JobScheduler scheduler, boolean requireFastMode, boolean requestPeriodicFastMode) {
        Context context = ContextUtils.getApplicationContext();
        ComponentName thisComponent = new ComponentName(context, AwVariationsSeedFetcher.class);
        PersistableBundle extras = new PersistableBundle(/* capacity= */ 2);
        extras.putInt(JOB_REQUEST_COUNT_KEY, 0);
        extras.putBoolean(JOB_REQUEST_FAST_MODE, requireFastMode);
        extras.putBoolean(PERIODIC_FAST_MODE, requestPeriodicFastMode);
        JobInfo.Builder builder =
                new JobInfo.Builder(JOB_ID, thisComponent)
                        .setRequiredNetworkType(JobInfo.NETWORK_TYPE_ANY)
                        .setBackoffCriteria(JOB_INITIAL_BACKOFF_TIME_IN_MS, JOB_BACKOFF_POLICY);
        if (requireFastMode) {
            long backoffTime =
                    sUseSmallJitterForTesting ? SMALL_JITTER_IN_MS : TimeUnit.MINUTES.toMillis(1);
            builder =
                    builder.setBackoffCriteria(backoffTime, JobInfo.BACKOFF_POLICY_LINEAR)
                            .setPersisted(true);

            boolean isInitialRequest = !requestPeriodicFastMode;
            if (isInitialRequest) {
                // The jitter is used to create a more uniform distribution of seed fetch requests
                // for the population. Adding jitter to the initial request helps space them out
                // more evenly as the mitigation is deployed so the seed fetches are not requested
                // all at once even if SafeMode is enabled simultaneously on many devices.
                builder =
                        builder.setMinimumLatency(
                                sUseSmallJitterForTesting ? SMALL_JITTER_IN_MS : sJitter);
            } else {
                builder =
                        builder.setPeriodic(VariationsFastFetchModeUtils.MAX_ALLOWABLE_SEED_AGE_MS);
            }
        } else {
            boolean requiresCharging =
                    !CommandLine.getInstance()
                            .hasSwitch(AwSwitches.FINCH_SEED_NO_CHARGING_REQUIREMENT);
            builder = builder.setRequiresCharging(requiresCharging);
        }
        builder = builder.setExtras(extras);
        if (scheduler.schedule(builder.build()) == JobScheduler.RESULT_SUCCESS) {
            VariationsServiceMetricsHelper metrics =
                    VariationsServiceMetricsHelper.fromVariationsSharedPreferences(context);
            metrics.setLastEnqueueTime(currentTimeMillis());
            if (!metrics.writeMetricsToVariationsSharedPreferences(context)) {
                Log.e(TAG, "Failed to write variations SharedPreferences to disk");
            }
        } else {
            Log.e(TAG, "Failed to schedule job");
        }
    }

    private class FetchTask extends BackgroundOnlyAsyncTask<Void> {
        private JobParameters mParams;

        FetchTask(JobParameters params) {
            mParams = params;
        }

        @Override
        protected Void doInBackground() {
            PersistableBundle bundle = mParams != null ? mParams.getExtras() : null;
            boolean fastMode = isFastModeJob(bundle);
            boolean periodicFastModeJob = isPeriodicFastModeJob(bundle);
            FetchSeedOutput output = new FetchSeedOutput();

            try {
                output = fetchSeed(fastMode);
                if (output.getCancelled()) {
                    return null;
                } else if (fastMode && !periodicFastModeJob) {
                    scheduleJob(
                            getScheduler(),
                            /* requireFastMode= */ true,
                            /* requestPeriodicFastMode= */ true);
                    output =
                            new FetchSeedOutput(
                                    /* shouldFinish= */ output.getShouldFinish(),
                                    /* needsReschedule= */ false,
                                    /* cancelled= */ output.getCancelled());
                    mParams.getExtras().putBoolean(PERIODIC_FAST_MODE, true);
                }
            } finally {
                // Continually reschedule Fast Mode jobs until the SafeMode "off" command is given
                if (output.getShouldFinish()) onFinished(mParams, output.getNeedsReschedule());
            }

            return null;
        }

        private void saveMetrics(long startTime, long endTime) {
            Context context = ContextUtils.getApplicationContext();
            VariationsServiceMetricsHelper metrics =
                    VariationsServiceMetricsHelper.fromVariationsSharedPreferences(context);
            if (metrics.hasLastEnqueueTime()) {
                metrics.setJobQueueTime(startTime - metrics.getLastEnqueueTime());
            }
            if (metrics.hasLastJobStartTime()) {
                metrics.setJobInterval(startTime - metrics.getLastJobStartTime());
            }
            metrics.clearLastEnqueueTime();
            metrics.setLastJobStartTime(startTime);
            if (!metrics.writeMetricsToVariationsSharedPreferences(context)) {
                Log.e(TAG, "Failed to write variations SharedPreferences to disk");
            }
        }

        private FetchSeedOutput fetchSeed(boolean fastMode) {
            long startTime = currentTimeMillis();
            // Should we call onFinished at the end of this task?
            boolean shouldFinish = true;
            // Should we retry the job?
            boolean needsReschedule = false;
            PersistableBundle bundle = mParams != null ? mParams.getExtras() : null;
            VariationsUtils.updateStampTime();
            SeedInfo info = VariationsUtils.readSeedFile(VariationsUtils.getSeedFile());
            VariationsUtils.debugLog(String.format("Downloading new seed [fastMode=%B]", fastMode));

            VariationsSeedFetcher downloader =
                    sMockDownloader != null ? sMockDownloader : VariationsSeedFetcher.get();
            String milestone = String.valueOf(VersionConstants.PRODUCT_MAJOR_VERSION);

            final VariationsSeedFetcher.SeedFetchParameters params =
                    VariationsSeedFetcher.SeedFetchParameters.Builder.newBuilder()
                            .setPlatform(VariationsSeedFetcher.VariationsPlatform.ANDROID_WEBVIEW)
                            .setMilestone(milestone)
                            .setChannel(getChannelStr())
                            .setIsFastFetchMode(fastMode)
                            .build();
            SeedFetchInfo fetchInfo = downloader.downloadContent(params, info);

            saveMetrics(startTime, /* endTime= */ currentTimeMillis());

            if (isCancelled()) {
                return new FetchSeedOutput(
                        /* shouldFinish= */ false,
                        /* needsReschedule= */ false,
                        /* cancelled= */ true);
            }

            // VariationsSeedFetcher returns HttpURLConnection.HTTP_NOT_MODIFIED if seed did
            // not change server-side, or HttpURLConnection.HTTP_OK if a new seed was
            // successfully fetched
            if (HttpURLConnection.HTTP_OK != fetchInfo.seedFetchResult
                    && HttpURLConnection.HTTP_NOT_MODIFIED != fetchInfo.seedFetchResult) {
                int requestCount = 0;
                if (bundle != null) {
                    requestCount = bundle.getInt(JOB_REQUEST_COUNT_KEY) + 1;
                    bundle.putInt(JOB_REQUEST_COUNT_KEY, requestCount);
                }
                // Limit the retries to JOB_MAX_REQUEST_COUNT.
                needsReschedule = (requestCount <= JOB_MAX_REQUEST_COUNT);
            }
            if (fetchInfo.seedInfo != null) {
                VariationsSeedHolder.getInstance()
                        .updateSeed(
                                fetchInfo.seedInfo,
                                /* onFinished= */ () -> {
                                    onFinished(mParams, /* needsReschedule= */ false);
                                });
                shouldFinish = false; // jobFinished will be deferred until updateSeed is done.
            }
            return new FetchSeedOutput(shouldFinish, needsReschedule, /* cancelled= */ false);
        }

        private class FetchSeedOutput {
            private boolean mShouldFinish;
            private boolean mNeedsReschedule;
            private boolean mCancelled;

            public boolean getShouldFinish() {
                return mShouldFinish;
            }

            public boolean getNeedsReschedule() {
                return mNeedsReschedule;
            }

            public boolean getCancelled() {
                return mCancelled;
            }

            public FetchSeedOutput() {
                mShouldFinish = true;
                mNeedsReschedule = false;
                mCancelled = false;
            }

            public FetchSeedOutput(
                    boolean shouldFinish, boolean needsReschedule, boolean cancelled) {
                mShouldFinish = shouldFinish;
                mNeedsReschedule = needsReschedule;
                mCancelled = cancelled;
            }
        }

        /** Determines whether the currently scheduled job is in Fast Mode and periodic. */
        private boolean isPeriodicFastModeJob(@Nullable PersistableBundle bundle) {
            if (bundle == null) return false;
            // Default to assume WebView is not in Fast Mode
            return bundle.getBoolean(PERIODIC_FAST_MODE);
        }
    }

    @Override
    public boolean onStartJob(JobParameters params) {
        // If this process has survived since the last run of this job, mFetchTask could still
        // exist. Either way, (re)create it with the new params.
        mFetchTask = new FetchTask(params);
        if (params != null && isFastModeJob(params.getExtras())) {
            final Executor userBlockingExecutor =
                    (Runnable r) -> PostTask.postTask(TaskTraits.USER_BLOCKING, r);
            mFetchTask.executeOnExecutor(userBlockingExecutor);
        } else {
            mFetchTask.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
        }
        return true;
    }

    @Override
    public boolean onStopJob(JobParameters params) {
        if (mFetchTask != null) {
            mFetchTask.cancel(true);
            mFetchTask = null;
        }
        return false;
    }

    protected void onFinished(JobParameters params, boolean needsReschedule) {
        assert params.getJobId() == JOB_ID;
        jobFinished(params, needsReschedule);
    }

    public static void setMocks(JobScheduler scheduler, VariationsSeedFetcher fetcher) {
        sMockJobScheduler = scheduler;
        sMockDownloader = fetcher;
    }

    public static void setTestClock(Clock clock) {
        sTestClock = clock;
    }

    public static void setUseSmallJitterForTesting() {
        sUseSmallJitterForTesting = true;
        ResettersForTesting.register(() -> sUseSmallJitterForTesting = false);
    }

    public static void setDateForTesting(Date date) {
        sDateForTesting = date;
        ResettersForTesting.register(() -> sDateForTesting = null);
    }

    private static long getCurrentTimestamp() {
        return sDateForTesting != null ? sDateForTesting.getTime() : new Date().getTime();
    }

    /** Determines whether the currently scheduled job is in Fast Mode and periodic. */
    @VisibleForTesting
    public static boolean periodicFastModeJobScheduled() {
        JobScheduler scheduler = getScheduler();
        if (scheduler == null) return false;
        JobInfo job = scheduler.getPendingJob(JOB_ID);
        if (job == null) return false;
        PersistableBundle extras = job.getExtras();
        if (extras == null) return false;

        return extras.getBoolean(PERIODIC_FAST_MODE);
    }
}