Merge "Poll jobs' status to update notifications."
diff --git a/packages/DocumentsUI/res/values/strings.xml b/packages/DocumentsUI/res/values/strings.xml
index c221385..bf34461 100644
--- a/packages/DocumentsUI/res/values/strings.xml
+++ b/packages/DocumentsUI/res/values/strings.xml
@@ -159,6 +159,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;