// 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.variations;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.content.pm.PackageManager.NameNotFoundException;
import android.os.Bundle;
import android.os.IBinder;
import android.os.ParcelFileDescriptor;
import android.os.RemoteException;
import android.os.SystemClock;
import androidx.annotation.NonNull;
import androidx.annotation.VisibleForTesting;
import org.jni_zero.CalledByNative;
import org.jni_zero.JNINamespace;
import org.jni_zero.NativeMethods;
import org.chromium.android_webview.AwBrowserProcess;
import org.chromium.android_webview.common.AwSwitches;
import org.chromium.android_webview.common.services.IVariationsSeedServer;
import org.chromium.android_webview.common.services.IVariationsSeedServerCallback;
import org.chromium.android_webview.common.services.ServiceConnectionDelayRecorder;
import org.chromium.android_webview.common.services.ServiceNames;
import org.chromium.android_webview.common.variations.VariationsServiceMetricsHelper;
import org.chromium.android_webview.common.variations.VariationsUtils;
import org.chromium.base.ContextUtils;
import org.chromium.base.Log;
import org.chromium.base.metrics.RecordHistogram;
import org.chromium.base.task.PostTask;
import org.chromium.base.task.TaskTraits;
import org.chromium.components.variations.LoadSeedResult;
import java.io.File;
import java.io.FileNotFoundException;
import java.util.Date;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.FutureTask;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
/**
* VariationsSeedLoader asynchronously loads and updates the variations seed. VariationsSeedLoader
* wraps a Runnable which wraps a FutureTask. The FutureTask loads the seed. The Runnable invokes
* the FutureTask and then updates the seed if necessary, possibly requesting a new seed from
* VariationsSeedServer. The reason for splitting the work this way is that WebView startup must
* block on loading the seed (by using FutureTask.get()) but should not block on the other work done
* by the Runnable.
*
* The Runnable and FutureTask together perform these steps:
* 1. Pre-load the metrics client ID. This is needed to seed the EntropyProvider. If there is no
* client ID, variations can't be used on this run.
* 2. Load the new seed file, if any.
* 3. If no new seed file, load the old seed file, if any.
* 4. Make the loaded seed available via get() (or null if there was no seed).
* 5. If there was a new seed file, replace the old with the new (but only after making the loaded
* seed available, as the replace need not block startup).
* 6. If there was no seed, or the loaded seed was expired, request a new seed (but don't request
* more often than MAX_REQUEST_PERIOD_MILLIS).
*
* VariationsSeedLoader should be used during WebView startup like so:
* 1. Ensure ContextUtils.getApplicationContext(), AwBrowserProcess.getWebViewPackageName(), and
* PathUtils are ready to use.
* 2. As early as possible, call startVariationsInit() to begin the task.
* 3. Perform any WebView startup tasks which don't require variations to be initialized.
* 4. Call finishVariationsInit() with the value returned from startVariationsInit(). This will
* block for up to SEED_LOAD_TIMEOUT_MILLIS if the task hasn't fininshed loading the seed. If the
* seed is loaded on time, variations will be initialized. finishVariationsInit() must be called
* before AwFeatureListCreator::SetUpFieldTrials() runs.
*/
@JNINamespace("android_webview")
public class VariationsSeedLoader {
private static final String TAG = "VariationsSeedLoader";
// The expiration time for an app's copy of the Finch seed, after which we'll still use it,
// but we'll request a new one from VariationsSeedService.
private static final long SEED_EXPIRATION_MILLIS = TimeUnit.HOURS.toMillis(6);
// After requesting a new seed, wait at least this long before making a new request.
private static final long MAX_REQUEST_PERIOD_MILLIS = TimeUnit.HOURS.toMillis(1);
// Block in finishVariationsInit() for at most this value waiting for the seed. If the timeout
// is exceeded, proceed with variations disabled, and record the event in the
// Variations.SeedLoadResult histogram's "Seed Load Timed Out" bucket. See the discussion on
// https://crbug.com/936172 about the trade-offs of increasing or decreasing this value.
private static final long SEED_LOAD_TIMEOUT_MILLIS = 20;
@VisibleForTesting
public static final String APP_SEED_FRESHNESS_HISTOGRAM_NAME = "Variations.AppSeedFreshness";
@VisibleForTesting
public static final String SEED_FRESHNESS_DIFF_HISTOGRAM_NAME = "Variations.SeedFreshnessDiff";
@VisibleForTesting
public static final String DOWNLOAD_JOB_INTERVAL_HISTOGRAM_NAME =
"Variations.WebViewDownloadJobInterval";
@VisibleForTesting
public static final String DOWNLOAD_JOB_QUEUE_TIME_HISTOGRAM_NAME =
"Variations.WebViewDownloadJobQueueTime";
private static final String SEED_LOAD_BLOCKING_TIME_HISTOGRAM_NAME =
"Variations.SeedLoadBlockingTime";
// This metric is also written by VariationsSeedStore::LoadSeed and is used by other platforms.
private static final String SEED_LOAD_RESULT_HISTOGRAM_NAME = "Variations.SeedLoadResult";
// These two variables below are used for caching the difference between Seed and
// AppSeed Freshness.
private static long sCachedSeedFreshness;
private static long sCachedAppSeedFreshness;
private FutureTask<SeedLoadResult> mLoadTask;
private SeedServerCallback mSeedServerCallback = new SeedServerCallback();
private static void recordLoadSeedResult(@LoadSeedResult int result) {
RecordHistogram.recordEnumeratedHistogram(
SEED_LOAD_RESULT_HISTOGRAM_NAME, result, LoadSeedResult.MAX_VALUE + 1);
}
private static void recordSeedLoadBlockingTime(long timeMs) {
RecordHistogram.recordTimesHistogram(SEED_LOAD_BLOCKING_TIME_HISTOGRAM_NAME, timeMs);
}
private static void recordAppSeedFreshness(long appSeedFreshnessMinutes) {
// Bucket parameters should match Variations.SeedFreshness.
// See variations::RecordSeedFreshness.
RecordHistogram.recordCustomCountHistogram(
APP_SEED_FRESHNESS_HISTOGRAM_NAME,
(int) appSeedFreshnessMinutes,
/* min= */ 1,
/* max= */ (int) TimeUnit.DAYS.toMinutes(30),
/* numBuckets= */ 50);
cacheAppSeedFreshness(appSeedFreshnessMinutes);
}
// This method is to cache the AppSeedFreshness value
@VisibleForTesting
public static void cacheAppSeedFreshness(long appSeedFreshnessMinutes) {
if (appSeedFreshnessMinutes < 0) {
return;
}
sCachedAppSeedFreshness = appSeedFreshnessMinutes;
calculateSeedFreshnessDiff();
}
// This method is to cache the SeedFreshness value
@CalledByNative
@VisibleForTesting
public static void cacheSeedFreshness(long seedFreshness) {
if (seedFreshness < 0) {
return;
}
sCachedSeedFreshness = seedFreshness;
calculateSeedFreshnessDiff();
}
// This method is to calculate the difference between SeedFreshness
// and AppSeedFreshness
private static void calculateSeedFreshnessDiff() {
if (sCachedSeedFreshness == 0 || sCachedAppSeedFreshness == 0) {
return;
}
long diff = sCachedSeedFreshness - sCachedAppSeedFreshness;
recordAppSeedFreshnessDiff(diff);
}
// This method is to record the difference between SeedFreshness
// and AppSeedFreshness
private static void recordAppSeedFreshnessDiff(long diff) {
RecordHistogram.recordCustomCountHistogram(
SEED_FRESHNESS_DIFF_HISTOGRAM_NAME,
(int) diff,
/* min= */ 1,
/* max= */ (int) TimeUnit.DAYS.toMinutes(30),
/* numBuckets= */ 50);
sCachedSeedFreshness = 0;
sCachedAppSeedFreshness = 0;
}
private static void recordMinuteHistogram(String name, long value, long maxValue) {
// 50 buckets from 1min to maxValue minutes.
RecordHistogram.recordCustomCountHistogram(name, (int) value, 1, (int) maxValue, 50);
}
private static boolean shouldThrottleRequests(long now) {
long lastRequestTime = VariationsUtils.getStampTime();
if (lastRequestTime == 0) {
return false;
}
long maxRequestPeriodMillis =
VariationsUtils.getDurationSwitchValueInMillis(
AwSwitches.FINCH_SEED_MIN_UPDATE_PERIOD, MAX_REQUEST_PERIOD_MILLIS);
return now < lastRequestTime + maxRequestPeriodMillis;
}
private boolean isSeedExpired(long seedFileTime) {
long expirationDuration =
VariationsUtils.getDurationSwitchValueInMillis(
AwSwitches.FINCH_SEED_EXPIRATION_AGE, SEED_EXPIRATION_MILLIS);
return getCurrentTimeMillis() > seedFileTime + expirationDuration;
}
public static boolean parseAndSaveSeedFile(File seedFile) {
if (!VariationsSeedLoaderJni.get().parseAndSaveSeedProto(seedFile.getPath())) {
VariationsUtils.debugLog("Failed reading seed file \"" + seedFile + '"');
return false;
}
return true;
}
public static boolean parseAndSaveSeedProtoFromByteArray(byte[] seedAsByteArray) {
if (!VariationsSeedLoaderJni.get().parseAndSaveSeedProtoFromByteArray(seedAsByteArray)) {
VariationsUtils.debugLog("Failed reading seed as string");
return false;
}
return true;
}
public static void maybeRecordSeedFileTime(long seedFileTime) {
if (seedFileTime != 0) {
long freshnessMinutes =
TimeUnit.MILLISECONDS.toMinutes(new Date().getTime() - seedFileTime);
recordAppSeedFreshness(freshnessMinutes);
}
}
/** Result of loading the local copy of the seed. */
private static class SeedLoadResult {
/** Whether the seed was loaded successfully. */
final boolean mLoadedSeed;
/**
* The "date" field of our local seed, converted to milliseconds since epoch, or
* Long.MIN_VALUE if we have no seed. This value originates from the server.
*/
final long mCurrentSeedDate;
/**
* The time, in milliseconds since the UNIX epoch, our local copy of the seed was last
* written to disk as measured by the device's clock.
*/
final long mSeedFileTime;
private SeedLoadResult(boolean loadedSeed, long mCurrentSeedDate, long seedFileTime) {
this.mLoadedSeed = loadedSeed;
this.mCurrentSeedDate = mCurrentSeedDate;
this.mSeedFileTime = seedFileTime;
}
}
/**
* Load the current variations seed file.
*
* <p>This method should be posted to a background thread to ensure that other initialization
* work can happen concurrently.
*/
@NonNull
private SeedLoadResult loadSeedFile() {
File newSeedFile = VariationsUtils.getNewSeedFile();
File oldSeedFile = VariationsUtils.getSeedFile();
long currentSeedDate = Long.MIN_VALUE;
long seedFileTime = 0;
boolean loadedSeed = false;
boolean foundNewSeed = false;
// First check for a new seed.
if (parseAndSaveSeedFile(newSeedFile)) {
loadedSeed = true;
seedFileTime = newSeedFile.lastModified();
// If a valid new seed was found, make a note to replace the old
// seed with the new seed. (Don't do it now, to avoid delaying
// FutureTask.get().)
foundNewSeed = true;
} else if (parseAndSaveSeedFile(oldSeedFile)) {
// If no new seed, check for an old one.
loadedSeed = true;
seedFileTime = oldSeedFile.lastModified();
}
// Make a note to request a new seed if necessary. (Don't request it
// now, to avoid delaying FutureTask.get().)
boolean needNewSeed = false;
if (!loadedSeed || isSeedExpired(seedFileTime)) {
// Rate-limit the requests.
needNewSeed = !shouldThrottleRequests(getCurrentTimeMillis());
}
// Save the date field of whatever seed was loaded, if any.
if (loadedSeed) {
currentSeedDate = VariationsSeedLoaderJni.get().getSavedSeedDate();
}
// Schedule a task to update the seed files from the service.
updateSeedFileAndRequestNewFromServiceOnBackgroundThread(
foundNewSeed, needNewSeed, currentSeedDate);
return new SeedLoadResult(loadedSeed, currentSeedDate, seedFileTime);
}
/**
* Post a task to replace the old seed with a new one and request an update.
*
* @param foundNewSeed Is a "new" seed file present? (If so, it should be renamed to an "old"
* seed, replacing any existing "old" seed.)
* @param needNewSeed Should we request a new seed from the service?
* @param seedFileTime timestamp of the current seed file.
*/
private void updateSeedFileAndRequestNewFromServiceOnBackgroundThread(
boolean foundNewSeed, boolean needNewSeed, long seedFileTime) {
// This work is not time critical.
PostTask.postTask(
TaskTraits.BEST_EFFORT_MAY_BLOCK,
() -> {
if (foundNewSeed) {
// The move happens synchronously. It's not possible for the service to
// still be writing to this file when we move it, because foundNewSeed means
// we already read the seed and found it to be complete. Therefore the
// service must have already finished writing.
VariationsUtils.replaceOldWithNewSeed();
}
if (needNewSeed) {
// The new seed will arrive asynchronously; the new seed file is written by
// the service, and may complete after this app process has died.
requestSeedFromService(seedFileTime);
VariationsUtils.updateStampTime();
}
onBackgroundWorkFinished();
});
}
// Connects to VariationsSeedServer service. Sends a file descriptor for our local copy of the
// seed to the service, to which the service will write a new seed.
private class SeedServerConnection extends ServiceConnectionDelayRecorder {
private ParcelFileDescriptor mNewSeedFd;
private long mOldSeedDate;
public SeedServerConnection(ParcelFileDescriptor newSeedFd, long oldSeedDate) {
mNewSeedFd = newSeedFd;
mOldSeedDate = oldSeedDate;
}
public void start() {
try {
if (!bind(
ContextUtils.getApplicationContext(),
getServerIntent(),
Context.BIND_AUTO_CREATE)) {
Log.e(TAG, "Failed to bind to WebView service");
// If we don't close the file descriptor here, it will be leaked since this
// service only wants to close it once the service has been connected.
// Problematic if we can't connect to the service in the first place.
VariationsUtils.closeSafely(mNewSeedFd);
}
// Connect to nonembedded metrics Service at the same time we connect to variation
// service.
AwBrowserProcess.collectNonembeddedMetrics();
} catch (NameNotFoundException e) {
Log.e(
TAG,
"WebView provider \""
+ AwBrowserProcess.getWebViewPackageName()
+ "\" not found!");
}
}
@Override
public void onServiceConnectedImpl(ComponentName name, IBinder service) {
try {
if (mNewSeedFd.getFd() >= 0) {
IVariationsSeedServer.Stub.asInterface(service)
.getSeed(mNewSeedFd, mOldSeedDate, mSeedServerCallback);
}
} catch (RemoteException e) {
Log.e(TAG, "Faild requesting seed", e);
} finally {
ContextUtils.getApplicationContext().unbindService(this);
VariationsUtils.closeSafely(mNewSeedFd);
}
}
@Override
public void onServiceDisconnected(ComponentName name) {}
}
private class SeedServerCallback extends IVariationsSeedServerCallback.Stub {
@Override
public void reportVariationsServiceMetrics(Bundle metricsBundle) {
VariationsServiceMetricsHelper metrics =
VariationsServiceMetricsHelper.fromBundle(metricsBundle);
if (metrics.hasJobInterval()) {
// Variations.DownloadJobInterval records time in minutes.
recordMinuteHistogram(
DOWNLOAD_JOB_INTERVAL_HISTOGRAM_NAME,
TimeUnit.MILLISECONDS.toMinutes(metrics.getJobInterval()),
TimeUnit.DAYS.toMinutes(30));
}
if (metrics.hasJobQueueTime()) {
// Variations.DownloadJobQueueTime records time in minutes.
recordMinuteHistogram(
DOWNLOAD_JOB_QUEUE_TIME_HISTOGRAM_NAME,
TimeUnit.MILLISECONDS.toMinutes(metrics.getJobQueueTime()),
TimeUnit.DAYS.toMinutes(30));
}
}
}
@VisibleForTesting // Overridden by tests to wait until all work is done.
protected void onBackgroundWorkFinished() {}
@VisibleForTesting
protected long getSeedLoadTimeoutMillis() {
return SEED_LOAD_TIMEOUT_MILLIS;
}
@VisibleForTesting
protected long getCurrentTimeMillis() {
return new Date().getTime();
}
@VisibleForTesting // and non-static for overriding by tests
protected Intent getServerIntent() throws NameNotFoundException {
Intent intent = new Intent();
intent.setClassName(
AwBrowserProcess.getWebViewPackageName(), ServiceNames.VARIATIONS_SEED_SERVER);
return intent;
}
@VisibleForTesting
// Returns false if it didn't connect to the service.
protected boolean requestSeedFromService(long oldSeedDate) {
File newSeedFile = VariationsUtils.getNewSeedFile();
ParcelFileDescriptor newSeedFd = null;
try {
newSeedFd =
ParcelFileDescriptor.open(
newSeedFile,
ParcelFileDescriptor.MODE_WRITE_ONLY
| ParcelFileDescriptor.MODE_TRUNCATE
| ParcelFileDescriptor.MODE_CREATE);
} catch (FileNotFoundException e) {
Log.e(TAG, "Failed to open seed file " + newSeedFile);
return false;
}
VariationsUtils.debugLog("Requesting new seed from IVariationsSeedServer");
SeedServerConnection connection = new SeedServerConnection(newSeedFd, oldSeedDate);
connection.start();
return true;
}
// Begin asynchronously loading the variations seed. ContextUtils.getApplicationContext() and
// AwBrowserProcess.getWebViewPackageName() must be ready to use before calling this.
public void startVariationsInit() {
mLoadTask = new FutureTask<>(this::loadSeedFile);
// The Runnable task must be scheduled with high priority to start the FutureTask as soon as
// possible since that task is blocking WebView startup.
PostTask.postTask(TaskTraits.USER_BLOCKING_MAY_BLOCK, mLoadTask);
}
// Block on loading the seed with a timeout. Then if a seed was successfully loaded, initialize
// variations. Returns whether or not variations was initialized.
public boolean finishVariationsInit() {
long start = SystemClock.elapsedRealtime();
try {
try {
SeedLoadResult loadResult =
mLoadTask.get(getSeedLoadTimeoutMillis(), TimeUnit.MILLISECONDS);
maybeRecordSeedFileTime(loadResult.mSeedFileTime);
boolean gotSeed = loadResult.mLoadedSeed;
// Log the seed age to help with debugging.
long seedDate = loadResult.mCurrentSeedDate;
if (gotSeed && seedDate > 0) {
long seedAge = TimeUnit.MILLISECONDS.toSeconds(new Date().getTime() - seedDate);
// Changes to the log message below must be accompanied with changes to WebView
// finch smoke tests since they look for this message in the logcat.
VariationsUtils.debugLog("Loaded seed with age " + seedAge + "s");
}
return gotSeed;
} finally {
long end = SystemClock.elapsedRealtime();
recordSeedLoadBlockingTime(end - start);
}
} catch (TimeoutException e) {
recordLoadSeedResult(LoadSeedResult.LOAD_TIMED_OUT);
} catch (InterruptedException e) {
recordLoadSeedResult(LoadSeedResult.LOAD_INTERRUPTED);
} catch (ExecutionException e) {
recordLoadSeedResult(LoadSeedResult.LOAD_OTHER_FAILURE);
}
Log.e(TAG, "Failed loading variations seed. Variations disabled.");
return false;
}
@NativeMethods
interface Natives {
// Parses the AwVariationsSeed proto stored in the file with the given path, saving it in
// memory for later use by native code if the parsing succeeded. Returns true if the loading
// and parsing were successful.
boolean parseAndSaveSeedProto(String path);
// Parses the AwVariationsSeed proto stored in the given byte array, saving it in
// memory for later use by native code if the parsing succeeded. Returns true if the loading
// and parsing were successful.
boolean parseAndSaveSeedProtoFromByteArray(byte[] seedAsByteArray);
// Returns the timestamp in millis since unix epoch that the saved seed was generated on
// the server. This value corresponds to the |date| field in the AwVariationsSeed proto.
long getSavedSeedDate();
}
}