// Copyright 2015 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.precache;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.content.SharedPreferences;
import android.net.ConnectivityManager;
import android.os.Handler;
import android.os.Looper;
import android.os.PowerManager;
import android.os.PowerManager.WakeLock;
import com.google.android.gms.gcm.GcmNetworkManager;
import com.google.android.gms.gcm.OneoffTask;
import com.google.android.gms.gcm.PeriodicTask;
import org.chromium.base.ContextUtils;
import org.chromium.base.Log;
import org.chromium.base.VisibleForTesting;
import org.chromium.base.library_loader.LibraryLoader;
import org.chromium.base.metrics.RecordHistogram;
import org.chromium.base.metrics.RecordUserAction;
import org.chromium.chrome.browser.ChromeBackgroundService;
import org.chromium.chrome.browser.ChromeVersionInfo;
import org.chromium.chrome.browser.util.NonThreadSafe;
import org.chromium.components.precache.DeviceState;
import org.chromium.components.sync.ModelType;
import java.util.ArrayDeque;
import java.util.Arrays;
import java.util.Collections;
import java.util.EnumSet;
import java.util.HashSet;
import java.util.Queue;
import java.util.Set;
/**
* Singleton responsible for starting and stopping a precache session.
* Precaching occurs only when the device is connected to power and an
* un-metered network connection. It holds a wake lock while running. It stops
* running when power or the un-metered network is disconnected,
* MAX_PRECACHE_DURATION_SECONDS elapse, or there are no more resources to
* precache.
*/
public class PrecacheController {
private static final String TAG = "Precache";
/**
* ID of the periodic task. Used here and by
* {@link ChromeBackgroundService} for dispatch.
*/
public static final String PERIODIC_TASK_TAG = "precache";
@VisibleForTesting
static final String PREF_IS_PRECACHING_ENABLED = "precache.is_precaching_enabled";
/**
* ID of the continuation task. Used here and by
* {@link ChromeBackgroundService} for dispatch.
*/
public static final String CONTINUATION_TASK_TAG = "precache-continuation";
static final int WAIT_UNTIL_NEXT_PRECACHE_SECONDS = 6 * 60 * 60; // 6 hours.
static final int COMPLETION_TASK_MIN_DELAY_SECONDS = 5 * 60; // 5 minutes.
static final int COMPLETION_TASK_MAX_DELAY_SECONDS = 60 * 60; // 1 hour.
static final int MAX_SYNC_SERVICE_INIT_TIMOUT_MS = 5 * 60 * 1000; // 5 minutes
static final int MAX_PRECACHE_DURATION_SECONDS = 30 * 60; // 30 minutes.
static final Set<Integer> SYNC_SERVICE_CONFIGURED_DATATYPES =
Collections.unmodifiableSet(new HashSet<Integer>(Arrays.asList(ModelType.SESSIONS)));
private static final String PREF_PRECACHE_PERIODIC_TASK_START_TIME_MS =
"precache.periodic_task_start_time_ms";
/**
* Singleton instance of the PrecacheController. PrecacheController is a
* singleton so that there is a single handle by which to determine if
* precaching is underway, and to cancel it if necessary.
*/
private static PrecacheController sInstance;
/**
* The default task scheduler. Overridden for tests.
*/
private static PrecacheTaskScheduler sTaskScheduler = new PrecacheTaskScheduler();
/**
* Listener for syncservice backend.
*/
SyncServiceInitializedNotifier mSyncServiceNotifier;
/** True if a precache session is in progress. Threadsafe. */
private boolean mIsPrecaching = false;
/** Wakelock that is held while precaching is in progress. */
private WakeLock mPrecachingWakeLock;
private Context mAppContext;
private Queue<Integer> mFailureReasonsToRecord = new ArrayDeque<Integer>();
private DeviceState mDeviceState = DeviceState.getInstance();
/** Receiver that will be notified when conditions become wrong for precaching. */
private final BroadcastReceiver mDeviceStateReceiver = new BroadcastReceiver() {
@Override
public void onReceive(final Context context, Intent intent) {
runOnInstanceThread(new Runnable() {
@Override
public void run() {
Log.v(TAG, "conditions changed: precaching(%s), powered(%s), unmetered(%s)",
isPrecaching(), mDeviceState.isPowerConnected(context),
mDeviceState.isUnmeteredNetworkAvailable(context));
if (isPrecaching()
&& ((ChromeVersionInfo.isStableBuild()
&& !mDeviceState.isPowerConnected(context))
|| !mDeviceState.isUnmeteredNetworkAvailable(context))) {
recordFailureReasons(context);
cancelPrecaching(!mDeviceState.isUnmeteredNetworkAvailable(context)
? PrecacheUMA.Event.PRECACHE_CANCEL_NO_UNMETERED_NETWORK
: PrecacheUMA.Event.PRECACHE_CANCEL_NO_POWER);
}
}
});
}
};
Handler mHandler;
Runnable mTimeoutRunnable = new Runnable() {
@Override
public void run() {
Log.v(TAG, "precache session timed out");
cancelPrecaching(PrecacheUMA.Event.PRECACHE_SESSION_TIMEOUT);
}
};
/**
* Used to ensure this class is always used on the thread on which it
* is instantiated.
*/
private final NonThreadSafe mNonThreadSafe;
/**
* Returns the singleton PrecacheController instance. Should only be called
* from the UI thread.
*/
public static PrecacheController get(Context context) {
if (sInstance == null) {
sInstance = new PrecacheController(context);
}
return sInstance;
}
/**
* Returns true if the PrecacheController singleton has already been
* created.
*/
public static boolean hasInstance() {
return sInstance != null;
}
/**
* Schedules a periodic task to precache resources.
* @param context The application context.
* @return false if the task cannot be scheduled.
*/
private static boolean schedulePeriodicPrecacheTask(Context context) {
PeriodicTask task = new PeriodicTask.Builder()
.setPeriod(WAIT_UNTIL_NEXT_PRECACHE_SECONDS)
.setPersisted(true)
.setRequiredNetwork(PeriodicTask.NETWORK_STATE_UNMETERED)
.setRequiresCharging(ChromeVersionInfo.isStableBuild())
.setService(ChromeBackgroundService.class)
.setTag(PERIODIC_TASK_TAG)
.build();
return sTaskScheduler.scheduleTask(context, task);
}
private static void cancelPeriodicPrecacheTask(Context context) {
Log.v(TAG, "canceling a periodic precache task");
sTaskScheduler.cancelTask(context, PERIODIC_TASK_TAG);
}
/**
* Schedules a one-off task to finish precaching the resources that were
* still outstanding when the last task was interrupted. Interrupting such
* a one-off task will result in scheduling a new one.
* @param context The application context.
*/
private static void schedulePrecacheCompletionTask(Context context) {
Log.v(TAG, "scheduling a precache completion task");
OneoffTask task = new OneoffTask.Builder()
.setExecutionWindow(COMPLETION_TASK_MIN_DELAY_SECONDS,
COMPLETION_TASK_MAX_DELAY_SECONDS)
.setPersisted(true)
.setRequiredNetwork(OneoffTask.NETWORK_STATE_UNMETERED)
.setRequiresCharging(ChromeVersionInfo.isStableBuild())
.setService(ChromeBackgroundService.class)
.setTag(CONTINUATION_TASK_TAG)
.setUpdateCurrent(true)
.build();
if (sTaskScheduler.scheduleTask(context, task)) {
PrecacheUMA.record(PrecacheUMA.Event.ONEOFF_TASK_SCHEDULE);
} else {
PrecacheUMA.record(PrecacheUMA.Event.ONEOFF_TASK_SCHEDULE_FAIL);
}
}
private static void cancelPrecacheCompletionTask(Context context) {
Log.v(TAG, "canceling a precache completion task");
sTaskScheduler.cancelTask(context, CONTINUATION_TASK_TAG);
}
/**
* Called when Chrome package is upgraded to reschedule the precache periodic task.
* @param context The application context.
*/
public static void rescheduleTasksOnUpgrade(Context context) {
// Reschedule the periodic task if precache was enabled previously.
SharedPreferences sharedPreferences = ContextUtils.getAppSharedPreferences();
if (sharedPreferences.getBoolean(PREF_IS_PRECACHING_ENABLED, false)
&& !schedulePeriodicPrecacheTask(context)) {
// Clear the preference, for the task to be scheduled next time.
sharedPreferences.edit().putBoolean(PREF_IS_PRECACHING_ENABLED, false).apply();
PrecacheUMA.record(PrecacheUMA.Event.PERIODIC_TASK_SCHEDULE_UPGRADE_FAIL);
} else {
PrecacheUMA.record(PrecacheUMA.Event.PERIODIC_TASK_SCHEDULE_UPGRADE);
}
}
@VisibleForTesting
PrecacheController(Context context) {
mNonThreadSafe = new NonThreadSafe();
mAppContext = context.getApplicationContext();
mHandler = new Handler(Looper.myLooper());
}
/** Returns true if precaching is able to run. */
@VisibleForTesting
boolean isPrecachingEnabled() {
assert mNonThreadSafe.calledOnValidThread();
SharedPreferences prefs = ContextUtils.getAppSharedPreferences();
return prefs.getBoolean(PREF_IS_PRECACHING_ENABLED, false);
}
private void runOnInstanceThread(final Runnable r) {
if (mHandler.getLooper() == Looper.myLooper()) {
r.run();
} else {
mHandler.post(r);
}
}
/**
* Sets whether or not precaching is enabled. If precaching is enabled, a
* periodic precaching task will be scheduled to run. If disabled, any
* running precache session will be stopped, and all tasks canceled.
*/
public static void setIsPrecachingEnabled(Context context, boolean enabled) {
boolean cancelRequired = !enabled && PrecacheController.hasInstance();
Context appContext = context.getApplicationContext();
SharedPreferences sharedPreferences = ContextUtils.getAppSharedPreferences();
if (sharedPreferences.getBoolean(PREF_IS_PRECACHING_ENABLED, !enabled) == enabled) {
return;
}
Log.v(TAG, "setting precache enabled to %s", enabled);
sharedPreferences.edit().putBoolean(PREF_IS_PRECACHING_ENABLED, enabled).apply();
if (enabled) {
if (!schedulePeriodicPrecacheTask(appContext)) {
// Clear the preference, for the task to be scheduled next time.
sharedPreferences.edit().putBoolean(PREF_IS_PRECACHING_ENABLED, false).apply();
PrecacheUMA.record(PrecacheUMA.Event.PERIODIC_TASK_SCHEDULE_STARTUP_FAIL);
} else {
PrecacheUMA.record(PrecacheUMA.Event.PERIODIC_TASK_SCHEDULE_STARTUP);
}
} else {
// If precaching, stop.
cancelPeriodicPrecacheTask(appContext);
cancelPrecacheCompletionTask(appContext);
}
if (cancelRequired) {
sInstance.cancelPrecaching(PrecacheUMA.Event.PRECACHE_CANCEL_DISABLED_PREF);
}
}
/** Returns true if the precache session in progress. */
public boolean isPrecaching() {
assert mNonThreadSafe.calledOnValidThread();
return mIsPrecaching;
}
/**
* Sets whether or not the precache session is in progress.
* @return True if this state changed.
*/
@VisibleForTesting
boolean setIsPrecaching(boolean isPrecaching) {
assert mNonThreadSafe.calledOnValidThread();
if (mIsPrecaching != isPrecaching) {
mIsPrecaching = isPrecaching;
return true;
}
return false;
}
/** Overrides the default DeviceState object, e.g., with a mock for tests. */
@VisibleForTesting
void setDeviceState(DeviceState deviceState) {
assert mNonThreadSafe.calledOnValidThread();
mDeviceState = deviceState;
}
@VisibleForTesting
Runnable getTimeoutRunnable() {
assert mNonThreadSafe.calledOnValidThread();
return mTimeoutRunnable;
}
@VisibleForTesting
BroadcastReceiver getDeviceStateReceiver() {
assert mNonThreadSafe.calledOnValidThread();
return mDeviceStateReceiver;
}
/**
* Ends a precache session.
* @param precachingIncomplete True if the session was interrupted.
*/
void handlePrecacheCompleted(boolean precachingIncomplete) {
assert mNonThreadSafe.calledOnValidThread();
if (setIsPrecaching(false)) {
shutdownPrecaching(precachingIncomplete);
}
PrecacheUMA.record(precachingIncomplete ? PrecacheUMA.Event.PRECACHE_SESSION_INCOMPLETE
: PrecacheUMA.Event.PRECACHE_SESSION_COMPLETE);
}
/** {@link PrecacheLauncher} used to run a precache session. */
private PrecacheLauncher mPrecacheLauncher = new PrecacheLauncher() {
@Override
protected void onPrecacheCompleted(boolean tryAgainSoon) {
Log.v(TAG, "precache session completed");
handlePrecacheCompleted(tryAgainSoon);
}
};
/**
* Called by {@link ChromeBackgroundService} when a precache task is ready
* to run.
*/
public int precache(String tag) {
assert mNonThreadSafe.calledOnValidThread();
PrecacheUMA.record(PERIODIC_TASK_TAG.equals(tag)
? PrecacheUMA.Event.PRECACHE_TASK_STARTED_PERIODIC
: PrecacheUMA.Event.PRECACHE_TASK_STARTED_ONEOFF);
Log.v(TAG, "precache task (%s) started", tag);
if (!isPrecachingEnabled()) {
Log.v(TAG, "precaching isn't enabled");
cancelPeriodicPrecacheTask(mAppContext);
cancelPrecacheCompletionTask(mAppContext);
PrecacheUMA.record(PrecacheUMA.Event.DISABLED_IN_PRECACHE_PREF);
return GcmNetworkManager.RESULT_SUCCESS;
}
if (setIsPrecaching(true)) {
if (PERIODIC_TASK_TAG.equals(tag)) {
recordPeriodicTaskIntervalHistogram();
cancelPrecacheCompletionTask(mAppContext);
}
recordBatteryLevelAtStart();
registerDeviceStateReceiver();
acquirePrecachingWakeLock();
startPrecachingAfterSyncInit();
return GcmNetworkManager.RESULT_SUCCESS;
}
Log.v(TAG, "precache session was already running");
PrecacheUMA.record(PrecacheUMA.Event.PRECACHE_TASK_STARTED_DUPLICATE);
return GcmNetworkManager.RESULT_FAILURE;
}
@VisibleForTesting
void startPrecachingAfterSyncInit() {
mSyncServiceNotifier = new SyncServiceInitializedNotifier(
SYNC_SERVICE_CONFIGURED_DATATYPES, new SyncServiceInitializedNotifier.Listener() {
@Override
public void onDataTypesActive() {
startPrecaching();
}
@Override
public void onFailureOrTimedOut() {
cancelPrecaching(PrecacheUMA.Event.SYNC_SERVICE_TIMEOUT);
}
}, MAX_SYNC_SERVICE_INIT_TIMOUT_MS);
}
/** Begins a precache session. */
@VisibleForTesting
void startPrecaching() {
Log.v(TAG, "precache session has started");
mHandler.postDelayed(mTimeoutRunnable, MAX_PRECACHE_DURATION_SECONDS * 1000);
PrecacheUMA.record(PrecacheUMA.Event.PRECACHE_SESSION_STARTED);
// In certain cases, the PrecacheLauncher will skip precaching entirely and call
// finishPrecaching() before this call to mPrecacheLauncher.start() returns, so the call to
// mPrecacheLauncher.start() must happen after acquiring the wake lock to ensure that the
// wake lock is released properly.
mPrecacheLauncher.start();
}
/**
* Cancels the current precache session.
* @param event the failure reason.
*/
private void cancelPrecaching(final int event) {
// cancelPrecaching() could be called from PrecacheManager::Shutdown(), precache GCM task,
// etc., where it could be a different thread.
runOnInstanceThread(new Runnable() {
@Override
public void run() {
Log.v(TAG, "canceling precache session");
if (setIsPrecaching(false)) {
mPrecacheLauncher.cancel();
shutdownPrecaching(true);
}
PrecacheUMA.record(event);
}
});
}
/**
* Updates state to indicate that the precache session is no longer in
* progress, and stops the service.
*/
private void shutdownPrecaching(boolean precachingIncomplete) {
Log.v(TAG, "shutting down precache session");
if (precachingIncomplete) {
schedulePrecacheCompletionTask(mAppContext);
}
recordBatteryLevelAtEnd();
mHandler.removeCallbacks(mTimeoutRunnable);
mAppContext.unregisterReceiver(mDeviceStateReceiver);
releasePrecachingWakeLock();
}
/**
* Registers a BroadcastReceiver to detect when conditions become wrong
* for precaching.
*/
private void registerDeviceStateReceiver() {
Log.v(TAG, "registered device state receiver");
IntentFilter filter = new IntentFilter();
if (ChromeVersionInfo.isStableBuild()) {
// Power requirement for precache is only for stable channel.
filter.addAction(Intent.ACTION_POWER_DISCONNECTED);
}
filter.addAction(ConnectivityManager.CONNECTIVITY_ACTION);
mAppContext.registerReceiver(mDeviceStateReceiver, filter);
}
/** Acquires the precaching {@link WakeLock}. */
@VisibleForTesting
void acquirePrecachingWakeLock() {
assert mNonThreadSafe.calledOnValidThread();
Log.v(TAG, "acquiring wake lock");
if (mPrecachingWakeLock == null) {
PowerManager pm = (PowerManager) mAppContext.getSystemService(Context.POWER_SERVICE);
mPrecachingWakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, TAG);
}
mPrecachingWakeLock.acquire();
}
/** Releases the precaching {@link WakeLock} if it is held. */
@VisibleForTesting
void releasePrecachingWakeLock() {
assert mNonThreadSafe.calledOnValidThread();
Log.v(TAG, "releasing wake lock");
if (mPrecachingWakeLock != null && mPrecachingWakeLock.isHeld()) {
mPrecachingWakeLock.release();
}
}
/**
* Returns the set of reasons that the last prefetch attempt failed to start.
*
* @param context the context passed to onReceive()
*/
@VisibleForTesting
EnumSet<FailureReason> interruptionReasons(Context context) {
assert mNonThreadSafe.calledOnValidThread();
EnumSet<FailureReason> reasons = EnumSet.noneOf(FailureReason.class);
reasons.addAll(mPrecacheLauncher.failureReasons());
if (!mDeviceState.isPowerConnected(context)) reasons.add(FailureReason.NO_POWER);
if (!mDeviceState.isUnmeteredNetworkAvailable(context)) reasons.add(FailureReason.NO_WIFI);
if (isPrecaching()) reasons.add(FailureReason.CURRENTLY_PRECACHING);
return reasons;
}
/**
* Tries to record a histogram enumerating all of the return value of failureReasons().
*
* If the native libraries are not already loaded, no histogram is recorded.
*
* @param context the context passed to onReceive()
*/
@VisibleForTesting
void recordFailureReasons(Context context) {
assert mNonThreadSafe.calledOnValidThread();
int reasons = FailureReason.bitValue(interruptionReasons(context));
// Queue up this failure reason, for the next time we are able to record it in UMA.
mFailureReasonsToRecord.add(reasons);
// If native libraries are loaded, then we are able to flush our queue to UMA.
if (LibraryLoader.isInitialized()) {
Integer reasonsToRecord;
while ((reasonsToRecord = mFailureReasonsToRecord.poll()) != null) {
RecordHistogram.recordSparseSlowlyHistogram(
"Precache.Fetch.FailureReasons", reasonsToRecord);
RecordUserAction.record("Precache.Fetch.IntentReceived");
}
}
}
@VisibleForTesting
void setPrecacheLauncher(PrecacheLauncher precacheLauncher) {
assert mNonThreadSafe.calledOnValidThread();
mPrecacheLauncher = precacheLauncher;
}
@VisibleForTesting
static void setTaskScheduler(PrecacheTaskScheduler taskScheduler) {
PrecacheController.sTaskScheduler = taskScheduler;
}
private static void recordPeriodicTaskIntervalHistogram() {
SharedPreferences prefs = ContextUtils.getAppSharedPreferences();
long previous_start_time_ms = prefs.getLong(PREF_PRECACHE_PERIODIC_TASK_START_TIME_MS, 0);
long current_start_time_ms = System.currentTimeMillis();
if (previous_start_time_ms > 0 && current_start_time_ms > previous_start_time_ms) {
int interval_mins =
(int) ((current_start_time_ms - previous_start_time_ms) / (1000 * 60));
RecordHistogram.recordCustomCountHistogram(
"Precache.PeriodicTaskInterval", interval_mins, 1, 10000, 50);
}
prefs.edit()
.putLong(PREF_PRECACHE_PERIODIC_TASK_START_TIME_MS, current_start_time_ms)
.apply();
}
private void recordBatteryLevelAtStart() {
mDeviceState.saveCurrentBatteryPercentage(mAppContext);
// Report battery percentage.
RecordHistogram.recordPercentageHistogram(
"Precache.BatteryPercentage.Start", mDeviceState.getSavedBatteryPercentage());
}
private void recordBatteryLevelAtEnd() {
int delta_percentage = mDeviceState.getCurrentBatteryPercentage(mAppContext)
- mDeviceState.getSavedBatteryPercentage();
if (delta_percentage >= 0) {
RecordHistogram.recordPercentageHistogram(
"Precache.BatteryPercentageDiff.End", delta_percentage);
}
}
}