ParseApplication/ExternalLibs/Parse-SDK-Android/Parse/src/main/java/com/parse/ParseCommandCache.java

689 lines
24 KiB
Java

/*
* Copyright (c) 2015-present, Parse, LLC.
* All rights reserved.
*
* This source code is licensed under the BSD-style license found in the
* LICENSE file in the root directory of this source tree. An additional grant
* of patent rights can be found in the PATENTS file in the same directory.
*/
package com.parse;
import android.Manifest;
import android.content.Context;
import android.content.Intent;
import android.net.ConnectivityManager;
import org.json.JSONException;
import org.json.JSONObject;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.UnsupportedEncodingException;
import java.util.Arrays;
import java.util.HashMap;
import java.util.concurrent.Callable;
import java.util.logging.Level;
import java.util.logging.Logger;
import bolts.Capture;
import bolts.Continuation;
import bolts.Task;
import bolts.TaskCompletionSource;
/**
* ParseCommandCache manages an on-disk cache of commands to be executed, and a thread with a
* standard run loop that executes the commands. There should only ever be one instance of this
* class, because multiple instances would be running separate threads trying to read and execute
* the same commands.
*/
/** package */ class ParseCommandCache extends ParseEventuallyQueue {
private static final String TAG = "com.parse.ParseCommandCache";
private static int filenameCounter = 0; // Appended to temp file names so we know their creation
// order.
// Lock guards access to the file system and all of the instance variables above. It is static so
// that if somehow there are two instances of ParseCommandCache, they won't step on each others'
// toes while using the file system. A thread with lock should *not* try to get runningLock, under
// penalty of deadlock. Only the run loop (runLoop) thread should ever wait on this lock. Other
// threads should notify on this lock whenever the run loop should wake up and try to execute more
// commands.
private static final Object lock = new Object();
private static File getCacheDir() {
// Construct the path to the cache directory.
File cacheDir = new File(Parse.getParseDir(), "CommandCache");
cacheDir.mkdirs();
return cacheDir;
}
public static int getPendingCount() {
synchronized (lock) {
String[] files = getCacheDir().list();
return files == null ? 0 : files.length;
}
}
private File cachePath; // Where the cache is stored on disk.
private int timeoutMaxRetries = 5; // Don't retry more than 5 times before assuming disconnection.
private double timeoutRetryWaitSeconds = 600.0f; // Wait 10 minutes before retrying after network
// timeout.
private int maxCacheSizeBytes = 10 * 1024 * 1024; // Don't consume more than N bytes of storage.
private boolean shouldStop; // Should the run loop thread processing the disk cache continue?
private boolean unprocessedCommandsExist; // Has a command been added which hasn't yet been
// processed by the run loop?
// Map of filename to TaskCompletionSource, for all commands that are in the queue from this run
// of the program. This is necessary so that the original objects can be notified after their
// saves complete.
private HashMap<File, TaskCompletionSource<JSONObject>> pendingTasks = new HashMap<>();
private boolean running; // Is the run loop executing commands from the disk cache running?
// Guards access to running. Gets a broadcast whenever running changes. A thread should only wait
// on runningLock if it's sure the value of running is going to change. Only the run loop
// (runLoop) thread should ever notify on runningLock. It's perfectly fine for a thread that has
// runningLock to then also try to acquire the other lock.
private final Object runningLock;
private Logger log; // Why is there a custom logger? To prevent Mockito deadlock!
private final ParseHttpClient httpClient;
ConnectivityNotifier notifier;
ConnectivityNotifier.ConnectivityListener listener = new ConnectivityNotifier.ConnectivityListener() {
@Override
public void networkConnectivityStatusChanged(Context context, Intent intent) {
final boolean connectionLost =
intent.getBooleanExtra(ConnectivityManager.EXTRA_NO_CONNECTIVITY, false);
final boolean isConnected = ConnectivityNotifier.isConnected(context);
/*
Hack to avoid blocking the UI thread with disk I/O
setConnected uses the same lock we use for synchronizing disk I/O, so there's a possibility
that we can block the UI thread on disk I/O, so we're going to bump the lock usage to a
different thread.
TODO(grantland): Convert to TaskQueue, similar to ParsePinningEventuallyQueue
*/
Task.call(new Callable<Void>() {
@Override
public Void call() throws Exception {
if (connectionLost) {
setConnected(false);
} else {
setConnected(isConnected);
}
return null;
}
}, ParseExecutors.io());
}
};
public ParseCommandCache(Context context, ParseHttpClient client) {
setConnected(false);
shouldStop = false;
running = false;
runningLock = new Object();
httpClient = client;
log = Logger.getLogger(TAG);
cachePath = getCacheDir();
if (!Parse.hasPermission(Manifest.permission.ACCESS_NETWORK_STATE)) {
// The command cache only works if the user has granted us permission to monitor the network.
return;
}
setConnected(ConnectivityNotifier.isConnected(context));
notifier = ConnectivityNotifier.getNotifier(context);
notifier.addListener(listener);
resume();
}
@Override
public void onDestroy() {
//TODO (grantland): pause #6484855
notifier.removeListener(listener);
}
// Set the maximum number of times to retry before assuming disconnection.
@SuppressWarnings("unused")
public void setTimeoutMaxRetries(int tries) {
synchronized (lock) {
timeoutMaxRetries = tries;
}
}
// Sets the amount of time to wait before retrying after network timeout.
public void setTimeoutRetryWaitSeconds(double seconds) {
synchronized (lock) {
timeoutRetryWaitSeconds = seconds;
}
}
// Sets the maximum amount of storage space this cache can consume.
public void setMaxCacheSizeBytes(int bytes) {
synchronized (lock) {
maxCacheSizeBytes = bytes;
}
}
// Starts the run loop thread running.
public void resume() {
synchronized (runningLock) {
if (!running) {
new Thread("ParseCommandCache.runLoop()") {
@Override
public void run() {
runLoop();
}
}.start();
try {
runningLock.wait();
} catch (InterruptedException e) {
// Someone told this thread to stop.
synchronized (lock) {
shouldStop = true;
lock.notifyAll();
}
}
}
}
}
// Stops the run loop thread from processing commands until resume is called.
// When this function returns, the run loop has stopped.
public void pause() {
synchronized (runningLock) {
if (running) {
synchronized (lock) {
shouldStop = true;
lock.notifyAll();
}
}
while (running) {
try {
runningLock.wait();
} catch (InterruptedException e) {
// Someone told this thread to stop while it was already waiting to
// finish...
// Ignore them and continue waiting.
}
}
}
}
/**
* Removes a file from the file system and any internal caches.
*/
private void removeFile(File file) {
synchronized (lock) {
// Remove the data in memory for this command.
pendingTasks.remove(file);
// Release all the localIds referenced by the command.
// Read one command from the cache.
JSONObject json;
try {
json = ParseFileUtils.readFileToJSONObject(file);
ParseRESTCommand command = commandFromJSON(json);
command.releaseLocalIds();
} catch (Exception e) {
// Well, we did our best. We'll just have to leak a localId.
}
// Delete the command file itself.
ParseFileUtils.deleteQuietly(file);
}
}
/**
* Makes this command cache forget all the state it keeps during a single run of the app. This is
* only for testing purposes.
*/
void simulateReboot() {
synchronized (lock) {
pendingTasks.clear();
}
}
/**
* Fakes an object update notification for use in tests. This is used by saveEventually to make it
* look like test code has updated an object through the command cache even if it actually
* avoided executing update by determining the object wasn't dirty.
*/
void fakeObjectUpdate() {
notifyTestHelper(TestHelper.COMMAND_ENQUEUED);
notifyTestHelper(TestHelper.COMMAND_SUCCESSFUL);
notifyTestHelper(TestHelper.OBJECT_UPDATED);
}
@Override
public Task<JSONObject> enqueueEventuallyAsync(ParseRESTCommand command,
ParseObject object) {
return enqueueEventuallyAsync(command, false, object);
}
/**
* Attempts to run the given command and any pending commands. Adds the command to the pending set
* if it can't be run yet.
*
* @param command
* - The command to run.
* @param preferOldest
* - When the disk is full, if preferOldest, drop new commands. Otherwise, the oldest
* commands will be deleted to make room.
* @param object
* - See runEventually.
*/
private Task<JSONObject> enqueueEventuallyAsync(ParseRESTCommand command, boolean preferOldest,
ParseObject object) {
Parse.requirePermission(Manifest.permission.ACCESS_NETWORK_STATE);
TaskCompletionSource<JSONObject> tcs = new TaskCompletionSource<>();
byte[] json;
try {
// If this object doesn't have an objectId yet, store the localId so we can remap it to the
// objectId after the save completes.
if (object != null && object.getObjectId() == null) {
command.setLocalId(object.getOrCreateLocalId());
}
JSONObject jsonObject = command.toJSONObject();
json = jsonObject.toString().getBytes("UTF-8");
} catch (UnsupportedEncodingException e) {
if (Parse.LOG_LEVEL_WARNING >= Parse.getLogLevel()) {
log.log(Level.WARNING, "UTF-8 isn't supported. This shouldn't happen.", e);
}
notifyTestHelper(TestHelper.COMMAND_NOT_ENQUEUED);
return Task.forResult(null);
}
// If this object by itself is larger than the full disk cache, then don't
// even bother trying.
if (json.length > maxCacheSizeBytes) {
if (Parse.LOG_LEVEL_WARNING >= Parse.getLogLevel()) {
log.warning("Unable to save command for later because it's too big.");
}
notifyTestHelper(TestHelper.COMMAND_NOT_ENQUEUED);
return Task.forResult(null);
}
synchronized (lock) {
try {
// Is there enough free storage space?
String[] fileNames = cachePath.list();
if (fileNames != null) {
Arrays.sort(fileNames);
int size = 0;
for (String fileName : fileNames) {
File file = new File(cachePath, fileName);
// Should be safe to convert long to int, because we don't allow
// files larger than 2GB.
size += (int) file.length();
}
size += json.length;
if (size > maxCacheSizeBytes) {
if (preferOldest) {
if (Parse.LOG_LEVEL_WARNING >= Parse.getLogLevel()) {
log.warning("Unable to save command for later because storage is full.");
}
return Task.forResult(null);
} else {
if (Parse.LOG_LEVEL_WARNING >= Parse.getLogLevel()) {
log.warning("Deleting old commands to make room in command cache.");
}
int indexToDelete = 0;
while (size > maxCacheSizeBytes && indexToDelete < fileNames.length) {
File file = new File(cachePath, fileNames[indexToDelete++]);
size -= (int) file.length();
removeFile(file);
}
}
}
}
// Get the current time to store in the filename, so that we process them in order.
String prefix1 = Long.toHexString(System.currentTimeMillis());
if (prefix1.length() < 16) {
char[] zeroes = new char[16 - prefix1.length()];
Arrays.fill(zeroes, '0');
prefix1 = new String(zeroes) + prefix1;
}
// Then add another incrementing number in case we enqueue items faster than the system's
// time granularity.
String prefix2 = Integer.toHexString(filenameCounter++);
if (prefix2.length() < 8) {
char[] zeroes = new char[8 - prefix2.length()];
Arrays.fill(zeroes, '0');
prefix2 = new String(zeroes) + prefix2;
}
String prefix = "CachedCommand_" + prefix1 + "_" + prefix2 + "_";
// Get a unique filename to store this command in.
File path = File.createTempFile(prefix, "", cachePath);
// Write the command to that file.
pendingTasks.put(path, tcs);
command.retainLocalIds();
ParseFileUtils.writeByteArrayToFile(path, json);
notifyTestHelper(TestHelper.COMMAND_ENQUEUED);
unprocessedCommandsExist = true;
} catch (IOException e) {
if (Parse.LOG_LEVEL_WARNING >= Parse.getLogLevel()) {
log.log(Level.WARNING, "Unable to save command for later.", e);
}
} finally {
lock.notifyAll();
}
}
return tcs.getTask();
}
/**
* Returns the number of commands currently in the set waiting to be run.
*/
@Override
public int pendingCount() {
return getPendingCount();
}
/**
* Gets rid of all pending commands.
*/
public void clear() {
synchronized (lock) {
File[] files = cachePath.listFiles();
if (files == null) {
return;
}
for (File file : files) {
removeFile(file);
}
pendingTasks.clear();
}
}
/**
* Manually sets the network connection status.
*/
public void setConnected(boolean connected) {
synchronized (lock) {
if (isConnected() != connected) {
if (connected) {
lock.notifyAll();
}
}
super.setConnected(connected);
}
}
/**
* This is kind of like ParseTaskUtils.wait(), except that it gives up the CommandCache's lock
* while the task is running, and reclaims it before returning.
*/
private <T> T waitForTaskWithoutLock(Task<T> task) throws ParseException {
synchronized (lock) {
final Capture<Boolean> finished = new Capture<>(false);
task.continueWith(new Continuation<T, Void>() {
@Override
public Void then(Task<T> task) throws Exception {
finished.set(true);
synchronized(lock) {
lock.notifyAll();
}
return null;
}
}, Task.BACKGROUND_EXECUTOR);
while (!finished.get()) {
try {
lock.wait();
} catch (InterruptedException ie) {
shouldStop = true;
}
}
return ParseTaskUtils.wait(task); // Just to get the return value and maybe rethrow.
}
}
/**
* Attempts to run every command in the disk queue in order, synchronously. If there is no network
* connection, returns immediately without doing anything. If there is supposedly a connection,
* but parse can't be reached, waits timeoutRetryWaitSeconds before retrying up to
* retriesRemaining times. Blocks until either there's a connection, or the retries are exhausted.
* If any command fails, just deletes it and moves on to the next one.
*/
private void maybeRunAllCommandsNow(int retriesRemaining) {
synchronized (lock) {
unprocessedCommandsExist = false;
if (!isConnected()) {
// There's no way to do work when there's no network connection.
return;
}
String[] fileNames = cachePath.list();
if (fileNames == null || fileNames.length == 0) {
return;
}
Arrays.sort(fileNames);
for (String fileName : fileNames) {
final File file = new File(cachePath, fileName);
// Read one command from the cache.
JSONObject json;
try {
json = ParseFileUtils.readFileToJSONObject(file);
} catch (FileNotFoundException e) {
// This shouldn't really be possible.
if (Parse.LOG_LEVEL_ERROR >= Parse.getLogLevel()) {
log.log(Level.SEVERE, "File disappeared from cache while being read.", e);
}
continue;
} catch (IOException e) {
if (Parse.LOG_LEVEL_ERROR >= Parse.getLogLevel()) {
log.log(Level.SEVERE, "Unable to read contents of file in cache.", e);
}
removeFile(file);
continue;
} catch (JSONException e) {
if (Parse.LOG_LEVEL_ERROR >= Parse.getLogLevel()) {
log.log(Level.SEVERE, "Error parsing JSON found in cache.", e);
}
removeFile(file);
continue;
}
// Convert the command from a string.
final ParseRESTCommand command;
final TaskCompletionSource<JSONObject> tcs =
pendingTasks.containsKey(file) ? pendingTasks.get(file) : null;
try {
command = commandFromJSON(json);
} catch (JSONException e) {
if (Parse.LOG_LEVEL_ERROR >= Parse.getLogLevel()) {
log.log(Level.SEVERE, "Unable to create ParseCommand from JSON.", e);
}
removeFile(file);
continue;
}
try {
Task<JSONObject> commandTask;
if (command == null) {
commandTask = Task.forResult(null);
if (tcs != null) {
tcs.setResult(null);
}
notifyTestHelper(TestHelper.COMMAND_OLD_FORMAT_DISCARDED);
} else {
commandTask = command.executeAsync(httpClient).continueWithTask(new Continuation<JSONObject, Task<JSONObject>>() {
@Override
public Task<JSONObject> then(Task<JSONObject> task) throws Exception {
String localId = command.getLocalId();
Exception error = task.getError();
if (error != null) {
if (error instanceof ParseException
&& ((ParseException) error).getCode() == ParseException.CONNECTION_FAILED) {
// do nothing
} else {
if (tcs != null) {
tcs.setError(error);
}
}
return task;
}
JSONObject json = task.getResult();
if (tcs != null) {
tcs.setResult(json);
} else if (localId != null) {
// If this command created a new objectId, add it to the map.
String objectId = json.optString("objectId", null);
if (objectId != null) {
ParseCorePlugins.getInstance()
.getLocalIdManager().setObjectId(localId, objectId);
}
}
return task;
}
});
}
waitForTaskWithoutLock(commandTask);
if (tcs != null) {
waitForTaskWithoutLock(tcs.getTask());
}
// The command succeeded. Remove it from the cache.
removeFile(file);
notifyTestHelper(TestHelper.COMMAND_SUCCESSFUL);
} catch (ParseException e) {
if (e.getCode() == ParseException.CONNECTION_FAILED) {
if (retriesRemaining > 0) {
// Reachability says we have a network connection, but we can't actually contact
// Parse. Wait N minutes, or until we get signaled again before doing anything else.
if (Parse.LOG_LEVEL_INFO >= Parse.getLogLevel()) {
log.info("Network timeout in command cache. Waiting for " + timeoutRetryWaitSeconds
+ " seconds and then retrying " + retriesRemaining + " times.");
}
long currentTime = System.currentTimeMillis();
long waitUntil = currentTime + (long) (timeoutRetryWaitSeconds * 1000);
while (currentTime < waitUntil) {
// We haven't waited long enough, but if we lost the connection,
// or should stop, just quit.
if (!isConnected() || shouldStop) {
if (Parse.LOG_LEVEL_INFO >= Parse.getLogLevel()) {
log.info("Aborting wait because runEventually thread should stop.");
}
return;
}
try {
lock.wait(waitUntil - currentTime);
} catch (InterruptedException ie) {
shouldStop = true;
}
currentTime = System.currentTimeMillis();
if (currentTime < (waitUntil - (long) (timeoutRetryWaitSeconds * 1000))) {
// This situation should be impossible, so it must mean the clock changed.
currentTime = (waitUntil - (long) (timeoutRetryWaitSeconds * 1000));
}
}
maybeRunAllCommandsNow(retriesRemaining - 1);
} else {
setConnected(false);
notifyTestHelper(TestHelper.NETWORK_DOWN);
}
} else {
if (Parse.LOG_LEVEL_ERROR >= Parse.getLogLevel()) {
log.log(Level.SEVERE, "Failed to run command.", e);
}
// Delete the command from the cache, even though it failed.
// Otherwise, we'll just keep trying it forever.
removeFile(file);
notifyTestHelper(TestHelper.COMMAND_FAILED, e);
}
}
}
}
}
/**
* The main function of the run loop thread. This function basically loops forever (unless pause
* is called). On each iteration, if it hasn't been told to stop, it calls maybeRunAllCommandsNow
* to try to execute everything queued up on disk. Then it waits until it gets signaled again by
* lock.notify(). Usually that happens as a result of either (1) Parse being initialized, (2)
* runEventually being called, or (3) the OS notifying that the network connection has been
* re-established.
*/
private void runLoop() {
if (Parse.LOG_LEVEL_INFO >= Parse.getLogLevel()) {
log.info("Parse command cache has started processing queued commands.");
}
// Make sure we marked as running.
synchronized (runningLock) {
if (running) {
// Don't run this thread more than once.
return;
} else {
running = true;
runningLock.notifyAll();
}
}
boolean shouldRun;
synchronized (lock) {
shouldRun = !(shouldStop || Thread.interrupted());
}
while (shouldRun) {
synchronized (lock) {
try {
maybeRunAllCommandsNow(timeoutMaxRetries);
if (!shouldStop) {
try {
/*
* If an unprocessed command was added, avoid waiting because we want
* maybeRunAllCommandsNow to run at least once to potentially process that command.
*/
if (!unprocessedCommandsExist) {
lock.wait();
}
} catch (InterruptedException e) {
shouldStop = true;
}
}
} catch (Exception e) {
if (Parse.LOG_LEVEL_ERROR >= Parse.getLogLevel()) {
log.log(Level.SEVERE, "saveEventually thread had an error.", e);
}
} finally {
shouldRun = !shouldStop;
}
}
}
synchronized (runningLock) {
running = false;
runningLock.notifyAll();
}
if (Parse.LOG_LEVEL_INFO >= Parse.getLogLevel()) {
log.info("saveEventually thread has stopped processing commands.");
}
}
}