Poll jobs' status to update notifications.
Bug: 27249491
Change-Id: I8912c781582af1789c8f76dea06879a3dde75d34
diff --git a/packages/DocumentsUI/res/values/strings.xml b/packages/DocumentsUI/res/values/strings.xml
index be21b55..f7124e6 100644
--- a/packages/DocumentsUI/res/values/strings.xml
+++ b/packages/DocumentsUI/res/values/strings.xml
@@ -157,6 +157,8 @@
<string name="move_preparing">Preparing for move\u2026</string>
<!-- Text shown on the notification while DocumentsUI performs setup in preparation for deleting files [CHAR LIMIT=32] -->
<string name="delete_preparing">Preparing for delete\u2026</string>
+ <!-- Text progress shown on the notification while DocumentsUI is deleting files. -->
+ <string name="delete_progress"><xliff:g id="count" example="3">%1$d</xliff:g> / <xliff:g id="totalCount" example="5">%2$d</xliff:g></string>
<!-- Title of the copy error notification [CHAR LIMIT=48] -->
<plurals name="copy_error_notification_title">
<item quantity="one">Couldn\u2019t copy <xliff:g id="count" example="1">%1$d</xliff:g> file</item>
diff --git a/packages/DocumentsUI/src/com/android/documentsui/services/CopyJob.java b/packages/DocumentsUI/src/com/android/documentsui/services/CopyJob.java
index f10af43..d6f2e5b 100644
--- a/packages/DocumentsUI/src/com/android/documentsui/services/CopyJob.java
+++ b/packages/DocumentsUI/src/com/android/documentsui/services/CopyJob.java
@@ -21,6 +21,7 @@
import static android.provider.DocumentsContract.buildDocumentUri;
import static android.provider.DocumentsContract.getDocumentId;
import static android.provider.DocumentsContract.isChildDocument;
+
import static com.android.documentsui.OperationDialogFragment.DIALOG_TYPE_CONVERTED;
import static com.android.documentsui.Shared.DEBUG;
import static com.android.documentsui.model.DocumentInfo.getCursorLong;
@@ -45,8 +46,6 @@
import android.os.RemoteException;
import android.provider.DocumentsContract;
import android.provider.DocumentsContract.Document;
-import android.system.ErrnoException;
-import android.system.Os;
import android.text.format.DateUtils;
import android.util.Log;
import android.webkit.MimeTypeMap;
@@ -62,7 +61,6 @@
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
-import java.io.OutputStream;
import java.text.NumberFormat;
import java.util.ArrayList;
import java.util.List;
@@ -70,7 +68,6 @@
class CopyJob extends Job {
private static final String TAG = "CopyJob";
- private static final int PROGRESS_INTERVAL_MILLIS = 500;
final List<DocumentInfo> mSrcs;
final ArrayList<DocumentInfo> convertedFiles = new ArrayList<>();
@@ -78,8 +75,7 @@
private long mStartTime = -1;
private long mBatchSize;
- private long mBytesCopied;
- private long mLastNotificationTime;
+ private volatile long mBytesCopied;
// Speed estimation
private long mBytesCopiedSample;
private long mSampleTime;
@@ -127,16 +123,13 @@
return getSetupNotification(service.getString(R.string.copy_preparing));
}
- public boolean shouldUpdateProgress() {
- // Wait a while between updates :)
- return elapsedRealtime() - mLastNotificationTime > PROGRESS_INTERVAL_MILLIS;
- }
-
Notification getProgressNotification(@StringRes int msgId) {
+ updateRemainingTimeEstimate();
+
if (mBatchSize >= 0) {
double completed = (double) this.mBytesCopied / mBatchSize;
mProgressBuilder.setProgress(100, (int) (completed * 100), false);
- mProgressBuilder.setContentInfo(
+ mProgressBuilder.setSubText(
NumberFormat.getPercentInstance().format(completed));
} else {
// If the total file size failed to compute on some files, then show
@@ -153,12 +146,10 @@
mProgressBuilder.setContentText(null);
}
- // Remember when we last returned progress so we can provide an answer
- // in shouldUpdateProgress.
- mLastNotificationTime = elapsedRealtime();
return mProgressBuilder.build();
}
+ @Override
public Notification getProgressNotification() {
return getProgressNotification(R.string.copy_remaining);
}
@@ -170,11 +161,14 @@
/**
* Generates an estimate of the remaining time in the copy.
*/
- void updateRemainingTimeEstimate() {
+ private void updateRemainingTimeEstimate() {
long elapsedTime = elapsedRealtime() - mStartTime;
+ // mBytesCopied is modified in worker thread, but this method is called in monitor thread,
+ // so take a snapshot of mBytesCopied to make sure the updated estimate is consistent.
+ final long bytesCopied = mBytesCopied;
final long sampleDuration = elapsedTime - mSampleTime;
- final long sampleSpeed = ((mBytesCopied - mBytesCopiedSample) * 1000) / sampleDuration;
+ final long sampleSpeed = ((bytesCopied - mBytesCopiedSample) * 1000) / sampleDuration;
if (mSpeed == 0) {
mSpeed = sampleSpeed;
} else {
@@ -182,13 +176,13 @@
}
if (mSampleTime > 0 && mSpeed > 0) {
- mRemainingTime = ((mBatchSize - mBytesCopied) * 1000) / mSpeed;
+ mRemainingTime = ((mBatchSize - bytesCopied) * 1000) / mSpeed;
} else {
mRemainingTime = 0;
}
mSampleTime = elapsedTime;
- mBytesCopiedSample = mBytesCopied;
+ mBytesCopiedSample = bytesCopied;
}
@Override
@@ -273,10 +267,6 @@
*/
private void makeCopyProgress(long bytesCopied) {
onBytesCopied(bytesCopied);
- if (shouldUpdateProgress()) {
- updateRemainingTimeEstimate();
- listener.onProgress(this);
- }
}
/**
@@ -308,6 +298,7 @@
Log.e(TAG, "Provider side copy failed for: " + src.derivedUri
+ " due to an exception: " + e);
}
+
// If optimized copy fails, then fallback to byte-by-byte copy.
if (DEBUG) Log.d(TAG, "Fallback to byte-by-byte copy for: " + src.derivedUri);
}
@@ -418,14 +409,16 @@
src = DocumentInfo.fromCursor(cursor, srcDir.authority);
processDocument(src, srcDir, destDir);
} catch (RuntimeException e) {
- Log.e(TAG, "Failed to recursively process a file %s due to an exception."
- .format(srcDir.derivedUri.toString()), e);
+ Log.e(TAG, String.format(
+ "Failed to recursively process a file %s due to an exception.",
+ srcDir.derivedUri.toString()), e);
success = false;
}
}
} catch (RuntimeException e) {
- Log.e(TAG, "Failed to copy a file %s to %s. "
- .format(srcDir.derivedUri.toString(), destDir.derivedUri.toString()), e);
+ Log.e(TAG, String.format(
+ "Failed to copy a file %s to %s. ",
+ srcDir.derivedUri.toString(), destDir.derivedUri.toString()), e);
success = false;
} finally {
IoUtils.closeQuietly(cursor);
diff --git a/packages/DocumentsUI/src/com/android/documentsui/services/DeleteJob.java b/packages/DocumentsUI/src/com/android/documentsui/services/DeleteJob.java
index 8f45162..e9bdd2c 100644
--- a/packages/DocumentsUI/src/com/android/documentsui/services/DeleteJob.java
+++ b/packages/DocumentsUI/src/com/android/documentsui/services/DeleteJob.java
@@ -37,6 +37,8 @@
private List<DocumentInfo> mSrcs;
final DocumentInfo mSrcParent;
+ private volatile int mDocsProcessed = 0;
+
/**
* Moves files to a destination identified by {@code destination}.
* Performs most work by delegating to CopyJob, then deleting
@@ -69,6 +71,17 @@
}
@Override
+ public Notification getProgressNotification() {
+ mProgressBuilder.setProgress(mSrcs.size(), mDocsProcessed, false);
+ String format = service.getString(R.string.delete_progress);
+ mProgressBuilder.setSubText(String.format(format, mDocsProcessed, mSrcs.size()));
+
+ mProgressBuilder.setContentText(null);
+
+ return mProgressBuilder.build();
+ }
+
+ @Override
Notification getFailureNotification() {
return getFailureNotification(
R.plurals.delete_error_notification_title, R.drawable.ic_menu_delete);
@@ -85,10 +98,17 @@
if (DEBUG) Log.d(TAG, "Deleting document @ " + doc.derivedUri);
try {
deleteDocument(doc, mSrcParent);
+
+ if (isCanceled()) {
+ // Canceled, dump the rest of the work. Deleted docs are not recoverable.
+ return;
+ }
} catch (ResourceException e) {
Log.e(TAG, "Failed to delete document @ " + doc.derivedUri);
onFileFailed(doc);
}
+
+ ++mDocsProcessed;
}
Metrics.logFileOperation(service, operationType, mSrcs, null);
}
diff --git a/packages/DocumentsUI/src/com/android/documentsui/services/FileOperationService.java b/packages/DocumentsUI/src/com/android/documentsui/services/FileOperationService.java
index 36a279b..a3bff90 100644
--- a/packages/DocumentsUI/src/com/android/documentsui/services/FileOperationService.java
+++ b/packages/DocumentsUI/src/com/android/documentsui/services/FileOperationService.java
@@ -22,6 +22,7 @@
import android.app.NotificationManager;
import android.app.Service;
import android.content.Intent;
+import android.os.Handler;
import android.os.IBinder;
import android.os.PowerManager;
import android.support.annotation.Nullable;
@@ -35,6 +36,7 @@
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
+import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@@ -85,10 +87,13 @@
// a sub-optimal arrangement.
@VisibleForTesting ExecutorService executor;
- // Use a separate thread pool to prioritize deletions
+ // Use a separate thread pool to prioritize deletions.
@VisibleForTesting ExecutorService deletionExecutor;
@VisibleForTesting Factory jobFactory;
+ // Use a handler to schedule monitor tasks.
+ @VisibleForTesting Handler handler;
+
private PowerManager mPowerManager;
private PowerManager.WakeLock mWakeLock; // the wake lock, if held.
private NotificationManager mNotificationManager;
@@ -113,6 +118,11 @@
jobFactory = Job.Factory.instance;
}
+ if (handler == null) {
+ // Monitor tasks are small enough to schedule them on main thread.
+ handler = new Handler();
+ }
+
if (DEBUG) Log.d(TAG, "Created.");
mPowerManager = getSystemService(PowerManager.class);
mNotificationManager = getSystemService(NotificationManager.class);
@@ -121,11 +131,20 @@
@Override
public void onDestroy() {
if (DEBUG) Log.d(TAG, "Shutting down executor.");
- List<Runnable> unfinished = executor.shutdownNow();
+
+ List<Runnable> unfinishedCopies = executor.shutdownNow();
+ List<Runnable> unfinishedDeletions = deletionExecutor.shutdownNow();
+ List<Runnable> unfinished =
+ new ArrayList<>(unfinishedCopies.size() + unfinishedDeletions.size());
+ unfinished.addAll(unfinishedCopies);
+ unfinished.addAll(unfinishedDeletions);
if (!unfinished.isEmpty()) {
Log.w(TAG, "Shutting down, but executor reports running jobs: " + unfinished);
}
+
executor = null;
+ deletionExecutor = null;
+ handler = null;
if (DEBUG) Log.d(TAG, "Destroyed.");
}
@@ -154,7 +173,6 @@
// Track the service supplied id so we can stop the service once we're out of work to do.
mLastServiceId = serviceId;
- Job job = null;
synchronized (mRunning) {
if (mWakeLock == null) {
mWakeLock = mPowerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, TAG);
@@ -164,7 +182,7 @@
DocumentInfo srcParent = intent.getParcelableExtra(EXTRA_SRC_PARENT);
DocumentStack stack = intent.getParcelableExtra(Shared.EXTRA_STACK);
- job = createJob(operationType, jobId, srcs, srcParent, stack);
+ Job job = createJob(operationType, jobId, srcs, srcParent, stack);
if (job == null) {
return;
@@ -301,40 +319,45 @@
@Override
public void onStart(Job job) {
if (DEBUG) Log.d(TAG, "onStart: " + job.id);
- mNotificationManager.notify(job.id, NOTIFICATION_ID_PROGRESS, job.getSetupNotification());
+
+ // Show start up notification
+ mNotificationManager.notify(
+ job.id, NOTIFICATION_ID_PROGRESS, job.getSetupNotification());
+
+ // Set up related monitor
+ JobMonitor monitor = new JobMonitor(job, mNotificationManager, handler);
+ monitor.start();
}
@Override
public void onFinished(Job job) {
+ assert(job.isFinished());
if (DEBUG) Log.d(TAG, "onFinished: " + job.id);
- // Dismiss the ongoing copy notification when the copy is done.
- mNotificationManager.cancel(job.id, NOTIFICATION_ID_PROGRESS);
+ // Use the same thread of monitors to tackle notifications to avoid race conditions.
+ // Otherwise we may fail to dismiss progress notification.
+ handler.post(() -> {
+ // Dismiss the ongoing copy notification when the copy is done.
+ mNotificationManager.cancel(job.id, NOTIFICATION_ID_PROGRESS);
- if (job.hasFailures()) {
- Log.e(TAG, "Job failed on files: " + job.failedFiles.size() + ".");
- mNotificationManager.notify(
- job.id, NOTIFICATION_ID_FAILURE, job.getFailureNotification());
- }
+ if (job.hasFailures()) {
+ Log.e(TAG, "Job failed on files: " + job.failedFiles.size() + ".");
+ mNotificationManager.notify(
+ job.id, NOTIFICATION_ID_FAILURE, job.getFailureNotification());
+ }
- if (job.hasWarnings()) {
- if (DEBUG) Log.d(TAG, "Job finished with warnings.");
- mNotificationManager.notify(
- job.id, NOTIFICATION_ID_WARNING, job.getWarningNotification());
- }
+ if (job.hasWarnings()) {
+ if (DEBUG) Log.d(TAG, "Job finished with warnings.");
+ mNotificationManager.notify(
+ job.id, NOTIFICATION_ID_WARNING, job.getWarningNotification());
+ }
+ });
synchronized (mRunning) {
deleteJob(job);
}
}
- @Override
- public void onProgress(CopyJob job) {
- if (DEBUG) Log.d(TAG, "onProgress: " + job.id);
- mNotificationManager.notify(
- job.id, NOTIFICATION_ID_PROGRESS, job.getProgressNotification());
- }
-
private static final class JobRecord {
private final Job job;
private final Future<?> future;
@@ -345,6 +368,47 @@
}
}
+ /**
+ * A class used to periodically polls state of a job.
+ *
+ * <p>It's possible that jobs hang because underlying document providers stop responding. We
+ * still need to update notifications if jobs hang, so instead of jobs pushing their states,
+ * we poll states of jobs.
+ */
+ private static final class JobMonitor implements Runnable {
+ private static final long INITIAL_PROGRESS_DELAY_MILLIS = 10L;
+ private static final long PROGRESS_INTERVAL_MILLIS = 500L;
+
+ private final Job mJob;
+ private final NotificationManager mNotificationManager;
+ private final Handler mHandler;
+
+ private JobMonitor(Job job, NotificationManager notificationManager, Handler handler) {
+ mJob = job;
+ mNotificationManager = notificationManager;
+ mHandler = handler;
+ }
+
+ private void start() {
+ // Delay the first update to avoid dividing by 0 when calculate speed
+ mHandler.postDelayed(this, INITIAL_PROGRESS_DELAY_MILLIS);
+ }
+
+ @Override
+ public void run() {
+ if (mJob.isFinished()) {
+ // Finish notification is already shown. Progress notification is removed.
+ // Just finish itself.
+ return;
+ }
+
+ mNotificationManager.notify(
+ mJob.id, NOTIFICATION_ID_PROGRESS, mJob.getProgressNotification());
+
+ mHandler.postDelayed(this, PROGRESS_INTERVAL_MILLIS);
+ }
+ }
+
@Override
public IBinder onBind(Intent intent) {
return null; // Boilerplate. See super#onBind
diff --git a/packages/DocumentsUI/src/com/android/documentsui/services/Job.java b/packages/DocumentsUI/src/com/android/documentsui/services/Job.java
index b8f8fba..fc3a731 100644
--- a/packages/DocumentsUI/src/com/android/documentsui/services/Job.java
+++ b/packages/DocumentsUI/src/com/android/documentsui/services/Job.java
@@ -25,6 +25,7 @@
import static com.android.documentsui.services.FileOperationService.OPERATION_UNKNOWN;
import android.annotation.DrawableRes;
+import android.annotation.IntDef;
import android.annotation.PluralsRes;
import android.app.Notification;
import android.app.Notification.Builder;
@@ -48,6 +49,8 @@
import com.android.documentsui.model.DocumentStack;
import com.android.documentsui.services.FileOperationService.OpType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
@@ -60,6 +63,18 @@
abstract public class Job implements Runnable {
private static final String TAG = "Job";
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({STATE_CREATED, STATE_STARTED, STATE_COMPLETED, STATE_CANCELED})
+ @interface State {}
+ static final int STATE_CREATED = 0;
+ static final int STATE_STARTED = 1;
+ static final int STATE_COMPLETED = 2;
+ /**
+ * A job is in canceled state as long as {@link #cancel()} is called on it, even after it is
+ * completed.
+ */
+ static final int STATE_CANCELED = 3;
+
static final String INTENT_TAG_WARNING = "warning";
static final String INTENT_TAG_FAILURE = "failure";
static final String INTENT_TAG_PROGRESS = "progress";
@@ -77,7 +92,7 @@
final Notification.Builder mProgressBuilder;
private final Map<String, ContentProviderClient> mClients = new HashMap<>();
- private volatile boolean mCanceled;
+ private volatile @State int mState = STATE_CREATED;
/**
* A simple progressable job, much like an AsyncTask, but with support
@@ -111,6 +126,12 @@
@Override
public final void run() {
+ if (isCanceled()) {
+ // Canceled before running
+ return;
+ }
+
+ mState = STATE_STARTED;
listener.onStart(this);
try {
start();
@@ -120,6 +141,7 @@
Log.e(TAG, "Operation failed due to an unhandled runtime exception.", e);
Metrics.logFileOperationErrors(service, operationType, failedFiles);
} finally {
+ mState = (mState == STATE_STARTED) ? STATE_COMPLETED : mState;
listener.onFinished(this);
}
}
@@ -127,8 +149,7 @@
abstract void start();
abstract Notification getSetupNotification();
- // TODO: Progress notification for deletes.
- // abstract Notification getProgressNotification(long bytesCopied);
+ abstract Notification getProgressNotification();
abstract Notification getFailureNotification();
abstract Notification getWarningNotification();
@@ -158,13 +179,21 @@
}
}
+ final @State int getState() {
+ return mState;
+ }
+
final void cancel() {
- mCanceled = true;
+ mState = STATE_CANCELED;
Metrics.logFileOperationCancelled(service, operationType);
}
final boolean isCanceled() {
- return mCanceled;
+ return mState == STATE_CANCELED;
+ }
+
+ final boolean isFinished() {
+ return mState == STATE_CANCELED || mState == STATE_COMPLETED;
}
final ContentResolver getContentResolver() {
@@ -321,6 +350,5 @@
interface Listener {
void onStart(Job job);
void onFinished(Job job);
- void onProgress(CopyJob job);
}
}
diff --git a/packages/DocumentsUI/tests/src/com/android/documentsui/services/FileOperationServiceTest.java b/packages/DocumentsUI/tests/src/com/android/documentsui/services/FileOperationServiceTest.java
index f385776..9d6e1d7 100644
--- a/packages/DocumentsUI/tests/src/com/android/documentsui/services/FileOperationServiceTest.java
+++ b/packages/DocumentsUI/tests/src/com/android/documentsui/services/FileOperationServiceTest.java
@@ -20,6 +20,7 @@
import static com.android.documentsui.services.FileOperationService.OPERATION_DELETE;
import static com.android.documentsui.services.FileOperations.createBaseIntent;
import static com.android.documentsui.services.FileOperations.createJobId;
+
import static com.google.android.collect.Lists.newArrayList;
import android.content.Context;
@@ -31,13 +32,11 @@
import com.android.documentsui.model.DocumentInfo;
import com.android.documentsui.model.DocumentStack;
import com.android.documentsui.services.Job.Listener;
+import com.android.documentsui.testing.TestHandler;
import java.util.ArrayList;
import java.util.List;
-/**
- * TODO: Test progress updates.
- */
@MediumTest
public class FileOperationServiceTest extends ServiceTestCase<FileOperationService> {
@@ -49,6 +48,7 @@
private FileOperationService mService;
private TestScheduledExecutorService mExecutor;
private TestScheduledExecutorService mDeletionExecutor;
+ private TestHandler mHandler;
private TestJobFactory mJobFactory;
public FileOperationServiceTest() {
@@ -62,6 +62,7 @@
mExecutor = new TestScheduledExecutorService();
mDeletionExecutor = new TestScheduledExecutorService();
+ mHandler = new TestHandler();
mJobFactory = new TestJobFactory();
// Install test doubles.
@@ -73,6 +74,9 @@
assertNull(mService.deletionExecutor);
mService.deletionExecutor = mDeletionExecutor;
+ assertNull(mService.handler);
+ mService.handler = mHandler;
+
assertNull(mService.jobFactory);
mService.jobFactory = mJobFactory;
}
@@ -128,6 +132,29 @@
mJobFactory.assertNoCopyJobsStarted();
}
+ public void testUpdatesNotification() throws Exception {
+ startService(createCopyIntent(newArrayList(ALPHA_DOC), BETA_DOC));
+ mExecutor.runAll();
+
+ // Assert monitoring continues until job is done
+ assertTrue(mHandler.hasScheduledMessage());
+ // Two notifications -- one for setup; one for progress
+ assertEquals(2, mJobFactory.copyJobs.get(0).getNumOfNotifications());
+ }
+
+ public void testStopsUpdatingNotificationAfterFinished() throws Exception {
+ startService(createCopyIntent(newArrayList(ALPHA_DOC), BETA_DOC));
+ mExecutor.runAll();
+
+ mHandler.dispatchNextMessage();
+ // Assert monitoring stops once job is completed.
+ assertFalse(mHandler.hasScheduledMessage());
+
+ // Assert no more notification is generated after finish.
+ assertEquals(2, mJobFactory.copyJobs.get(0).getNumOfNotifications());
+
+ }
+
public void testHoldsWakeLockWhileWorking() throws Exception {
startService(createCopyIntent(newArrayList(ALPHA_DOC), BETA_DOC));
@@ -154,11 +181,12 @@
startService(createCopyIntent(newArrayList(ALPHA_DOC), BETA_DOC));
mExecutor.assertAlive();
+ mDeletionExecutor.assertAlive();
mExecutor.runAll();
shutdownService();
- mExecutor.assertShutdown();
+ assertExecutorsShutdown();
}
public void testShutdownStopsExecutor_AfterMixedFailures() throws Exception {
@@ -170,7 +198,7 @@
mExecutor.runAll();
shutdownService();
- mExecutor.assertShutdown();
+ assertExecutorsShutdown();
}
public void testShutdownStopsExecutor_AfterTotalFailure() throws Exception {
@@ -183,7 +211,7 @@
mExecutor.runAll();
shutdownService();
- mExecutor.assertShutdown();
+ assertExecutorsShutdown();
}
private Intent createCopyIntent(ArrayList<DocumentInfo> files, DocumentInfo dest)
@@ -217,10 +245,21 @@
return destDoc;
}
+ private void assertExecutorsShutdown() {
+ mExecutor.assertShutdown();
+ mDeletionExecutor.assertShutdown();
+ }
+
private final class TestJobFactory extends Job.Factory {
- final List<TestJob> copyJobs = new ArrayList<>();
- final List<TestJob> deleteJobs = new ArrayList<>();
+ private final List<TestJob> copyJobs = new ArrayList<>();
+ private final List<TestJob> deleteJobs = new ArrayList<>();
+
+ private Runnable mJobRunnable = () -> {
+ // The following statement is executed concurrently to Job.start() in real situation.
+ // Call it in TestJob.start() to mimic this behavior.
+ mHandler.dispatchNextMessage();
+ };
void assertAllCopyJobsStarted() {
for (TestJob job : copyJobs) {
@@ -258,7 +297,8 @@
throw new RuntimeException("Empty srcs not supported!");
}
- TestJob job = new TestJob(service, appContext, listener, OPERATION_COPY, id, stack);
+ TestJob job = new TestJob(
+ service, appContext, listener, OPERATION_COPY, id, stack, mJobRunnable);
copyJobs.add(job);
return job;
}
@@ -271,7 +311,8 @@
throw new RuntimeException("Empty srcs not supported!");
}
- TestJob job = new TestJob(service, appContext, listener, OPERATION_DELETE, id, stack);
+ TestJob job = new TestJob(
+ service, appContext, listener, OPERATION_DELETE, id, stack, mJobRunnable);
deleteJobs.add(job);
return job;
diff --git a/packages/DocumentsUI/tests/src/com/android/documentsui/services/TestJob.java b/packages/DocumentsUI/tests/src/com/android/documentsui/services/TestJob.java
index c78f582..9104ff0 100644
--- a/packages/DocumentsUI/tests/src/com/android/documentsui/services/TestJob.java
+++ b/packages/DocumentsUI/tests/src/com/android/documentsui/services/TestJob.java
@@ -22,6 +22,7 @@
import android.app.Notification;
import android.app.Notification.Builder;
import android.content.Context;
+import android.icu.text.NumberFormat;
import com.android.documentsui.R;
import com.android.documentsui.model.DocumentInfo;
@@ -30,16 +31,23 @@
public class TestJob extends Job {
private boolean mStarted;
+ private Runnable mStartRunnable;
+
+ private int mNumOfNotifications = 0;
TestJob(
Context service, Context appContext, Listener listener,
- int operationType, String id, DocumentStack stack) {
+ int operationType, String id, DocumentStack stack, Runnable startRunnable) {
super(service, appContext, listener, operationType, id, stack);
+
+ mStartRunnable = startRunnable;
}
@Override
void start() {
mStarted = true;
+
+ mStartRunnable.run();
}
void assertStarted() {
@@ -54,12 +62,27 @@
onFileFailed(doc);
}
+ int getNumOfNotifications() {
+ return mNumOfNotifications;
+ }
+
@Override
Notification getSetupNotification() {
+ ++mNumOfNotifications;
return getSetupNotification(service.getString(R.string.copy_preparing));
}
@Override
+ Notification getProgressNotification() {
+ ++mNumOfNotifications;
+ double completed = mStarted ? 1F : 0F;
+ return mProgressBuilder
+ .setProgress(1, (int) completed, true)
+ .setSubText(NumberFormat.getPercentInstance().format(completed))
+ .build();
+ }
+
+ @Override
Notification getFailureNotification() {
// the "copy" stuff was just convenient and available :)
return getFailureNotification(
@@ -73,6 +96,7 @@
@Override
Builder createProgressBuilder() {
+ ++mNumOfNotifications;
// the "copy" stuff was just convenient and available :)
return super.createProgressBuilder(
service.getString(R.string.copy_notification_title),
diff --git a/packages/DocumentsUI/tests/src/com/android/documentsui/services/TestJobListener.java b/packages/DocumentsUI/tests/src/com/android/documentsui/services/TestJobListener.java
index 46b093d..e9c68c6 100644
--- a/packages/DocumentsUI/tests/src/com/android/documentsui/services/TestJobListener.java
+++ b/packages/DocumentsUI/tests/src/com/android/documentsui/services/TestJobListener.java
@@ -46,11 +46,6 @@
latch.countDown();
}
- @Override
- public void onProgress(CopyJob job) {
- progress.add(job);
- }
-
public void assertStarted() {
if (started == null) {
fail("Job didn't start. onStart never called.");
diff --git a/packages/DocumentsUI/tests/src/com/android/documentsui/testing/TestHandler.java b/packages/DocumentsUI/tests/src/com/android/documentsui/testing/TestHandler.java
new file mode 100644
index 0000000..c18ef1f
--- /dev/null
+++ b/packages/DocumentsUI/tests/src/com/android/documentsui/testing/TestHandler.java
@@ -0,0 +1,64 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.documentsui.testing;
+
+import android.os.Handler;
+import android.os.Looper;
+import android.os.Message;
+
+import java.util.TimerTask;
+
+/**
+ * A test double of {@link Handler}, backed by {@link TestTimer}.
+ */
+public class TestHandler extends Handler {
+ private TestTimer mTimer = new TestTimer();
+
+ public TestHandler() {
+ // Use main looper to trick underlying handler, we're not using it at all.
+ super(Looper.getMainLooper());
+ }
+
+ public boolean hasScheduledMessage() {
+ return mTimer.hasScheduledTask();
+ }
+
+ public void dispatchNextMessage() {
+ mTimer.fastForwardToNextTask();
+ }
+
+ @Override
+ public boolean sendMessageAtTime(Message msg, long uptimeMillis) {
+ msg.setTarget(this);
+ TimerTask task = new MessageTimerTask(msg);
+ mTimer.scheduleAtTime(new TestTimer.Task(task), uptimeMillis);
+ return true;
+ }
+
+ private static class MessageTimerTask extends TimerTask {
+ private Message mMessage;
+
+ private MessageTimerTask(Message message) {
+ mMessage = message;
+ }
+
+ @Override
+ public void run() {
+ mMessage.getTarget().dispatchMessage(mMessage);
+ }
+ }
+}
diff --git a/packages/DocumentsUI/tests/src/com/android/documentsui/testing/TestTimer.java b/packages/DocumentsUI/tests/src/com/android/documentsui/testing/TestTimer.java
index 428e8bd..04af283 100644
--- a/packages/DocumentsUI/tests/src/com/android/documentsui/testing/TestTimer.java
+++ b/packages/DocumentsUI/tests/src/com/android/documentsui/testing/TestTimer.java
@@ -47,6 +47,17 @@
}
}
+ public boolean hasScheduledTask() {
+ return !mTaskList.isEmpty();
+ }
+
+ public void fastForwardToNextTask() {
+ if (!hasScheduledTask()) {
+ throw new IllegalStateException("There is no scheduled task!");
+ }
+ fastForwardTo(mTaskList.getFirst().mExecuteTime);
+ }
+
@Override
public void cancel() {
mTaskList.clear();
@@ -68,7 +79,8 @@
@Override
public void schedule(TimerTask task, Date time) {
- throw new UnsupportedOperationException();
+ long executeTime = time.getTime();
+ scheduleAtTime(task, executeTime);
}
@Override
@@ -79,16 +91,7 @@
@Override
public void schedule(TimerTask task, long delay) {
long executeTime = mNow + delay;
- Task testTimerTask = (Task) task;
- testTimerTask.mExecuteTime = executeTime;
-
- ListIterator<Task> iter = mTaskList.listIterator(0);
- while (iter.hasNext()) {
- if (iter.next().mExecuteTime >= executeTime) {
- break;
- }
- }
- iter.add(testTimerTask);
+ scheduleAtTime(task, executeTime);
}
@Override
@@ -106,6 +109,19 @@
throw new UnsupportedOperationException();
}
+ public void scheduleAtTime(TimerTask task, long executeTime) {
+ Task testTimerTask = (Task) task;
+ testTimerTask.mExecuteTime = executeTime;
+
+ ListIterator<Task> iter = mTaskList.listIterator(0);
+ while (iter.hasNext()) {
+ if (iter.next().mExecuteTime >= executeTime) {
+ break;
+ }
+ }
+ iter.add(testTimerTask);
+ }
+
public static class Task extends TimerTask {
private boolean mIsCancelled;
private long mExecuteTime;